Skip to content

New Styling for Sliders #19256

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
ianhi opened this issue Jan 8, 2021 · 4 comments · Fixed by #19265
Closed

New Styling for Sliders #19256

ianhi opened this issue Jan 8, 2021 · 4 comments · Fixed by #19265

Comments

@ianhi
Copy link
Contributor

ianhi commented Jan 8, 2021

Problem

I've never loved the way Matplotlib Slider widgets look (and per jupyter-widgets/ipywidgets#3025 (comment) I am apparently not alone). However, beyond that I think that the current way the slider widgets are drawn introduces two issue that hamper usability.

  1. There is no clear handle for the user to grab
  2. No visual feedback that you've grabbed the slider

<speculation> More broadly I suspect that most people's expectation of what a slider UI element will look like is strongly shaped by how they look on the web. So if Matplotlib sliders look more similar to web sliders then they will be more natural for users. </speculation>

Proposed Solution

Something to the effect of this:

script with new slider definition + an example

import numpy as np
from matplotlib.widgets import SliderBase
from matplotlib import _api
import matplotlib.patches as mpatches
from matplotlib import transforms

class newSlider(SliderBase):
    cnt = _api.deprecated("3.4")(property(# Not real, but close enough.
        lambda self: len(self._observers.callbacks['changed'])))
    observers = _api.deprecated("3.4")(property( lambda self: self._observers.callbacks['changed']))

    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',s=10, **kwargs):
        super().__init__(ax, orientation, closedmin, closedmax,
                                 valmin, valmax, valfmt, dragging, valstep)

        if slidermin is not None and not hasattr(slidermin, 'val'):
            raise ValueError(
                f"Argument slidermin ({type(slidermin)}) has no 'val'")
        if slidermax is not None and not hasattr(slidermax, 'val'):
            raise ValueError(
                f"Argument slidermax ({type(slidermax)}) has no 'val'")
        self.slidermin = slidermin
        self.slidermax = slidermax
        valinit = self._value_in_bounds(valinit)
        if valinit is None:
            valinit = valmin
        self.val = valinit
        self.valinit = valinit

        ax.axis('off')
        self.dot, = ax.plot([valinit],[.5], 'o', markersize=s, color='C0')
        trans = transforms.blended_transform_factory(
            ax.transData, ax.transAxes)
        self.rect = mpatches.Rectangle((valmin, .25), width=valinit-valmin, height=.5, transform=trans,
                              color='C0', alpha=0.75)
        self.above_rect = mpatches.Rectangle((valinit, .25), width=valmax-valinit, height=.5, transform=trans,
                              color='grey', alpha=0.5)
        ax.add_patch(self.rect)
        ax.add_patch(self.above_rect)

        self.label = ax.text(-0.02, 0.5, label, transform=ax.transAxes,
                             verticalalignment='center',
                             horizontalalignment='right')

        self.valtext = ax.text(1.02, 0.5, self._format(valinit),
                               transform=ax.transAxes,
                               verticalalignment='center',
                               horizontalalignment='left')

        self.set_val(valinit)


    def _format(self, val):
        """Pretty-print *val*."""
        if self.valfmt is not None:
            return self.valfmt % val
        else:
            _, s, _ = self._fmt.format_ticks([self.valmin, val, self.valmax])
            # fmt.get_offset is actually the multiplicative factor, if any.
            return s + self._fmt.get_offset()
        
    def _value_in_bounds(self, val):
        """Makes sure *val* is with given bounds."""
        val = self._stepped_value(val)

        if val <= self.valmin:
            if not self.closedmin:
                return
            val = self.valmin
        elif val >= self.valmax:
            if not self.closedmax:
                return
            val = self.valmax

        if self.slidermin is not None and val <= self.slidermin.val:
            if not self.closedmin:
                return
            val = self.slidermin.val

        if self.slidermax is not None and val >= self.slidermax.val:
            if not self.closedmax:
                return
            val = self.slidermax.val
        return val

    def _update(self, event):
        """Update the slider position."""
        if self.ignore(event) or event.button != 1:
            return

        if event.name == 'button_press_event' and event.inaxes == self.ax:
            self.drag_active = True
            event.canvas.grab_mouse(self.ax)

        if not self.drag_active:
            return

        elif ((event.name == 'button_release_event') or
              (event.name == 'button_press_event' and
               event.inaxes != self.ax)):
            self.drag_active = False
            event.canvas.release_mouse(self.ax)
            self.dot.set_markeredgecolor('grey')
            self.dot.set_markerfacecolor('grey')
            self.ax.figure.canvas.draw_idle()
            return

        if event.name == 'button_press_event':
            self.dot.set_markeredgecolor('C0')
            self.dot.set_markerfacecolor('C0')
        if self.orientation == 'vertical':
            val = self._value_in_bounds(event.ydata)
        else:
            val = self._value_in_bounds(event.xdata)
        if val not in [None, self.val]:
            self.set_val(val)
    def set_val(self, val):
        """
        Set slider value to *val*.
        Parameters
        ----------
        val : float
        """
        self.dot.set_xdata([val])
        self.rect.set_width(val)
        self.above_rect.set_x(val)
        self.valtext.set_text(self._format(val))
        if self.drawon:
            self.ax.figure.canvas.draw_idle()
        self.val = val
        if self.eventson:
            self._observers.process('changed', val)
    def on_changed(self, func):
        """
        Connect *func* as callback function to changes of the slider value.
        Parameters
        ----------
        func : callable
            Function to call when slider is changed.
            The function must accept a single float as its arguments.
        Returns
        -------
        int
            Connection id (which can be used to disconnect *func*).
        """
        return self._observers.connect('changed', lambda val: func(val))

