Skip to content
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

Need a more flexible Number widget #1128

Open
maximlt opened this issue Mar 5, 2020 · 20 comments
Open

Need a more flexible Number widget #1128

maximlt opened this issue Mar 5, 2020 · 20 comments
Labels
type: enhancement Minor feature or improvement to an existing feature

Comments

@maximlt
Copy link
Member

maximlt commented Mar 5, 2020

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
Copy link
Collaborator

MarcSkovMadsen commented Mar 6, 2020

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 #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 #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
Copy link
Member

jbednar commented Mar 6, 2020

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
Copy link
Member Author

maximlt commented Mar 6, 2020

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
Copy link
Member Author

maximlt commented Mar 9, 2020

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

@Jhsmit
Copy link

Jhsmit commented Apr 17, 2020

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 bokeh/bokeh#6173 / bokeh/bokeh#7157 which led to PR bokeh/bokeh#8678 which implemented Spinner but AFAIK they dont work with floats.

@philippjfr philippjfr added the type: enhancement Minor feature or improvement to an existing feature label Apr 17, 2020
@jbednar
Copy link
Member

jbednar commented Apr 19, 2020

@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 (bokeh/bokeh#9943); feel free to chime in there.

@maximlt
Copy link
Member Author

maximlt commented Apr 20, 2020

@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
Copy link

Jhsmit commented Apr 20, 2020

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:

  • Allow very large input ranges (ie 1e-50 to 1e50), currently step has a lower bound of 1e-16.
  • Formatting of the input, if I put 1e20 in a spinner, it renders '100000000000000000000', while the LiteralInput keeps '1e20' , which I prefer.
  • The possibility of not showing the spinners. When I've set the step to 1e-16 for maximum input range, these buttons basically do not do much anymore and I'd prefer to not show them.

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

@maximlt
Copy link
Member Author

maximlt commented Apr 20, 2020

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
Copy link

1081 commented Apr 20, 2020

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
Copy link
Contributor

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
Copy link
Collaborator

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
Copy link
Member

jbednar commented May 6, 2020

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

  • it links bidirectionally (seems to in the GIF, but I can't see in the code how both directions link)
  • I wouldn't want the extra display of the value on the left; just one copy is ideal.
  • Would the type be configurable between float and int?
  • The overall width should behave like the other widgets, so that this combined widget takes the same space in a table of widgets as a selector or other normal widget

@xavArtley
Copy link
Collaborator

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
Copy link

Jhsmit commented May 7, 2020

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
Copy link
Collaborator

xavArtley commented May 7, 2020

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
Copy link

Jhsmit commented May 7, 2020

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
Copy link
Collaborator

xavArtley commented May 7, 2020

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 #1322

@maximlt
Copy link
Member Author

maximlt commented May 8, 2020

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
Copy link

Jhsmit commented Sep 16, 2020

@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

@jbednar jbednar mentioned this issue Mar 22, 2021
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: enhancement Minor feature or improvement to an existing feature
Projects
None yet
Development

Successfully merging a pull request may close this issue.

8 participants