diff --git a/doc/users/next_whats_new/slider_styling.rst b/doc/users/next_whats_new/slider_styling.rst new file mode 100644 index 000000000000..f007954b4806 --- /dev/null +++ b/doc/users/next_whats_new/slider_styling.rst @@ -0,0 +1,42 @@ +Updated the appearance of Slider widgets +---------------------------------------- + +The appearance of `~.Slider` and `~.RangeSlider` widgets +were updated and given new styling parameters for the +added handles. + +.. plot:: + + import matplotlib.pyplot as plt + from matplotlib.widgets import Slider + + plt.figure(figsize=(4, 2)) + ax_old = plt.axes([0.2, 0.65, 0.65, 0.1]) + ax_new = plt.axes([0.2, 0.25, 0.65, 0.1]) + Slider(ax_new, "New", 0, 1) + + ax = ax_old + valmin = 0 + valinit = 0.5 + ax.set_xlim([0, 1]) + ax_old.axvspan(valmin, valinit, 0, 1) + ax.axvline(valinit, 0, 1, color="r", lw=1) + ax.set_xticks([]) + ax.set_yticks([]) + ax.text( + -0.02, + 0.5, + "Old", + transform=ax.transAxes, + verticalalignment="center", + horizontalalignment="right", + ) + + ax.text( + 1.02, + 0.5, + "0.5", + transform=ax.transAxes, + verticalalignment="center", + horizontalalignment="left", + ) diff --git a/examples/widgets/slider_demo.py b/examples/widgets/slider_demo.py index aa33315d3db4..ffe66279a77b 100644 --- a/examples/widgets/slider_demo.py +++ b/examples/widgets/slider_demo.py @@ -32,14 +32,11 @@ def f(t, amplitude, frequency): line, = plt.plot(t, f(t, init_amplitude, init_frequency), lw=2) ax.set_xlabel('Time [s]') -axcolor = 'lightgoldenrodyellow' -ax.margins(x=0) - # adjust the main plot to make room for the sliders plt.subplots_adjust(left=0.25, bottom=0.25) # Make a horizontal slider to control the frequency. -axfreq = plt.axes([0.25, 0.1, 0.65, 0.03], facecolor=axcolor) +axfreq = plt.axes([0.25, 0.1, 0.65, 0.03]) freq_slider = Slider( ax=axfreq, label='Frequency [Hz]', @@ -49,7 +46,7 @@ def f(t, amplitude, frequency): ) # Make a vertically oriented slider to control the amplitude -axamp = plt.axes([0.1, 0.25, 0.0225, 0.63], facecolor=axcolor) +axamp = plt.axes([0.1, 0.25, 0.0225, 0.63]) amp_slider = Slider( ax=axamp, label="Amplitude", @@ -72,7 +69,7 @@ def update(val): # Create a `matplotlib.widgets.Button` to reset the sliders to initial values. resetax = plt.axes([0.8, 0.025, 0.1, 0.04]) -button = Button(resetax, 'Reset', color=axcolor, hovercolor='0.975') +button = Button(resetax, 'Reset', hovercolor='0.975') def reset(event): diff --git a/examples/widgets/slider_snap_demo.py b/examples/widgets/slider_snap_demo.py index e985f84ca7ac..f02dd9a6b961 100644 --- a/examples/widgets/slider_snap_demo.py +++ b/examples/widgets/slider_snap_demo.py @@ -28,9 +28,8 @@ plt.subplots_adjust(bottom=0.25) l, = plt.plot(t, s, lw=2) -slider_bkd_color = 'lightgoldenrodyellow' -ax_freq = plt.axes([0.25, 0.1, 0.65, 0.03], facecolor=slider_bkd_color) -ax_amp = plt.axes([0.25, 0.15, 0.65, 0.03], facecolor=slider_bkd_color) +ax_freq = plt.axes([0.25, 0.1, 0.65, 0.03]) +ax_amp = plt.axes([0.25, 0.15, 0.65, 0.03]) # define the values to use for snapping allowed_amplitudes = np.concatenate([np.linspace(.1, 5, 100), [6, 7, 8, 9]]) @@ -60,7 +59,7 @@ def update(val): samp.on_changed(update) ax_reset = plt.axes([0.8, 0.025, 0.1, 0.04]) -button = Button(ax_reset, 'Reset', color=slider_bkd_color, hovercolor='0.975') +button = Button(ax_reset, 'Reset', hovercolor='0.975') def reset(event): diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index f2ac7749d6ea..6784ac2ab60e 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -332,7 +332,7 @@ def test_slider_horizontal_vertical(): assert slider.val == 10 # check the dimension of the slider patch in axes units box = slider.poly.get_extents().transformed(ax.transAxes.inverted()) - assert_allclose(box.bounds, [0, 0, 10/24, 1]) + assert_allclose(box.bounds, [0, .25, 10/24, .5]) fig, ax = plt.subplots() slider = widgets.Slider(ax=ax, label='', valmin=0, valmax=24, @@ -341,7 +341,7 @@ def test_slider_horizontal_vertical(): assert slider.val == 10 # check the dimension of the slider patch in axes units box = slider.poly.get_extents().transformed(ax.transAxes.inverted()) - assert_allclose(box.bounds, [0, 0, 1, 10/24]) + assert_allclose(box.bounds, [.25, 0, .5, 10/24]) @pytest.mark.parametrize("orientation", ["horizontal", "vertical"]) @@ -358,7 +358,7 @@ def test_range_slider(orientation): valinit=[0.1, 0.34] ) box = slider.poly.get_extents().transformed(ax.transAxes.inverted()) - assert_allclose(box.get_points().flatten()[idx], [0.1, 0, 0.34, 1]) + assert_allclose(box.get_points().flatten()[idx], [0.1, 0.25, 0.34, 0.75]) # Check initial value is set correctly assert_allclose(slider.val, (0.1, 0.34)) @@ -366,7 +366,7 @@ def test_range_slider(orientation): slider.set_val((0.2, 0.6)) assert_allclose(slider.val, (0.2, 0.6)) box = slider.poly.get_extents().transformed(ax.transAxes.inverted()) - assert_allclose(box.get_points().flatten()[idx], [0.2, 0, 0.6, 1]) + assert_allclose(box.get_points().flatten()[idx], [0.2, .25, 0.6, .75]) slider.set_val((0.2, 0.1)) assert_allclose(slider.val, (0.1, 0.2)) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 7d2243f6c553..09573e45950e 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -267,9 +267,9 @@ def __init__(self, ax, orientation, closedmin, closedmax, self._fmt.set_useOffset(False) # No additive offset. self._fmt.set_useMathText(True) # x sign before multiplicative offset. - ax.set_xticks([]) - ax.set_yticks([]) + ax.set_axis_off() ax.set_navigate(False) + self.connect_event("button_press_event", self._update) self.connect_event("button_release_event", self._update) if dragging: @@ -329,7 +329,8 @@ class Slider(SliderBase): def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt=None, closedmin=True, closedmax=True, slidermin=None, slidermax=None, dragging=True, valstep=None, - orientation='horizontal', *, initcolor='r', **kwargs): + orientation='horizontal', *, initcolor='r', + track_color='lightgrey', handle_style=None, **kwargs): """ Parameters ---------- @@ -380,11 +381,30 @@ def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt=None, The color of the line at the *valinit* position. Set to ``'none'`` for no line. + track_color : color, default: 'lightgrey' + The color of the background track. The track is accessible for + further styling via the *track* attribute. + + handle_style : dict + Properties of the slider handle. Default values are + + ========= ===== ======= ======================================== + Key Value Default Description + ========= ===== ======= ======================================== + facecolor color 'white' The facecolor of the slider handle. + edgecolor color '.75' The edgecolor of the slider handle. + size int 10 The size of the slider handle in points. + ========= ===== ======= ======================================== + + Other values will be transformed as marker{foo} and passed to the + `~.Line2D` constructor. e.g. ``handle_style = {'style'='x'}`` will + result in ``markerstyle = 'x'``. + Notes ----- Additional kwargs are passed on to ``self.poly`` which is the - `~matplotlib.patches.Rectangle` that draws the slider knob. See the - `.Rectangle` documentation for valid property names (``facecolor``, + `~matplotlib.patches.Polygon` that draws the slider knob. See the + `.Polygon` documentation for valid property names (``facecolor``, ``edgecolor``, ``alpha``, etc.). """ super().__init__(ax, orientation, closedmin, closedmax, @@ -403,12 +423,44 @@ def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt=None, valinit = valmin self.val = valinit self.valinit = valinit + + defaults = {'facecolor': 'white', 'edgecolor': '.75', 'size': 10} + handle_style = {} if handle_style is None else handle_style + marker_props = { + f'marker{k}': v for k, v in {**defaults, **handle_style}.items() + } + if orientation == 'vertical': - self.poly = ax.axhspan(valmin, valinit, 0, 1, **kwargs) - self.hline = ax.axhline(valinit, 0, 1, color=initcolor, lw=1) + self.track = Rectangle( + (.25, 0), .5, 1, + transform=ax.transAxes, + facecolor=track_color + ) + ax.add_patch(self.track) + self.poly = ax.axhspan(valmin, valinit, .25, .75, **kwargs) + self.hline = ax.axhline(valinit, .15, .85, color=initcolor, lw=1) + handleXY = [[0.5], [valinit]] else: - self.poly = ax.axvspan(valmin, valinit, 0, 1, **kwargs) - self.vline = ax.axvline(valinit, 0, 1, color=initcolor, lw=1) + self.track = Rectangle( + (0, .25), 1, .5, + transform=ax.transAxes, + facecolor=track_color + ) + ax.add_patch(self.track) + self.poly = ax.axvspan(valmin, valinit, .25, .75, **kwargs) + # These asymmetric limits (.2, .9) minimize the asymmetry + # above and below the *poly* when rendered to pixels. + # This seems to be different for Horizontal and Vertical lines. + # For discussion see: + # https://github.com/matplotlib/matplotlib/pull/19265 + self.vline = ax.axvline(valinit, .2, .9, color=initcolor, lw=1) + handleXY = [[valinit], [0.5]] + self._handle, = ax.plot( + *handleXY, + "o", + **marker_props, + clip_on=False + ) if orientation == 'vertical': self.label = ax.text(0.5, 1.02, label, transform=ax.transAxes, @@ -499,11 +551,13 @@ def set_val(self, val): """ xy = self.poly.xy if self.orientation == 'vertical': - xy[1] = 0, val - xy[2] = 1, val + xy[1] = .25, val + xy[2] = .75, val + self._handle.set_ydata([val]) else: - xy[2] = val, 1 - xy[3] = val, 0 + xy[2] = val, .75 + xy[3] = val, .25 + self._handle.set_xdata([val]) self.poly.xy = xy self.valtext.set_text(self._format(val)) if self.drawon: @@ -558,6 +612,8 @@ def __init__( dragging=True, valstep=None, orientation="horizontal", + track_color='lightgrey', + handle_style=None, **kwargs, ): """ @@ -598,11 +654,30 @@ def __init__( orientation : {'horizontal', 'vertical'}, default: 'horizontal' The orientation of the slider. + track_color : color, default: 'lightgrey' + The color of the background track. The track is accessible for + further styling via the *track* attribute. + + handle_style : dict + Properties of the slider handles. Default values are + + ========= ===== ======= ========================================= + Key Value Default Description + ========= ===== ======= ========================================= + facecolor color 'white' The facecolor of the slider handles. + edgecolor color '.75' The edgecolor of the slider handles. + size int 10 The size of the slider handles in points. + ========= ===== ======= ========================================= + + Other values will be transformed as marker{foo} and passed to the + `~.Line2D` constructor. e.g. ``handle_style = {'style'='x'}`` will + result in ``markerstyle = 'x'``. + Notes ----- Additional kwargs are passed on to ``self.poly`` which is the - `~matplotlib.patches.Rectangle` that draws the slider knob. See the - `.Rectangle` documentation for valid property names (``facecolor``, + `~matplotlib.patches.Polygon` that draws the slider knob. See the + `.Polygon` documentation for valid property names (``facecolor``, ``edgecolor``, ``alpha``, etc.). """ super().__init__(ax, orientation, closedmin, closedmax, @@ -619,10 +694,47 @@ def __init__( valinit = self._value_in_bounds(valinit) self.val = valinit self.valinit = valinit + + defaults = {'facecolor': 'white', 'edgecolor': '.75', 'size': 10} + handle_style = {} if handle_style is None else handle_style + marker_props = { + f'marker{k}': v for k, v in {**defaults, **handle_style}.items() + } + if orientation == "vertical": + self.track = Rectangle( + (.25, 0), .5, 2, + transform=ax.transAxes, + facecolor=track_color + ) + ax.add_patch(self.track) self.poly = ax.axhspan(valinit[0], valinit[1], 0, 1, **kwargs) + handleXY_1 = [.5, valinit[0]] + handleXY_2 = [.5, valinit[1]] else: + self.track = Rectangle( + (0, .25), 1, .5, + transform=ax.transAxes, + facecolor=track_color + ) + ax.add_patch(self.track) self.poly = ax.axvspan(valinit[0], valinit[1], 0, 1, **kwargs) + handleXY_1 = [valinit[0], .5] + handleXY_2 = [valinit[1], .5] + self._handles = [ + ax.plot( + *handleXY_1, + "o", + **marker_props, + clip_on=False + )[0], + ax.plot( + *handleXY_2, + "o", + **marker_props, + clip_on=False + )[0] + ] if orientation == "vertical": self.label = ax.text( @@ -661,6 +773,7 @@ def __init__( horizontalalignment="left", ) + self._active_handle = None self.set_val(valinit) def _min_in_bounds(self, min): @@ -698,6 +811,8 @@ def _update_val_from_pos(self, pos): else: val = self._max_in_bounds(pos) self.set_max(val) + if self._active_handle: + self._active_handle.set_xdata([val]) def _update(self, event): """Update the slider position.""" @@ -716,7 +831,20 @@ def _update(self, event): ): self.drag_active = False event.canvas.release_mouse(self.ax) + self._active_handle = None return + + # determine which handle was grabbed + handle = self._handles[ + np.argmin( + np.abs([h.get_xdata()[0] - event.xdata for h in self._handles]) + ) + ] + # these checks ensure smooth behavior if the handles swap which one + # has a higher value. i.e. if one is dragged over and past the other. + if handle is not self._active_handle: + self._active_handle = handle + if self.orientation == "vertical": self._update_val_from_pos(event.ydata) else: @@ -773,17 +901,17 @@ def set_val(self, val): val[1] = self._max_in_bounds(val[1]) xy = self.poly.xy if self.orientation == "vertical": - xy[0] = 0, val[0] - xy[1] = 0, val[1] - xy[2] = 1, val[1] - xy[3] = 1, val[0] - xy[4] = 0, val[0] + xy[0] = .25, val[0] + xy[1] = .25, val[1] + xy[2] = .75, val[1] + xy[3] = .75, val[0] + xy[4] = .25, val[0] else: - xy[0] = val[0], 0 - xy[1] = val[0], 1 - xy[2] = val[1], 1 - xy[3] = val[1], 0 - xy[4] = val[0], 0 + xy[0] = val[0], .25 + xy[1] = val[0], .75 + xy[2] = val[1], .75 + xy[3] = val[1], .25 + xy[4] = val[0], .25 self.poly.xy = xy self.valtext.set_text(self._format(val)) if self.drawon: