diff --git a/Dockerfile b/Dockerfile index d2392b5..ef04051 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,7 +53,6 @@ RUN R -e 'require(devtools); \ install_version("translate", version = "0.1.2", repos = "http://cran.us.r-project.org", quiet = TRUE)' # Install R packages - RUN mamba install --yes -c conda-forge \ 'r-stargazer' \ 'r-quanteda' \ @@ -69,8 +68,11 @@ RUN mamba install --yes -c conda-forge \ 'r-modelsummary' \ 'r-nsyllable' \ 'r-proxyc' \ +# 'r-car' \ +# 'vtable-dumper' \ 'r-tidytext' && \ mamba clean --all -f -y +RUN mamba install --yes -c conda-forge r-car RUN pip install --upgrade setuptools @@ -124,7 +126,8 @@ RUN /opt/conda/envs/sage/bin/sage -c "install_scripts('/usr/local/bin')" && \ ln -s /usr/bin/sage /usr/bin/sagemath RUN jupyter kernelspec install $(/opt/conda/envs/sage/bin/sage -sh -c 'ls -d /opt/conda/envs/sage/share/jupyter/kernels/sagemath'); exit 0 - +COPY widget_selection.py /opt/conda/lib/python3.10/site-packages/ipywidgets/widgets/ +COPY interaction.py /opt/conda/lib/python3.10/site-packages/ipywidgets/widgets/ RUN chown -R jovyan:users /home/jovyan && \ chmod -R 0777 /home/jovyan && \ rm -rf /home/jovyan/* diff --git a/interaction.py b/interaction.py new file mode 100644 index 0000000..c57b3a0 --- /dev/null +++ b/interaction.py @@ -0,0 +1,571 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +"""Interact with functions using widgets.""" + +from __future__ import print_function +from __future__ import division + +try: # Python >= 3.3 + from inspect import signature, Parameter +except ImportError: + from IPython.utils.signatures import signature, Parameter +from inspect import getcallargs + +try: + from inspect import getfullargspec as check_argspec +except ImportError: + from inspect import getargspec as check_argspec # py2 +import sys + +from IPython.core.getipython import get_ipython +from . import (ValueWidget, Text, + FloatSlider, IntSlider, Checkbox, Dropdown, + VBox, Button, DOMWidget, Output) +from IPython.display import display, clear_output +from ipython_genutils.py3compat import string_types, unicode_type +from traitlets import HasTraits, Any, Unicode, observe +from numbers import Real, Integral +from warnings import warn +from collections.abc import Iterable, Mapping + +empty = Parameter.empty + + +def show_inline_matplotlib_plots(): + """Show matplotlib plots immediately if using the inline backend. + + With ipywidgets 6.0, matplotlib plots don't work well with interact when + using the inline backend that comes with ipykernel. Basically, the inline + backend only shows the plot after the entire cell executes, which does not + play well with drawing plots inside of an interact function. See + https://github.com/jupyter-widgets/ipywidgets/issues/1181/ and + https://github.com/ipython/ipython/issues/10376 for more details. This + function displays any matplotlib plots if the backend is the inline backend. + """ + if 'matplotlib' not in sys.modules: + # matplotlib hasn't been imported, nothing to do. + return + + try: + import matplotlib as mpl + from ipykernel.pylab.backend_inline import flush_figures + except ImportError: + return + + if mpl.get_backend() == 'module://ipykernel.pylab.backend_inline': + flush_figures() + + +def interactive_output(f, controls): + """Connect widget controls to a function. + + This function does not generate a user interface for the widgets (unlike `interact`). + This enables customisation of the widget user interface layout. + The user interface layout must be defined and displayed manually. + """ + + out = Output() + def observer(change): + kwargs = {k:v.value for k,v in controls.items()} + show_inline_matplotlib_plots() + with out: + clear_output(wait=True) + f(**kwargs) + show_inline_matplotlib_plots() + for k,w in controls.items(): + w.observe(observer, 'value') + show_inline_matplotlib_plots() + observer(None) + return out + + +def _matches(o, pattern): + """Match a pattern of types in a sequence.""" + if not len(o) == len(pattern): + return False + comps = zip(o,pattern) + return all(isinstance(obj,kind) for obj,kind in comps) + + +def _get_min_max_value(min, max, value=None, step=None): + """Return min, max, value given input values with possible None.""" + # Either min and max need to be given, or value needs to be given + if value is None: + if min is None or max is None: + raise ValueError('unable to infer range, value from: ({0}, {1}, {2})'.format(min, max, value)) + diff = max - min + value = min + (diff / 2) + # Ensure that value has the same type as diff + if not isinstance(value, type(diff)): + value = min + (diff // 2) + else: # value is not None + if not isinstance(value, Real): + raise TypeError('expected a real number, got: %r' % value) + # Infer min/max from value + if value == 0: + # This gives (0, 1) of the correct type + vrange = (value, value + 1) + elif value > 0: + vrange = (-value, 3*value) + else: + vrange = (3*value, -value) + if min is None: + min = vrange[0] + if max is None: + max = vrange[1] + if step is not None: + # ensure value is on a step + tick = int((value - min) / step) + value = min + tick * step + if not min <= value <= max: + raise ValueError('value must be between min and max (min={0}, value={1}, max={2})'.format(min, value, max)) + return min, max, value + +def _yield_abbreviations_for_parameter(param, kwargs): + """Get an abbreviation for a function parameter.""" + name = param.name + kind = param.kind + ann = param.annotation + default = param.default + not_found = (name, empty, empty) + if kind in (Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY): + if name in kwargs: + value = kwargs.pop(name) + elif ann is not empty: + warn("Using function annotations to implicitly specify interactive controls is deprecated. Use an explicit keyword argument for the parameter instead.", DeprecationWarning) + value = ann + elif default is not empty: + value = default + else: + yield not_found + yield (name, value, default) + elif kind == Parameter.VAR_KEYWORD: + # In this case name=kwargs and we yield the items in kwargs with their keys. + for k, v in kwargs.copy().items(): + kwargs.pop(k) + yield k, v, empty + + +class interactive(VBox): + """ + A VBox container containing a group of interactive widgets tied to a + function. + + Parameters + ---------- + __interact_f : function + The function to which the interactive widgets are tied. The `**kwargs` + should match the function signature. + __options : dict + A dict of options. Currently, the only supported keys are + ``"manual"`` and ``"manual_name"``. + **kwargs : various, optional + An interactive widget is created for each keyword argument that is a + valid widget abbreviation. + + Note that the first two parameters intentionally start with a double + underscore to avoid being mixed up with keyword arguments passed by + ``**kwargs``. + """ + def __init__(self, __interact_f, __options={}, **kwargs): + VBox.__init__(self, _dom_classes=['widget-interact']) + self.result = None + self.args = [] + self.kwargs = {} + + self.f = f = __interact_f + self.clear_output = kwargs.pop('clear_output', True) + self.manual = __options.get("manual", False) + self.manual_name = __options.get("manual_name", "Run Interact") + self.auto_display = __options.get("auto_display", False) + + new_kwargs = self.find_abbreviations(kwargs) + # Before we proceed, let's make sure that the user has passed a set of args+kwargs + # that will lead to a valid call of the function. This protects against unspecified + # and doubly-specified arguments. + try: + check_argspec(f) + except TypeError: + # if we can't inspect, we can't validate + pass + else: + getcallargs(f, **{n:v for n,v,_ in new_kwargs}) + # Now build the widgets from the abbreviations. + self.kwargs_widgets = self.widgets_from_abbreviations(new_kwargs) + + # This has to be done as an assignment, not using self.children.append, + # so that traitlets notices the update. We skip any objects (such as fixed) that + # are not DOMWidgets. + c = [w for w in self.kwargs_widgets if isinstance(w, DOMWidget)] + + # If we are only to run the function on demand, add a button to request this. + if self.manual: + self.manual_button = Button(description=self.manual_name) + c.append(self.manual_button) + + self.out = Output() + c.append(self.out) + self.children = c + + # Wire up the widgets + # If we are doing manual running, the callback is only triggered by the button + # Otherwise, it is triggered for every trait change received + # On-demand running also suppresses running the function with the initial parameters + if self.manual: + self.manual_button.on_click(self.update) + + # Also register input handlers on text areas, so the user can hit return to + # invoke execution. + for w in self.kwargs_widgets: + if isinstance(w, Text): + w.on_submit(self.update) + else: + for widget in self.kwargs_widgets: + widget.observe(self.update, names='value') + + self.on_displayed(self.update) + + # Callback function + def update(self, *args): + """ + Call the interact function and update the output widget with + the result of the function call. + + Parameters + ---------- + *args : ignored + Required for this method to be used as traitlets callback. + """ + self.kwargs = {} + if self.manual: + self.manual_button.disabled = True + try: + show_inline_matplotlib_plots() + with self.out: + if self.clear_output: + clear_output(wait=True) + for widget in self.kwargs_widgets: + value = widget.get_interact_value() + self.kwargs[widget._kwarg] = value + self.result = self.f(**self.kwargs) + show_inline_matplotlib_plots() + if self.auto_display and self.result is not None: + display(self.result) + except Exception as e: + ip = get_ipython() + if ip is None: + self.log.warn("Exception in interact callback: %s", e, exc_info=True) + else: + ip.showtraceback() + finally: + if self.manual: + self.manual_button.disabled = False + + # Find abbreviations + def signature(self): + return signature(self.f) + + def find_abbreviations(self, kwargs): + """Find the abbreviations for the given function and kwargs. + Return (name, abbrev, default) tuples. + """ + new_kwargs = [] + try: + sig = self.signature() + except (ValueError, TypeError): + # can't inspect, no info from function; only use kwargs + return [ (key, value, value) for key, value in kwargs.items() ] + + for param in sig.parameters.values(): + for name, value, default in _yield_abbreviations_for_parameter(param, kwargs): + if value is empty: + raise ValueError('cannot find widget or abbreviation for argument: {!r}'.format(name)) + new_kwargs.append((name, value, default)) + return new_kwargs + + # Abbreviations to widgets + def widgets_from_abbreviations(self, seq): + """Given a sequence of (name, abbrev, default) tuples, return a sequence of Widgets.""" + result = [] + for name, abbrev, default in seq: + widget = self.widget_from_abbrev(abbrev, default) + if not (isinstance(widget, ValueWidget) or isinstance(widget, fixed)): + if widget is None: + raise ValueError("{!r} cannot be transformed to a widget".format(abbrev)) + else: + raise TypeError("{!r} is not a ValueWidget".format(widget)) + if not widget.description: + widget.description = name + widget._kwarg = name + result.append(widget) + return result + + @classmethod + def widget_from_abbrev(cls, abbrev, default=empty): + """Build a ValueWidget instance given an abbreviation or Widget.""" + if isinstance(abbrev, ValueWidget) or isinstance(abbrev, fixed): + return abbrev + + if isinstance(abbrev, tuple): + widget = cls.widget_from_tuple(abbrev) + if default is not empty: + try: + widget.value = default + except Exception: + # ignore failure to set default + pass + return widget + + # Try single value + widget = cls.widget_from_single_value(abbrev) + if widget is not None: + return widget + + # Something iterable (list, dict, generator, ...). Note that str and + # tuple should be handled before, that is why we check this case last. + if isinstance(abbrev, Iterable): + widget = cls.widget_from_iterable(abbrev) + if default is not empty: + try: + widget.value = default + except Exception: + # ignore failure to set default + pass + return widget + + # No idea... + return None + + @staticmethod + def widget_from_single_value(o): + """Make widgets from single values, which can be used as parameter defaults.""" + if isinstance(o, string_types): + return Text(value=unicode_type(o)) + elif isinstance(o, bool): + return Checkbox(value=o) + elif isinstance(o, Integral): + min, max, value = _get_min_max_value(None, None, o) + return IntSlider(value=o, min=min, max=max) + elif isinstance(o, Real): + min, max, value = _get_min_max_value(None, None, o) + return FloatSlider(value=o, min=min, max=max) + else: + return None + + @staticmethod + def widget_from_tuple(o): + """Make widgets from a tuple abbreviation.""" + if _matches(o, (Real, Real)): + min, max, value = _get_min_max_value(o[0], o[1]) + if all(isinstance(_, Integral) for _ in o): + cls = IntSlider + else: + cls = FloatSlider + return cls(value=value, min=min, max=max) + elif _matches(o, (Real, Real, Real)): + step = o[2] + if step <= 0: + raise ValueError("step must be >= 0, not %r" % step) + min, max, value = _get_min_max_value(o[0], o[1], step=step) + if all(isinstance(_, Integral) for _ in o): + cls = IntSlider + else: + cls = FloatSlider + return cls(value=value, min=min, max=max, step=step) + + @staticmethod + def widget_from_iterable(o): + """Make widgets from an iterable. This should not be done for + a string or tuple.""" + # Dropdown expects a dict or list, so we convert an arbitrary + # iterable to either of those. + if isinstance(o, (list, dict)): + return Dropdown(options=o) + elif isinstance(o, Mapping): + return Dropdown(options=list(o.items())) + else: + return Dropdown(options=list(o)) + + # Return a factory for interactive functions + @classmethod + def factory(cls): + options = dict(manual=False, auto_display=True, manual_name="Run Interact") + return _InteractFactory(cls, options) + + +class _InteractFactory(object): + """ + Factory for instances of :class:`interactive`. + + This class is needed to support options like:: + + >>> @interact.options(manual=True) + ... def greeting(text="World"): + ... print("Hello {}".format(text)) + + Parameters + ---------- + cls : class + The subclass of :class:`interactive` to construct. + options : dict + A dict of options used to construct the interactive + function. By default, this is returned by + ``cls.default_options()``. + kwargs : dict + A dict of **kwargs to use for widgets. + """ + def __init__(self, cls, options, kwargs={}): + self.cls = cls + self.opts = options + self.kwargs = kwargs + + def widget(self, f): + """ + Return an interactive function widget for the given function. + + The widget is only constructed, not displayed nor attached to + the function. + + Returns + ------- + An instance of ``self.cls`` (typically :class:`interactive`). + + Parameters + ---------- + f : function + The function to which the interactive widgets are tied. + """ + return self.cls(f, self.opts, **self.kwargs) + + def __call__(self, __interact_f=None, **kwargs): + """ + Make the given function interactive by adding and displaying + the corresponding :class:`interactive` widget. + + Expects the first argument to be a function. Parameters to this + function are widget abbreviations passed in as keyword arguments + (``**kwargs``). Can be used as a decorator (see examples). + + Returns + ------- + f : __interact_f with interactive widget attached to it. + + Parameters + ---------- + __interact_f : function + The function to which the interactive widgets are tied. The `**kwargs` + should match the function signature. Passed to :func:`interactive()` + **kwargs : various, optional + An interactive widget is created for each keyword argument that is a + valid widget abbreviation. Passed to :func:`interactive()` + + Examples + -------- + Render an interactive text field that shows the greeting with the passed in + text:: + + # 1. Using interact as a function + def greeting(text="World"): + print("Hello {}".format(text)) + interact(greeting, text="IPython Widgets") + + # 2. Using interact as a decorator + @interact + def greeting(text="World"): + print("Hello {}".format(text)) + + # 3. Using interact as a decorator with named parameters + @interact(text="IPython Widgets") + def greeting(text="World"): + print("Hello {}".format(text)) + + Render an interactive slider widget and prints square of number:: + + # 1. Using interact as a function + def square(num=1): + print("{} squared is {}".format(num, num*num)) + interact(square, num=5) + + # 2. Using interact as a decorator + @interact + def square(num=2): + print("{} squared is {}".format(num, num*num)) + + # 3. Using interact as a decorator with named parameters + @interact(num=5) + def square(num=2): + print("{} squared is {}".format(num, num*num)) + """ + # If kwargs are given, replace self by a new + # _InteractFactory with the updated kwargs + if kwargs: + kw = dict(self.kwargs) + kw.update(kwargs) + self = type(self)(self.cls, self.opts, kw) + + f = __interact_f + if f is None: + # This branch handles the case 3 + # @interact(a=30, b=40) + # def f(*args, **kwargs): + # ... + # + # Simply return the new factory + return self + + # positional arg support in: https://gist.github.com/8851331 + # Handle the cases 1 and 2 + # 1. interact(f, **kwargs) + # 2. @interact + # def f(*args, **kwargs): + # ... + w = self.widget(f) + try: + f.widget = w + except AttributeError: + # some things (instancemethods) can't have attributes attached, + # so wrap in a lambda + f = lambda *args, **kwargs: __interact_f(*args, **kwargs) + f.widget = w + show_inline_matplotlib_plots() + display(w) + return f + + def options(self, **kwds): + """ + Change options for interactive functions. + + Returns + ------- + A new :class:`_InteractFactory` which will apply the + options when called. + """ + opts = dict(self.opts) + for k in kwds: + try: + # Ensure that the key exists because we want to change + # existing options, not add new ones. + _ = opts[k] + except KeyError: + raise ValueError("invalid option {!r}".format(k)) + opts[k] = kwds[k] + return type(self)(self.cls, opts, self.kwargs) + + +interact = interactive.factory() +interact_manual = interact.options(manual=True, manual_name="Run Interact") + + +class fixed(HasTraits): + """A pseudo-widget whose value is fixed and never synced to the client.""" + value = Any(help="Any Python object") + description = Unicode('', help="Any Python object") + def __init__(self, value, **kwargs): + super(fixed, self).__init__(value=value, **kwargs) + def get_interact_value(self): + """Return the value for this widget which should be passed to + interactive functions. Custom widgets can change this method + to process the raw value ``self.value``. + """ + return self.value diff --git a/widget_selection.py b/widget_selection.py new file mode 100644 index 0000000..225b955 --- /dev/null +++ b/widget_selection.py @@ -0,0 +1,623 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +"""Selection classes. + +Represents an enumeration using a widget. +""" + +from collections.abc import Mapping, Iterable +try: + from itertools import izip +except ImportError: #python3.x + izip = zip +from itertools import chain + +from .widget_description import DescriptionWidget, DescriptionStyle +from .valuewidget import ValueWidget +from .widget_core import CoreWidget +from .widget_style import Style +from .trait_types import InstanceDict, TypedTuple +from .widget import register, widget_serialization +from .docutils import doc_subst +from traitlets import (Unicode, Bool, Int, Any, Dict, TraitError, CaselessStrEnum, + Tuple, Union, observe, validate) +from ipython_genutils.py3compat import unicode_type + +_doc_snippets = {} +_doc_snippets['selection_params'] = """ + options: list + The options for the dropdown. This can either be a list of values, e.g. + ``['Galileo', 'Brahe', 'Hubble']`` or ``[0, 1, 2]``, or a list of + (label, value) pairs, e.g. + ``[('Galileo', 0), ('Brahe', 1), ('Hubble', 2)]``. + + index: int + The index of the current selection. + + value: any + The value of the current selection. When programmatically setting the + value, a reverse lookup is performed among the options to check that + the value is valid. The reverse lookup uses the equality operator by + default, but another predicate may be provided via the ``equals`` + keyword argument. For example, when dealing with numpy arrays, one may + set ``equals=np.array_equal``. + + label: str + The label corresponding to the selected value. + + disabled: bool + Whether to disable user changes. + + description: str + Label for this input group. This should be a string + describing the widget. +""" + +_doc_snippets['multiple_selection_params'] = """ + options: dict or list + The options for the dropdown. This can either be a list of values, e.g. + ``['Galileo', 'Brahe', 'Hubble']`` or ``[0, 1, 2]``, a list of + (label, value) pairs, e.g. + ``[('Galileo', 0), ('Brahe', 1), ('Hubble', 2)]``, + or a dictionary mapping the labels to the values, e.g. ``{'Galileo': 0, + 'Brahe': 1, 'Hubble': 2}``. The labels are the strings that will be + displayed in the UI, representing the actual Python choices, and should + be unique. If this is a dictionary, the order in which they are + displayed is not guaranteed. + + index: iterable of int + The indices of the options that are selected. + + value: iterable + The values that are selected. When programmatically setting the + value, a reverse lookup is performed among the options to check that + the value is valid. The reverse lookup uses the equality operator by + default, but another predicate may be provided via the ``equals`` + keyword argument. For example, when dealing with numpy arrays, one may + set ``equals=np.array_equal``. + + label: iterable of str + The labels corresponding to the selected value. + + disabled: bool + Whether to disable user changes. + + description: str + Label for this input group. This should be a string + describing the widget. +""" + +_doc_snippets['slider_params'] = """ + orientation: str + Either ``'horizontal'`` or ``'vertical'``. Defaults to ``horizontal``. + + readout: bool + Display the current label next to the slider. Defaults to ``True``. + + continuous_update: bool + If ``True``, update the value of the widget continuously as the user + holds the slider. Otherwise, the model is only updated after the + user has released the slider. Defaults to ``True``. +""" + + +def _make_options(x): + """Standardize the options tuple format. + + The returned tuple should be in the format (('label', value), ('label', value), ...). + + The input can be + * an iterable of (label, value) pairs + * an iterable of values, and labels will be generated + """ + # Check if x is a mapping of labels to values + if isinstance(x, Mapping): + import warnings + warnings.warn("Support for mapping types has been deprecated and will be dropped in a future release.", DeprecationWarning) + return tuple((unicode_type(k), v) for k, v in x.items()) + + # only iterate once through the options. + xlist = tuple(x) + + # Check if x is an iterable of (label, value) pairs + if all((isinstance(i, (list, tuple)) and len(i) == 2) for i in xlist): + return tuple((unicode_type(k), v) for k, v in xlist) + + # Otherwise, assume x is an iterable of values + return tuple((unicode_type(i), i) for i in xlist) + +def findvalue(array, value, compare = lambda x, y: x == y): + "A function that uses the compare function to return a value from the list." + try: + return next(x for x in array if compare(x, value)) + except StopIteration: + raise ValueError('%r not in array'%value) + +class _Selection(DescriptionWidget, ValueWidget, CoreWidget): + """Base class for Selection widgets + + ``options`` can be specified as a list of values, list of (label, value) + tuples, or a dict of {label: value}. The labels are the strings that will be + displayed in the UI, representing the actual Python choices, and should be + unique. If labels are not specified, they are generated from the values. + + When programmatically setting the value, a reverse lookup is performed + among the options to check that the value is valid. The reverse lookup uses + the equality operator by default, but another predicate may be provided via + the ``equals`` keyword argument. For example, when dealing with numpy arrays, + one may set equals=np.array_equal. + """ + + value = Any(None, help="Selected value", allow_none=True) + label = Unicode(None, help="Selected label", allow_none=True) + index = Int(None, help="Selected index", allow_none=True).tag(sync=True) + + options = Any((), + help="""Iterable of values, (label, value) pairs, or a mapping of {label: value} pairs that the user can select. + + The labels are the strings that will be displayed in the UI, representing the + actual Python choices, and should be unique. + """) + + _options_full = None + + # This being read-only means that it cannot be changed by the user. + _options_labels = TypedTuple(trait=Unicode(), read_only=True, help="The labels for the options.").tag(sync=True) + + disabled = Bool(help="Enable or disable user changes").tag(sync=True) + + def __init__(self, *args, **kwargs): + self.equals = kwargs.pop('equals', lambda x, y: x == y) + # We have to make the basic options bookkeeping consistent + # so we don't have errors the first time validators run + self._initializing_traits_ = True + options = _make_options(kwargs.get('options', ())) + self._options_full = options + self.set_trait('_options_labels', tuple(i[0] for i in options)) + self._options_values = tuple(i[1] for i in options) + + # Select the first item by default, if we can + if 'index' not in kwargs and 'value' not in kwargs and 'label' not in kwargs: + nonempty = (len(options) > 0) + kwargs['index'] = 0 if nonempty else None + kwargs['label'], kwargs['value'] = options[0] if nonempty else (None, None) + + super(_Selection, self).__init__(*args, **kwargs) + self._initializing_traits_ = False + + @validate('options') + def _validate_options(self, proposal): + # if an iterator is provided, exhaust it + if isinstance(proposal.value, Iterable) and not isinstance(proposal.value, Mapping): + proposal.value = tuple(proposal.value) + # throws an error if there is a problem converting to full form + self._options_full = _make_options(proposal.value) + return proposal.value + + @observe('options') + def _propagate_options(self, change): + "Set the values and labels, and select the first option if we aren't initializing" + options = self._options_full + self.set_trait('_options_labels', tuple(i[0] for i in options)) + self._options_values = tuple(i[1] for i in options) + if self._initializing_traits_ is not True: + if len(options) > 0: + if self.index == 0: + # Explicitly trigger the observers to pick up the new value and + # label. Just setting the value would not trigger the observers + # since traitlets thinks the value hasn't changed. + self._notify_trait('index', 0, 0) + else: + self.index = 0 + else: + self.index = None + + @validate('index') + def _validate_index(self, proposal): + if proposal.value is None or 0 <= proposal.value < len(self._options_labels): + return proposal.value + else: + raise TraitError('Invalid selection: index out of bounds') + + @observe('index') + def _propagate_index(self, change): + "Propagate changes in index to the value and label properties" + label = self._options_labels[change.new] if change.new is not None else None + value = self._options_values[change.new] if change.new is not None else None + if self.label is not label: + self.label = label + if self.value is not value: + self.value = value + + @validate('value') + def _validate_value(self, proposal): + value = proposal.value + try: + return findvalue(self._options_values, value, self.equals) if value is not None else None + except ValueError: + raise TraitError('Invalid selection: value not found') + + @observe('value') + def _propagate_value(self, change): + index = self._options_values.index(change.new) if change.new is not None else None + if self.index != index: + self.index = index + + @validate('label') + def _validate_label(self, proposal): + if (proposal.value is not None) and (proposal.value not in self._options_labels): + raise TraitError('Invalid selection: label not found') + return proposal.value + + @observe('label') + def _propagate_label(self, change): + index = self._options_labels.index(change.new) if change.new is not None else None + if self.index != index: + self.index = index + + def _repr_keys(self): + keys = super(_Selection, self)._repr_keys() + # Include options manually, as it isn't marked as synced: + for key in sorted(chain(keys, ('options',))): + if key == 'index' and self.index == 0: + # Index 0 is default when there are options + continue + yield key + + +class _MultipleSelection(DescriptionWidget, ValueWidget, CoreWidget): + """Base class for multiple Selection widgets + + ``options`` can be specified as a list of values, list of (label, value) + tuples, or a dict of {label: value}. The labels are the strings that will be + displayed in the UI, representing the actual Python choices, and should be + unique. If labels are not specified, they are generated from the values. + + When programmatically setting the value, a reverse lookup is performed + among the options to check that the value is valid. The reverse lookup uses + the equality operator by default, but another predicate may be provided via + the ``equals`` keyword argument. For example, when dealing with numpy arrays, + one may set equals=np.array_equal. + """ + + value = TypedTuple(trait=Any(), help="Selected values") + label = TypedTuple(trait=Unicode(), help="Selected labels") + index = TypedTuple(trait=Int(), help="Selected indices").tag(sync=True) + + options = Any((), + help="""Iterable of values, (label, value) pairs, or a mapping of {label: value} pairs that the user can select. + + The labels are the strings that will be displayed in the UI, representing the + actual Python choices, and should be unique. + """) + _options_full = None + + # This being read-only means that it cannot be changed from the frontend! + _options_labels = TypedTuple(trait=Unicode(), read_only=True, help="The labels for the options.").tag(sync=True) + + disabled = Bool(help="Enable or disable user changes").tag(sync=True) + + def __init__(self, *args, **kwargs): + self.equals = kwargs.pop('equals', lambda x, y: x == y) + + # We have to make the basic options bookkeeping consistent + # so we don't have errors the first time validators run + self._initializing_traits_ = True + options = _make_options(kwargs.get('options', ())) + self._full_options = options + self.set_trait('_options_labels', tuple(i[0] for i in options)) + self._options_values = tuple(i[1] for i in options) + + super(_MultipleSelection, self).__init__(*args, **kwargs) + self._initializing_traits_ = False + + @validate('options') + def _validate_options(self, proposal): + if isinstance(proposal.value, Iterable) and not isinstance(proposal.value, Mapping): + proposal.value = tuple(proposal.value) + # throws an error if there is a problem converting to full form + self._options_full = _make_options(proposal.value) + return proposal.value + + @observe('options') + def _propagate_options(self, change): + "Unselect any option" + options = self._options_full + self.set_trait('_options_labels', tuple(i[0] for i in options)) + self._options_values = tuple(i[1] for i in options) + if self._initializing_traits_ is not True: + self.index = () + + @validate('index') + def _validate_index(self, proposal): + "Check the range of each proposed index." + if all(0 <= i < len(self._options_labels) for i in proposal.value): + return proposal.value + else: + raise TraitError('Invalid selection: index out of bounds') + + @observe('index') + def _propagate_index(self, change): + "Propagate changes in index to the value and label properties" + label = tuple(self._options_labels[i] for i in change.new) + value = tuple(self._options_values[i] for i in change.new) + # we check equality so we can avoid validation if possible + if self.label != label: + self.label = label + if self.value != value: + self.value = value + + @validate('value') + def _validate_value(self, proposal): + "Replace all values with the actual objects in the options list" + try: + return tuple(findvalue(self._options_values, i, self.equals) for i in proposal.value) + except ValueError: + raise TraitError('Invalid selection: value not found') + + @observe('value') + def _propagate_value(self, change): + index = tuple(self._options_values.index(i) for i in change.new) + if self.index != index: + self.index = index + + @validate('label') + def _validate_label(self, proposal): + if any(i not in self._options_labels for i in proposal.value): + raise TraitError('Invalid selection: label not found') + return proposal.value + + @observe('label') + def _propagate_label(self, change): + index = tuple(self._options_labels.index(i) for i in change.new) + if self.index != index: + self.index = index + + def _repr_keys(self): + keys = super(_MultipleSelection, self)._repr_keys() + # Include options manually, as it isn't marked as synced: + for key in sorted(chain(keys, ('options',))): + yield key + + +@register +class ToggleButtonsStyle(DescriptionStyle, CoreWidget): + """Button style widget. + + Parameters + ---------- + button_width: str + The width of each button. This should be a valid CSS + width, e.g. '10px' or '5em'. + + font_weight: str + The text font weight of each button, This should be a valid CSS font + weight unit, for example 'bold' or '600' + """ + _model_name = Unicode('ToggleButtonsStyleModel').tag(sync=True) + button_width = Unicode(help="The width of each button.").tag(sync=True) + font_weight = Unicode(help="Text font weight of each button.").tag(sync=True) + + +@register +@doc_subst(_doc_snippets) +class ToggleButtons(_Selection): + """Group of toggle buttons that represent an enumeration. + + Only one toggle button can be toggled at any point in time. + + Parameters + ---------- + {selection_params} + + tooltips: list + Tooltip for each button. If specified, must be the + same length as `options`. + + icons: list + Icons to show on the buttons. This must be the name + of a font-awesome icon. See `http://fontawesome.io/icons/` + for a list of icons. + + button_style: str + One of 'primary', 'success', 'info', 'warning' or + 'danger'. Applies a predefined style to every button. + + style: ToggleButtonsStyle + Style parameters for the buttons. + """ + _view_name = Unicode('ToggleButtonsView').tag(sync=True) + _model_name = Unicode('ToggleButtonsModel').tag(sync=True) + + tooltips = TypedTuple(Unicode(), help="Tooltips for each button.").tag(sync=True) + icons = TypedTuple(Unicode(), help="Icons names for each button (FontAwesome names without the fa- prefix).").tag(sync=True) + style = InstanceDict(ToggleButtonsStyle).tag(sync=True, **widget_serialization) + + button_style = CaselessStrEnum( + values=['primary', 'success', 'info', 'warning', 'danger', ''], + default_value='', allow_none=True, help="""Use a predefined styling for the buttons.""").tag(sync=True) + + +@register +@doc_subst(_doc_snippets) +class Dropdown(_Selection): + """Allows you to select a single item from a dropdown. + + Parameters + ---------- + {selection_params} + """ + _view_name = Unicode('DropdownView').tag(sync=True) + _model_name = Unicode('DropdownModel').tag(sync=True) + + +@register +@doc_subst(_doc_snippets) +class RadioButtons(_Selection): + """Group of radio buttons that represent an enumeration. + + Only one radio button can be toggled at any point in time. + + Parameters + ---------- + {selection_params} + """ + _view_name = Unicode('RadioButtonsView').tag(sync=True) + _model_name = Unicode('RadioButtonsModel').tag(sync=True) + + +@register +@doc_subst(_doc_snippets) +class Select(_Selection): + """ + Listbox that only allows one item to be selected at any given time. + + Parameters + ---------- + {selection_params} + + rows: int + The number of rows to display in the widget. + """ + _view_name = Unicode('SelectView').tag(sync=True) + _model_name = Unicode('SelectModel').tag(sync=True) + rows = Int(5, help="The number of rows to display.").tag(sync=True) + +@register +@doc_subst(_doc_snippets) +class SelectMultiple(_MultipleSelection): + """ + Listbox that allows many items to be selected at any given time. + + The ``value``, ``label`` and ``index`` attributes are all iterables. + + Parameters + ---------- + {multiple_selection_params} + + rows: int + The number of rows to display in the widget. + """ + _view_name = Unicode('SelectMultipleView').tag(sync=True) + _model_name = Unicode('SelectMultipleModel').tag(sync=True) + rows = Int(5, help="The number of rows to display.").tag(sync=True) + + +class _SelectionNonempty(_Selection): + """Selection that is guaranteed to have a value selected.""" + # don't allow None to be an option. + value = Any(help="Selected value") + label = Unicode(help="Selected label") + index = Int(help="Selected index").tag(sync=True) + + def __init__(self, *args, **kwargs): + if len(kwargs.get('options', ())) == 0: + raise TraitError('options must be nonempty') + super(_SelectionNonempty, self).__init__(*args, **kwargs) + + @validate('options') + def _validate_options(self, proposal): + if isinstance(proposal.value, Iterable) and not isinstance(proposal.value, Mapping): + proposal.value = tuple(proposal.value) + self._options_full = _make_options(proposal.value) + if len(self._options_full) == 0: + raise TraitError("Option list must be nonempty") + return proposal.value + + @validate('index') + def _validate_index(self, proposal): + if 0 <= proposal.value < len(self._options_labels): + return proposal.value + else: + raise TraitError('Invalid selection: index out of bounds') + +class _MultipleSelectionNonempty(_MultipleSelection): + """Selection that is guaranteed to have an option available.""" + + def __init__(self, *args, **kwargs): + if len(kwargs.get('options', ())) == 0: + raise TraitError('options must be nonempty') + super(_MultipleSelectionNonempty, self).__init__(*args, **kwargs) + + @validate('options') + def _validate_options(self, proposal): + if isinstance(proposal.value, Iterable) and not isinstance(proposal.value, Mapping): + proposal.value = tuple(proposal.value) + # throws an error if there is a problem converting to full form + self._options_full = _make_options(proposal.value) + if len(self._options_full) == 0: + raise TraitError("Option list must be nonempty") + return proposal.value + +@register +@doc_subst(_doc_snippets) +class SelectionSlider(_SelectionNonempty): + """ + Slider to select a single item from a list or dictionary. + + Parameters + ---------- + {selection_params} + + {slider_params} + """ + _view_name = Unicode('SelectionSliderView').tag(sync=True) + _model_name = Unicode('SelectionSliderModel').tag(sync=True) + + orientation = CaselessStrEnum( + values=['horizontal', 'vertical'], default_value='horizontal', + help="Vertical or horizontal.").tag(sync=True) + readout = Bool(True, + help="Display the current selected label next to the slider").tag(sync=True) + continuous_update = Bool(True, + help="Update the value of the widget as the user is holding the slider.").tag(sync=True) + +@register +@doc_subst(_doc_snippets) +class SelectionRangeSlider(_MultipleSelectionNonempty): + """ + Slider to select multiple contiguous items from a list. + + The index, value, and label attributes contain the start and end of + the selection range, not all items in the range. + + Parameters + ---------- + {multiple_selection_params} + + {slider_params} + """ + _view_name = Unicode('SelectionRangeSliderView').tag(sync=True) + _model_name = Unicode('SelectionRangeSliderModel').tag(sync=True) + + value = Tuple(help="Min and max selected values") + label = Tuple(help="Min and max selected labels") + index = Tuple((0,0), help="Min and max selected indices").tag(sync=True) + + @observe('options') + def _propagate_options(self, change): + "Select the first range" + options = self._options_full + self.set_trait('_options_labels', tuple(i[0] for i in options)) + self._options_values = tuple(i[1] for i in options) + if self._initializing_traits_ is not True: + self.index = (0, 0) + + @validate('index') + def _validate_index(self, proposal): + "Make sure we have two indices and check the range of each proposed index." + if len(proposal.value) != 2: + raise TraitError('Invalid selection: index must have two values, but is %r'%(proposal.value,)) + if all(0 <= i < len(self._options_labels) for i in proposal.value): + return proposal.value + else: + raise TraitError('Invalid selection: index out of bounds: %s'%(proposal.value,)) + + orientation = CaselessStrEnum( + values=['horizontal', 'vertical'], default_value='horizontal', + help="Vertical or horizontal.").tag(sync=True) + readout = Bool(True, + help="Display the current selected label next to the slider").tag(sync=True) + continuous_update = Bool(True, + help="Update the value of the widget as the user is holding the slider.").tag(sync=True)