def connnect_slider_to_ax(slider, ax):
    t = np.arange(0.0, 1.0, 0.001)
    f0 = 3
    delta_f = 5.0
    amp = 5
    s = amp * np.sin(2 * np.pi * f0 * t)
    l, = ax.plot(t, s, lw=2)
    def update(val):
        freq = slider.val
        l.set_ydata(amp*np.sin(2*np.pi*freq*t))
        # ax.figure.canvas.draw_idle()
    slider.on_changed(update)

if __name__ == '__main__':
    import matplotlib.pyplot as plt
    from matplotlib.widgets import Slider
    # new style
    fig, ax = plt.subplots()
    plt.subplots_adjust(bottom=0.25)
    new_slider_ax = plt.axes([0.25, 0.1, 0.65, 0.03])
    sNew = newSlider(new_slider_ax, 'Freq', 0.1, 30.0,valinit=5)
    connnect_slider_to_ax(sNew, ax)

    fig2, ax2 = plt.subplots()
    plt.subplots_adjust(bottom=0.25)
    old_slider_ax = plt.axes([0.25, 0.1, 0.65, 0.03])
    sOld = Slider(old_slider_ax, 'Freq', 0.1, 30.0, valinit=5)
    connnect_slider_to_ax(sOld, ax2)

    plt.show()

The above script will generate these two figures for comparison. On the left is my new proposed style, and on the right is the current style:
comparing-sliders

Additional context and prior art

https://www.smashingmagazine.com/2017/07/designing-perfect-slider/
https://material-ui.com/components/slider/
https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20List.html#IntSlider

@ianhi
Copy link
Contributor Author

ianhi commented Jan 8, 2021

Slider history: They were added in fa318da all the way back in 2005. The original motivation seems to have been for use in the subplot adjustment panel. Cool stuff :)

@QuLogic
Copy link
Member

QuLogic commented Jan 8, 2021

This doesn't seem that difficult; turn off Axes frame, set Axes patch to grey-ish, and add a largish scatter marker at the value (it would need to be clip_on=False to display outside the Axes.) The first two can be done on existing widgets already, but the latter would need code to be added.

@timhoffm
Copy link
Member

timhoffm commented Jan 8, 2021

👍 on the concept. Buttons and TextBoxes could also need a bit more care with styling.

@ianhi
Copy link
Contributor Author

ianhi commented Jan 9, 2021

urn off Axes frame, set Axes patch to grey-ish, and add a largish scatter marker at the value (it would need to be clip_on=False to display outside the Axes.) The first two can be done on existing widgets already, but the latter would need code to be added.

This is pretty much what I did for the above, except I used RectanglePatchs which makes it a little bit cleaner I think. But that would be backwards incompatible on account of slider.poly not longer be a polypath?

@QuLogic QuLogic added this to the v3.5.0 milestone Jul 7, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants