1# Copyright (c) Jupyter Development Team.
2# Distributed under the terms of the Modified BSD License.
3
4"""Selection classes.
5
6Represents an enumeration using a widget.
7"""
8
9try:
10    from collections.abc import Iterable, Mapping
11except ImportError:
12    from collections import Iterable, Mapping # py2
13
14try:
15    from itertools import izip
16except ImportError:  #python3.x
17    izip = zip
18from itertools import chain
19
20from .widget_description import DescriptionWidget, DescriptionStyle
21from .valuewidget import ValueWidget
22from .widget_core import CoreWidget
23from .widget_style import Style
24from .trait_types import InstanceDict, TypedTuple
25from .widget import register, widget_serialization
26from .docutils import doc_subst
27from traitlets import (Unicode, Bool, Int, Any, Dict, TraitError, CaselessStrEnum,
28                       Tuple, Union, observe, validate)
29from ipython_genutils.py3compat import unicode_type
30
31_doc_snippets = {}
32_doc_snippets['selection_params'] = """
33    options: list
34        The options for the dropdown. This can either be a list of values, e.g.
35        ``['Galileo', 'Brahe', 'Hubble']`` or ``[0, 1, 2]``, or a list of
36        (label, value) pairs, e.g.
37        ``[('Galileo', 0), ('Brahe', 1), ('Hubble', 2)]``.
38
39    index: int
40        The index of the current selection.
41
42    value: any
43        The value of the current selection. When programmatically setting the
44        value, a reverse lookup is performed among the options to check that
45        the value is valid. The reverse lookup uses the equality operator by
46        default, but another predicate may be provided via the ``equals``
47        keyword argument. For example, when dealing with numpy arrays, one may
48        set ``equals=np.array_equal``.
49
50    label: str
51        The label corresponding to the selected value.
52
53    disabled: bool
54        Whether to disable user changes.
55
56    description: str
57        Label for this input group. This should be a string
58        describing the widget.
59"""
60
61_doc_snippets['multiple_selection_params'] = """
62    options: dict or list
63        The options for the dropdown. This can either be a list of values, e.g.
64        ``['Galileo', 'Brahe', 'Hubble']`` or ``[0, 1, 2]``, a list of
65        (label, value) pairs, e.g.
66        ``[('Galileo', 0), ('Brahe', 1), ('Hubble', 2)]``,
67        or a dictionary mapping the labels to the values, e.g. ``{'Galileo': 0,
68        'Brahe': 1, 'Hubble': 2}``. The labels are the strings that will be
69        displayed in the UI, representing the actual Python choices, and should
70        be unique. If this is a dictionary, the order in which they are
71        displayed is not guaranteed.
72
73    index: iterable of int
74        The indices of the options that are selected.
75
76    value: iterable
77        The values that are selected. When programmatically setting the
78        value, a reverse lookup is performed among the options to check that
79        the value is valid. The reverse lookup uses the equality operator by
80        default, but another predicate may be provided via the ``equals``
81        keyword argument. For example, when dealing with numpy arrays, one may
82        set ``equals=np.array_equal``.
83
84    label: iterable of str
85        The labels corresponding to the selected value.
86
87    disabled: bool
88        Whether to disable user changes.
89
90    description: str
91        Label for this input group. This should be a string
92        describing the widget.
93"""
94
95_doc_snippets['slider_params'] = """
96    orientation: str
97        Either ``'horizontal'`` or ``'vertical'``. Defaults to ``horizontal``.
98
99    readout: bool
100        Display the current label next to the slider. Defaults to ``True``.
101
102    continuous_update: bool
103        If ``True``, update the value of the widget continuously as the user
104        holds the slider. Otherwise, the model is only updated after the
105        user has released the slider. Defaults to ``True``.
106"""
107
108
109def _make_options(x):
110    """Standardize the options tuple format.
111
112    The returned tuple should be in the format (('label', value), ('label', value), ...).
113
114    The input can be
115    * an iterable of (label, value) pairs
116    * an iterable of values, and labels will be generated
117    """
118    # Check if x is a mapping of labels to values
119    if isinstance(x, Mapping):
120        import warnings
121        warnings.warn("Support for mapping types has been deprecated and will be dropped in a future release.", DeprecationWarning)
122        return tuple((unicode_type(k), v) for k, v in x.items())
123
124    # only iterate once through the options.
125    xlist = tuple(x)
126
127    # Check if x is an iterable of (label, value) pairs
128    if all((isinstance(i, (list, tuple)) and len(i) == 2) for i in xlist):
129        return tuple((unicode_type(k), v) for k, v in xlist)
130
131    # Otherwise, assume x is an iterable of values
132    return tuple((unicode_type(i), i) for i in xlist)
133
134def findvalue(array, value, compare = lambda x, y: x == y):
135    "A function that uses the compare function to return a value from the list."
136    try:
137        return next(x for x in array if compare(x, value))
138    except StopIteration:
139        raise ValueError('%r not in array'%value)
140
141class _Selection(DescriptionWidget, ValueWidget, CoreWidget):
142    """Base class for Selection widgets
143
144    ``options`` can be specified as a list of values, list of (label, value)
145    tuples, or a dict of {label: value}. The labels are the strings that will be
146    displayed in the UI, representing the actual Python choices, and should be
147    unique. If labels are not specified, they are generated from the values.
148
149    When programmatically setting the value, a reverse lookup is performed
150    among the options to check that the value is valid. The reverse lookup uses
151    the equality operator by default, but another predicate may be provided via
152    the ``equals`` keyword argument. For example, when dealing with numpy arrays,
153    one may set equals=np.array_equal.
154    """
155
156    value = Any(None, help="Selected value", allow_none=True)
157    label = Unicode(None, help="Selected label", allow_none=True)
158    index = Int(None, help="Selected index", allow_none=True).tag(sync=True)
159
160    options = Any((),
161    help="""Iterable of values, (label, value) pairs, or a mapping of {label: value} pairs that the user can select.
162
163    The labels are the strings that will be displayed in the UI, representing the
164    actual Python choices, and should be unique.
165    """)
166
167    _options_full = None
168
169    # This being read-only means that it cannot be changed by the user.
170    _options_labels = TypedTuple(trait=Unicode(), read_only=True, help="The labels for the options.").tag(sync=True)
171
172    disabled = Bool(help="Enable or disable user changes").tag(sync=True)
173
174    def __init__(self, *args, **kwargs):
175        self.equals = kwargs.pop('equals', lambda x, y: x == y)
176        # We have to make the basic options bookkeeping consistent
177        # so we don't have errors the first time validators run
178        self._initializing_traits_ = True
179        options = _make_options(kwargs.get('options', ()))
180        self._options_full = options
181        self.set_trait('_options_labels', tuple(i[0] for i in options))
182        self._options_values = tuple(i[1] for i in options)
183
184        # Select the first item by default, if we can
185        if 'index' not in kwargs and 'value' not in kwargs and 'label' not in kwargs:
186            nonempty = (len(options) > 0)
187            kwargs['index'] = 0 if nonempty else None
188            kwargs['label'], kwargs['value'] = options[0] if nonempty else (None, None)
189
190        super(_Selection, self).__init__(*args, **kwargs)
191        self._initializing_traits_ = False
192
193    @validate('options')
194    def _validate_options(self, proposal):
195        # if an iterator is provided, exhaust it
196        if isinstance(proposal.value, Iterable) and not isinstance(proposal.value, Mapping):
197            proposal.value = tuple(proposal.value)
198        # throws an error if there is a problem converting to full form
199        self._options_full = _make_options(proposal.value)
200        return proposal.value
201
202    @observe('options')
203    def _propagate_options(self, change):
204        "Set the values and labels, and select the first option if we aren't initializing"
205        options = self._options_full
206        self.set_trait('_options_labels', tuple(i[0] for i in options))
207        self._options_values = tuple(i[1] for i in options)
208        if self._initializing_traits_ is not True:
209            if len(options) > 0:
210                if self.index == 0:
211                    # Explicitly trigger the observers to pick up the new value and
212                    # label. Just setting the value would not trigger the observers
213                    # since traitlets thinks the value hasn't changed.
214                    self._notify_trait('index', 0, 0)
215                else:
216                    self.index = 0
217            else:
218                self.index = None
219
220    @validate('index')
221    def _validate_index(self, proposal):
222        if proposal.value is None or 0 <= proposal.value < len(self._options_labels):
223            return proposal.value
224        else:
225            raise TraitError('Invalid selection: index out of bounds')
226
227    @observe('index')
228    def _propagate_index(self, change):
229        "Propagate changes in index to the value and label properties"
230        label = self._options_labels[change.new] if change.new is not None else None
231        value = self._options_values[change.new] if change.new is not None else None
232        if self.label is not label:
233            self.label = label
234        if self.value is not value:
235            self.value = value
236
237    @validate('value')
238    def _validate_value(self, proposal):
239        value = proposal.value
240        try:
241            return findvalue(self._options_values, value, self.equals) if value is not None else None
242        except ValueError:
243            raise TraitError('Invalid selection: value not found')
244
245    @observe('value')
246    def _propagate_value(self, change):
247        if change.new is None:
248            index = None
249        elif self.index is not None and self._options_values[self.index] == change.new:
250            index = self.index
251        else:
252            index = self._options_values.index(change.new)
253        if self.index != index:
254            self.index = index
255
256    @validate('label')
257    def _validate_label(self, proposal):
258        if (proposal.value is not None) and (proposal.value not in self._options_labels):
259            raise TraitError('Invalid selection: label not found')
260        return proposal.value
261
262    @observe('label')
263    def _propagate_label(self, change):
264        if change.new is None:
265            index = None
266        elif self.index is not None and self._options_labels[self.index] == change.new:
267            index = self.index
268        else:
269            index = self._options_labels.index(change.new)
270        if self.index != index:
271            self.index = index
272
273    def _repr_keys(self):
274        keys = super(_Selection, self)._repr_keys()
275        # Include options manually, as it isn't marked as synced:
276        for key in sorted(chain(keys, ('options',))):
277            if key == 'index' and self.index == 0:
278                # Index 0 is default when there are options
279                continue
280            yield key
281
282
283class _MultipleSelection(DescriptionWidget, ValueWidget, CoreWidget):
284    """Base class for multiple Selection widgets
285
286    ``options`` can be specified as a list of values, list of (label, value)
287    tuples, or a dict of {label: value}. The labels are the strings that will be
288    displayed in the UI, representing the actual Python choices, and should be
289    unique. If labels are not specified, they are generated from the values.
290
291    When programmatically setting the value, a reverse lookup is performed
292    among the options to check that the value is valid. The reverse lookup uses
293    the equality operator by default, but another predicate may be provided via
294    the ``equals`` keyword argument. For example, when dealing with numpy arrays,
295    one may set equals=np.array_equal.
296    """
297
298    value = TypedTuple(trait=Any(), help="Selected values")
299    label = TypedTuple(trait=Unicode(), help="Selected labels")
300    index = TypedTuple(trait=Int(), help="Selected indices").tag(sync=True)
301
302    options = Any((),
303    help="""Iterable of values, (label, value) pairs, or a mapping of {label: value} pairs that the user can select.
304
305    The labels are the strings that will be displayed in the UI, representing the
306    actual Python choices, and should be unique.
307    """)
308    _options_full = None
309
310    # This being read-only means that it cannot be changed from the frontend!
311    _options_labels = TypedTuple(trait=Unicode(), read_only=True, help="The labels for the options.").tag(sync=True)
312
313    disabled = Bool(help="Enable or disable user changes").tag(sync=True)
314
315    def __init__(self, *args, **kwargs):
316        self.equals = kwargs.pop('equals', lambda x, y: x == y)
317
318        # We have to make the basic options bookkeeping consistent
319        # so we don't have errors the first time validators run
320        self._initializing_traits_ = True
321        options = _make_options(kwargs.get('options', ()))
322        self._full_options = options
323        self.set_trait('_options_labels', tuple(i[0] for i in options))
324        self._options_values = tuple(i[1] for i in options)
325
326        super(_MultipleSelection, self).__init__(*args, **kwargs)
327        self._initializing_traits_ = False
328
329    @validate('options')
330    def _validate_options(self, proposal):
331        if isinstance(proposal.value, Iterable) and not isinstance(proposal.value, Mapping):
332            proposal.value = tuple(proposal.value)
333        # throws an error if there is a problem converting to full form
334        self._options_full = _make_options(proposal.value)
335        return proposal.value
336
337    @observe('options')
338    def _propagate_options(self, change):
339        "Unselect any option"
340        options = self._options_full
341        self.set_trait('_options_labels', tuple(i[0] for i in options))
342        self._options_values = tuple(i[1] for i in options)
343        if self._initializing_traits_ is not True:
344            self.index = ()
345
346    @validate('index')
347    def _validate_index(self, proposal):
348        "Check the range of each proposed index."
349        if all(0 <= i < len(self._options_labels) for i in proposal.value):
350            return proposal.value
351        else:
352            raise TraitError('Invalid selection: index out of bounds')
353
354    @observe('index')
355    def _propagate_index(self, change):
356        "Propagate changes in index to the value and label properties"
357        label = tuple(self._options_labels[i] for i in change.new)
358        value = tuple(self._options_values[i] for i in change.new)
359        # we check equality so we can avoid validation if possible
360        if self.label != label:
361            self.label = label
362        if self.value != value:
363            self.value = value
364
365    @validate('value')
366    def _validate_value(self, proposal):
367        "Replace all values with the actual objects in the options list"
368        try:
369            return tuple(findvalue(self._options_values, i, self.equals) for i in proposal.value)
370        except ValueError:
371            raise TraitError('Invalid selection: value not found')
372
373    @observe('value')
374    def _propagate_value(self, change):
375        index = tuple(self._options_values.index(i) for i in change.new)
376        if self.index != index:
377            self.index = index
378
379    @validate('label')
380    def _validate_label(self, proposal):
381        if any(i not in self._options_labels for i in proposal.value):
382            raise TraitError('Invalid selection: label not found')
383        return proposal.value
384
385    @observe('label')
386    def _propagate_label(self, change):
387        index = tuple(self._options_labels.index(i) for i in change.new)
388        if self.index != index:
389            self.index = index
390
391    def _repr_keys(self):
392        keys = super(_MultipleSelection, self)._repr_keys()
393        # Include options manually, as it isn't marked as synced:
394        for key in sorted(chain(keys, ('options',))):
395            yield key
396
397
398@register
399class ToggleButtonsStyle(DescriptionStyle, CoreWidget):
400    """Button style widget.
401
402    Parameters
403    ----------
404    button_width: str
405        The width of each button. This should be a valid CSS
406        width, e.g. '10px' or '5em'.
407
408    font_weight: str
409        The text font weight of each button, This should be a valid CSS font
410        weight unit, for example 'bold' or '600'
411    """
412    _model_name = Unicode('ToggleButtonsStyleModel').tag(sync=True)
413    button_width = Unicode(help="The width of each button.").tag(sync=True)
414    font_weight = Unicode(help="Text font weight of each button.").tag(sync=True)
415
416
417@register
418@doc_subst(_doc_snippets)
419class ToggleButtons(_Selection):
420    """Group of toggle buttons that represent an enumeration.
421
422    Only one toggle button can be toggled at any point in time.
423
424    Parameters
425    ----------
426    {selection_params}
427
428    tooltips: list
429        Tooltip for each button. If specified, must be the
430        same length as `options`.
431
432    icons: list
433        Icons to show on the buttons. This must be the name
434        of a font-awesome icon. See `http://fontawesome.io/icons/`
435        for a list of icons.
436
437    button_style: str
438        One of 'primary', 'success', 'info', 'warning' or
439        'danger'. Applies a predefined style to every button.
440
441    style: ToggleButtonsStyle
442        Style parameters for the buttons.
443    """
444    _view_name = Unicode('ToggleButtonsView').tag(sync=True)
445    _model_name = Unicode('ToggleButtonsModel').tag(sync=True)
446
447    tooltips = TypedTuple(Unicode(), help="Tooltips for each button.").tag(sync=True)
448    icons = TypedTuple(Unicode(), help="Icons names for each button (FontAwesome names without the fa- prefix).").tag(sync=True)
449    style = InstanceDict(ToggleButtonsStyle).tag(sync=True, **widget_serialization)
450
451    button_style = CaselessStrEnum(
452        values=['primary', 'success', 'info', 'warning', 'danger', ''],
453        default_value='', allow_none=True, help="""Use a predefined styling for the buttons.""").tag(sync=True)
454
455
456@register
457@doc_subst(_doc_snippets)
458class Dropdown(_Selection):
459    """Allows you to select a single item from a dropdown.
460
461    Parameters
462    ----------
463    {selection_params}
464    """
465    _view_name = Unicode('DropdownView').tag(sync=True)
466    _model_name = Unicode('DropdownModel').tag(sync=True)
467
468
469@register
470@doc_subst(_doc_snippets)
471class RadioButtons(_Selection):
472    """Group of radio buttons that represent an enumeration.
473
474    Only one radio button can be toggled at any point in time.
475
476    Parameters
477    ----------
478    {selection_params}
479    """
480    _view_name = Unicode('RadioButtonsView').tag(sync=True)
481    _model_name = Unicode('RadioButtonsModel').tag(sync=True)
482
483
484@register
485@doc_subst(_doc_snippets)
486class Select(_Selection):
487    """
488    Listbox that only allows one item to be selected at any given time.
489
490    Parameters
491    ----------
492    {selection_params}
493
494    rows: int
495        The number of rows to display in the widget.
496    """
497    _view_name = Unicode('SelectView').tag(sync=True)
498    _model_name = Unicode('SelectModel').tag(sync=True)
499    rows = Int(5, help="The number of rows to display.").tag(sync=True)
500
501@register
502@doc_subst(_doc_snippets)
503class SelectMultiple(_MultipleSelection):
504    """
505    Listbox that allows many items to be selected at any given time.
506
507    The ``value``, ``label`` and ``index`` attributes are all iterables.
508
509    Parameters
510    ----------
511    {multiple_selection_params}
512
513    rows: int
514        The number of rows to display in the widget.
515    """
516    _view_name = Unicode('SelectMultipleView').tag(sync=True)
517    _model_name = Unicode('SelectMultipleModel').tag(sync=True)
518    rows = Int(5, help="The number of rows to display.").tag(sync=True)
519
520
521class _SelectionNonempty(_Selection):
522    """Selection that is guaranteed to have a value selected."""
523    # don't allow None to be an option.
524    value = Any(help="Selected value")
525    label = Unicode(help="Selected label")
526    index = Int(help="Selected index").tag(sync=True)
527
528    def __init__(self, *args, **kwargs):
529        if len(kwargs.get('options', ())) == 0:
530            raise TraitError('options must be nonempty')
531        super(_SelectionNonempty, self).__init__(*args, **kwargs)
532
533    @validate('options')
534    def _validate_options(self, proposal):
535        if isinstance(proposal.value, Iterable) and not isinstance(proposal.value, Mapping):
536            proposal.value = tuple(proposal.value)
537        self._options_full = _make_options(proposal.value)
538        if len(self._options_full) == 0:
539            raise TraitError("Option list must be nonempty")
540        return proposal.value
541
542    @validate('index')
543    def _validate_index(self, proposal):
544        if 0 <= proposal.value < len(self._options_labels):
545            return proposal.value
546        else:
547            raise TraitError('Invalid selection: index out of bounds')
548
549class _MultipleSelectionNonempty(_MultipleSelection):
550    """Selection that is guaranteed to have an option available."""
551
552    def __init__(self, *args, **kwargs):
553        if len(kwargs.get('options', ())) == 0:
554            raise TraitError('options must be nonempty')
555        super(_MultipleSelectionNonempty, self).__init__(*args, **kwargs)
556
557    @validate('options')
558    def _validate_options(self, proposal):
559        if isinstance(proposal.value, Iterable) and not isinstance(proposal.value, Mapping):
560            proposal.value = tuple(proposal.value)
561        # throws an error if there is a problem converting to full form
562        self._options_full = _make_options(proposal.value)
563        if len(self._options_full) == 0:
564            raise TraitError("Option list must be nonempty")
565        return proposal.value
566
567@register
568@doc_subst(_doc_snippets)
569class SelectionSlider(_SelectionNonempty):
570    """
571    Slider to select a single item from a list or dictionary.
572
573    Parameters
574    ----------
575    {selection_params}
576
577    {slider_params}
578    """
579    _view_name = Unicode('SelectionSliderView').tag(sync=True)
580    _model_name = Unicode('SelectionSliderModel').tag(sync=True)
581
582    orientation = CaselessStrEnum(
583        values=['horizontal', 'vertical'], default_value='horizontal',
584        help="Vertical or horizontal.").tag(sync=True)
585    readout = Bool(True,
586        help="Display the current selected label next to the slider").tag(sync=True)
587    continuous_update = Bool(True,
588        help="Update the value of the widget as the user is holding the slider.").tag(sync=True)
589
590@register
591@doc_subst(_doc_snippets)
592class SelectionRangeSlider(_MultipleSelectionNonempty):
593    """
594    Slider to select multiple contiguous items from a list.
595
596    The index, value, and label attributes contain the start and end of
597    the selection range, not all items in the range.
598
599    Parameters
600    ----------
601    {multiple_selection_params}
602
603    {slider_params}
604    """
605    _view_name = Unicode('SelectionRangeSliderView').tag(sync=True)
606    _model_name = Unicode('SelectionRangeSliderModel').tag(sync=True)
607
608    value = Tuple(help="Min and max selected values")
609    label = Tuple(help="Min and max selected labels")
610    index = Tuple((0,0), help="Min and max selected indices").tag(sync=True)
611
612    @observe('options')
613    def _propagate_options(self, change):
614        "Select the first range"
615        options = self._options_full
616        self.set_trait('_options_labels', tuple(i[0] for i in options))
617        self._options_values = tuple(i[1] for i in options)
618        if self._initializing_traits_ is not True:
619            self.index = (0, 0)
620
621    @validate('index')
622    def _validate_index(self, proposal):
623        "Make sure we have two indices and check the range of each proposed index."
624        if len(proposal.value) != 2:
625            raise TraitError('Invalid selection: index must have two values, but is %r'%(proposal.value,))
626        if all(0 <= i < len(self._options_labels) for i in proposal.value):
627            return proposal.value
628        else:
629            raise TraitError('Invalid selection: index out of bounds: %s'%(proposal.value,))
630
631    orientation = CaselessStrEnum(
632        values=['horizontal', 'vertical'], default_value='horizontal',
633        help="Vertical or horizontal.").tag(sync=True)
634    readout = Bool(True,
635        help="Display the current selected label next to the slider").tag(sync=True)
636    continuous_update = Bool(True,
637        help="Update the value of the widget as the user is holding the slider.").tag(sync=True)
638