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:
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: