holoviz / panel

Panel: The powerful data exploration & web app framework for Python
https://panel.holoviz.org
BSD 3-Clause "New" or "Revised" License
4.73k stars 514 forks source link

Need a more flexible Number widget #1128

Open maximlt opened 4 years ago

maximlt commented 4 years ago

The problem

When I create a Param-based app that declares a Number object with an open-ended bound, the default widget instantiated by panel is a Spinner. See the code to reproduce that behaviour.

import param
import panel as pn

class Dummy(param.Parameterized):
    amount = param.Number(bounds=(0, None))

app = pn.panel(Dummy())
app.show()

This was an unexepected behaviour to me as the Spinner widget isn't even introduced on the widgets page.

The instantiated Spinner has a step value of 1 which means that it's not possible to interactivaly set that widget value to a decimal number. spinner

It is however possible to programmatically set its value to a decimal number. spinner_setval But now the widget doesn't display the exact value but the value rounded according to that step of 1.

What would be really nice to have

In the case of a number that doesn't have any hard-bounds, I'd rather not get a Spinner but a more flexible widget, possibly a NumberInput. This widget would be similar to the TextInput widget, it would however accept only numeric (int, float) as input.

Ideally, a parameter format would allow to control how the number is formatted by the widget. The following code would instantiate a NumberInput widget that could display values such as 2.00 € or 2.15 €.

