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