pn.Param(Dummy.param, widgets={
    'amount': {'type': pn.widgets.NumberInput, 'format': '{.2f} €'},
)

In the end, the Spinner widget isn't that bad, and the IntSlider and FloatSlider widgets are also useful. I believe though that a more flexible widget would come in handy quite frequently, this is at least what I've observed with the few apps I've built so far.

Something else I've tried

I thought the TextInput widget would provide me with a simple workaround. Unfortunately, that failed. ti_error

Bonus Thanks a lot for panel, it's a great library! If I can be of any help in implementing this feature (Python only, sorry), please guide me through this work.

MarcSkovMadsen commented 4 years ago

Hi @maximlt

For a contribution the first thing would be to search (google, npm, alternative frameworks like ipywidgets, streamlit, dash, bokeh or shiny or ?) and identify suitable candidates for widgets. I don't know exactly what the criteria are but I guess one is bundle size.

Then you could add a pull request and implement the python side of the widget.

A way to get started is to identify a similar Panel widget and its panel widget .py and bokeh model .py file. Then implement something similar for the new widget. See

If there are questions reach out on gitter where the developers hang around.


A simpler, python only alternative is to use the upcoming web component functionality https://github.com/holoviz/panel/pull/1122.

The caveat is that the widgets created don't support jslinking and export to a static html file because there is no js implementation. But they work very well with a server or in a notebook.

In the pull request https://github.com/holoviz/panel/pull/1122 you will find lots of examples of implementations of the wired js widgets.

I dream of Panel also supporting

You could seach (google, npm, https://www.webcomponents.org/, material or vaadin) for a suitable component and then try implementing it.

There are some jupyter notebooks in the pull request that tries to explain how to do this.

And implementing a web component, python only widget is also a big stepping stone towards implementing a more traditional Panel widget that support jslinking and export to a static site.

So if you feel Panel would be even more awesome with more widgets its just to get started :-)

jbednar commented 4 years ago

The instantiated Spinner has a step value of 1 which means that it's not possible to interactivaly set that widget value to a decimal number.

You should be able to type in any number you like already, right?

In any case, how I think numeric widgets should work is to have both soft and hard bounds available, with a slider covering up to the soft bound but allowing extension to the hard bound (if any). See my proposal for this for ipywidgets, which was never implemented. I think this approach gives maximum utility and flexibility, especially when combined with interact-like guessing of initial soft bounds if none are provided. I'm not sure how hard this would be to achieve with Bokeh widgets, but I do think we should do it in Bokeh rather than add a separate library just for this case.

maximlt commented 4 years ago

Thank you both for you answers, that helps me a lot to understand what's going on.

You should be able to type in any number you like already, right?

Unfortunately not. In the dummy example I provided, the Spinner widget gets a default step value of 1 (see here) It's not possible to enter 2.95 for instance, the number gets rounded to 3.

@jbednar thanks for the link to your proposal. Before that I had never tried ipywidgets and decided to try its FloatSlider for the first time. I found out that its behaviour is very close to what I'd like to get, the widget numeric value is displayed in a entry box, so the value can be set either through that entry or with the slider. I believe your proposal is right, having both softbounds and hardbounds is required for numeric values, as in theory most quantities have an open-ended bound (temperature, weight, etc.).

It'd be really nice if Bokeh could implement this feature. Till then, I'll probably use the following workaround.

import param
import panel as pn

class Dummy(param.Parameterized):
    amount = param.Number(default=1,step=0.01, bounds=(0, None), softbounds=(0, 50))

dm = Dummy()
fs = pn.Param(dm.param.amount, widgets={"amount": {"show_value": False}})
s = pn.Param(dm.param.amount, widgets={"amount": {"type": pn.widgets.Spinner, "name": "", "width":80}})
numeric_input = pn.Row(fs, s)
numeric_input.app()

number_input

maximlt commented 4 years ago

@jbednar Do you think I should open a feature request on the GitHub page of Bokeh instead of here?

Jhsmit commented 4 years ago

I'm currently using LiteralInput for this but its too flexible because users can enter strings or out-of-bounds values.

Ideally I'd like a bounded field where you can type values like '1.23e-5' or '1', '124.45', and which doesnt accept anything thats out of bounds.

pn.param.LiteralInputTyped almost does the job except for bounds.

This was discussed over at Bokeh https://github.com/bokeh/bokeh/issues/6173 / https://github.com/bokeh/bokeh/pull/7157 which led to PR https://github.com/bokeh/bokeh/pull/8678 which implemented Spinner but AFAIK they dont work with floats.

jbednar commented 4 years ago

@maximlt Do you think I should open a feature request on the GitHub page of Bokeh instead of here?

I've opened such an issue on Bokeh (https://github.com/bokeh/bokeh/issues/9943); feel free to chime in there.

maximlt commented 4 years ago

@Jhsmit You can see in the docs that you can specify a type attribute of a LiteralInput widget (https://panel.holoviz.org/reference/widgets/LiteralInput.html#widgets-gallery-literalinput). It doesn't allow you to specify bounds though.

Jhsmit commented 4 years ago

Thanks, I think my problem with the spinner was that I didn't set the step properly. With this set sufficiently low its doing mostly what I want.

Some suggestions:

These are mostly personal preferences, but having control over these would be nice.

maximlt commented 4 years ago

Yes actually I find the spinner to be quite a weird widget, I'd rather have a more powerful LiteralInput widget, which can turn into a Spinner if I set a step.

1081 commented 4 years ago

Yes actually I find the spinner to be quite a weird widget, I'd rather have a more powerful LiteralInput widget, which can turn into a Spinner if I set a step.

Same here! The spinner doesn't work for our applications. This is the only reason why we hesitate to use panels for our tools!

poplarShift commented 4 years ago

Thanks for all the great comments in here. I agree with what @maximlt said, 90% of my use cases would ideally require fine-tuned in/output of numbers. For the time being, I made a PR that allows specifying LiteralInput (or anything else) for a param.Number even if no step or bounds are specified.

xavArtley commented 4 years ago

If I understand correctly you are looking for a widget like this? ezgif com-video-to-gif (2) If yes I could start a PR to discuss a better name and API for it

jbednar commented 4 years ago

Nice! Something like that would work for me, assuming:

xavArtley commented 4 years ago

The extra text display in the second display is just for demonstation of the link between the widget and an other panel (in this case a statictext) Here the the biderectionnal is implicit because I display 2 times the same widget => one panel instance (s) and 2 bokeh models 1 for each display

Jhsmit commented 4 years ago

That looks like it does what I would like to have. The bounds are given by the hard_end and hard_start parameters? I agree with @jbednar that the width is important, it should not look out of place if you put it in a column with selectors or other widgets. Along those lines, would it be possible to not show the slider? It is a nice functionality to have but if space is limited and high accuracy is needed, I might want to leave it out.

xavArtley commented 4 years ago

Ok so this is another issue personally if you don't want the slider you can try something like this (code to put in input.py of panel.panel.widgets):

class NumericInput(Widget):

    value = param.Number(default=0, allow_None=True, bounds=[None, None])

    placeholder = param.Number(default=None)

    start = param.Number(default=None, allow_None=True)

    end = param.Number(default=None, allow_None=True)

    _widget_type = _BkTextInput

    formatter = param.Parameter(default=None)

    _rename = {'formatter': None, 'start': None, 'end': None}

    def _bound_value(self, value):
        if self.start is not None:
            value = max(value, self.start)
        if self.end is not None:
            value = min(value, self.end)
        return value

    def _format_value(self, value):
        if self.formatter is not None:
            value = self.formatter.format(value)
        else:
            value = str(value)
        return value

    def _process_param_change(self, msg):
        msg.pop('formatter', None)

        if 'start' in msg:
            start = msg.pop('start')
            self.param.value.bounds[0] = start
        if 'end' in msg:
            end = msg.pop('end')
            self.param.value.bounds[1] = end

        if 'value' in msg and msg['value'] is not None:
            msg['value'] = self._format_value(self.value)
        if 'placeholder' in msg and msg['placeholder'] is not None:
            msg['placeholder'] = self._format_value(self.placeholder)
        return msg

    def _process_property_change(self, msg):
        if 'value' in msg and msg['value'] is not None:
            try:
                value = float(msg['value'])
                msg['value'] = self._bound_value(value)
                if msg['value'] != value:
                    self.param.trigger('value')
            except ValueError:
                msg.pop('value')
        if 'placeholder' in msg and msg['placeholder'] is not None:
            try:
                msg['placeholder'] = self._format_value(float(msg['placeholder']))
            except ValueError:
                msg.pop('placeholder')
        return msg

ezgif com-video-to-gif (4)

Jhsmit commented 4 years ago

Great, that does exactly what I was looking for.

I'd like to be able to this as well:

class A(param.Parameterized):
    my_number = param.Number(default=10)

    def panel(self):
        return pn.Param(self.param, widgets={'my_number': {'type': pn.widgets.input.NumericInput, 'formatter': None}})

Which requires some changes in param.py as well because currently although if you specify this widget if the param is a Number you get a spinner regardless. This should probably be in a separate PR?

By the way why doesnt your spinner show the spinning buttons in your example?

xavArtley commented 4 years ago

I need to hover it to make the spinning button appear the html5 input number look is browser dependant and concerning the mapping of param and widgets it should be address in https://github.com/holoviz/panel/pull/1322

maximlt commented 4 years ago

That looks indeed a lot better than what we have now! 👍 I need to give it a proper try. One question, is it possible to set the slider step?

Jhsmit commented 4 years ago

@xavArtley I've modified the NumericInput example you provided by copying parts of Literalnput such that now it does display a name from the name parameter. I don't understand why this code does not show a name and your example does not, however but hey it works.


class NumericInput(pn.widgets.input.Widget):
    """
    NumericInput allows input of floats with bounds
    """

    type = param.ClassSelector(default=None, class_=(type, tuple),
                               is_instance=True)

    value = param.Number(default=None)

    start = param.Number(default=None, allow_None=True)

    end = param.Number(default=None, allow_None=True)

    _rename = {'name': 'title', 'type': None, 'serializer': None, 'start': None, 'end': None}

    _source_transforms = {'value': """JSON.parse(value.replace(/'/g, '"'))"""}

    _target_transforms = {'value': """JSON.stringify(value).replace(/,/g, ", ").replace(/:/g, ": ")"""}

    _widget_type = _BkTextInput

    def __init__(self, **params):
        super(NumericInput, self).__init__(**params)
        self._state = ''
        self._validate(None)
        self._callbacks.append(self.param.watch(self._validate, 'value'))

    def _validate(self, event):
        if self.type is None: return
        new = self.value
        if not isinstance(new, self.type) and new is not None:
            if event:
                self.value = event.old
            types = repr(self.type) if isinstance(self.type, tuple) else self.type.__name__
            raise ValueError('LiteralInput expected %s type but value %s '
                             'is of type %s.' %
                             (types, new, type(new).__name__))

    def _bound_value(self, value):
        if self.start is not None:
            value = max(value, self.start)
        if self.end is not None:
            value = min(value, self.end)
        return value

    def _process_property_change(self, msg):
        if 'value' in msg and msg['value'] is not None:
            try:
                value = float(msg['value'])
                msg['value'] = self._bound_value(value)
                if msg['value'] != value:
                    self.param.trigger('value')
            except ValueError:
                msg.pop('value')
        if 'placeholder' in msg and msg['placeholder'] is not None:
            try:
                msg['placeholder'] = self._format_value(float(msg['placeholder']))
            except ValueError:
                msg.pop('placeholder')
        return msg

    def _process_param_change(self, msg):
        msg = super(NumericInput, self)._process_param_change(msg)

        if 'start' in msg:
            start = msg.pop('start')
            self.param.value.bounds[0] = start
        if 'end' in msg:
            end = msg.pop('end')
            self.param.value.bounds[1] = end

        if 'value' in msg:
            value = '' if msg['value'] is None else msg['value']
            value = as_unicode(value)
            msg['value'] = value
        msg['title'] = self.name
        return msg