From 4a512d147951035bcb3cf0c9573ae94ce228630b Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Sat, 25 Aug 2018 16:05:03 -0400 Subject: [PATCH 01/12] Add support for specifying custom icons in ToggleButtons --- ipywidgets/widgets/widget_selection.py | 8 ++++---- packages/controls/src/widget_selection.ts | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/ipywidgets/widgets/widget_selection.py b/ipywidgets/widgets/widget_selection.py index 297e818ac5..df9a9f3bdc 100644 --- a/ipywidgets/widgets/widget_selection.py +++ b/ipywidgets/widgets/widget_selection.py @@ -416,9 +416,9 @@ class ToggleButtons(_Selection): 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. + Icons to show on the buttons. This should either be the name + of a font-awesome icon or a string following the data URI scheme. See + `http://fontawesome.io/icons/` for a list of font-awesome icons. button_style: str One of 'primary', 'success', 'info', 'warning' or @@ -578,7 +578,7 @@ class SelectionSlider(_SelectionNonempty): 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. diff --git a/packages/controls/src/widget_selection.ts b/packages/controls/src/widget_selection.ts index 38456117fe..d75dc71662 100644 --- a/packages/controls/src/widget_selection.ts +++ b/packages/controls/src/widget_selection.ts @@ -463,11 +463,22 @@ class ToggleButtonsView extends DescriptionView { item_html = utils.escape_html(item); } - let icon = document.createElement('i'); - let button = document.createElement('button'); + let icon if (icons[index]) { + if (icons[index].startsWith('data:')) { + icon = document.createElement('img'); + icon.width = '16'; + icon.height = '16'; + icon.src = icons[index]; + } else { + icon = document.createElement('i'); icon.className = 'fa fa-' + icons[index]; + } + } else { + icon = document.createElement('i'); } + + let button = document.createElement('button'); button.setAttribute('type', 'button'); button.className = 'widget-toggle-button jupyter-button'; if (previous_bstyle) { From aaea487f646ca0661ea9c3d75a182e52f8ee8715 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Tue, 25 Sep 2018 16:04:39 +0200 Subject: [PATCH 02/12] turn icon into a Icon widget --- ipywidgets/widgets/__init__.py | 2 +- ipywidgets/widgets/tests/test_widget_icon.py | 54 ++++++++ ipywidgets/widgets/trait_types.py | 24 ++++ ipywidgets/widgets/widget_bool.py | 8 +- ipywidgets/widgets/widget_button.py | 23 ++-- ipywidgets/widgets/widget_media.py | 29 ++++ ipywidgets/widgets/widget_selection.py | 7 +- packages/controls/src/index.ts | 1 + packages/controls/src/widget_bool.ts | 35 +++-- packages/controls/src/widget_button.ts | 36 +++-- packages/controls/src/widget_icon.ts | 131 +++++++++++++++++++ packages/controls/src/widget_selection.ts | 41 +++--- 12 files changed, 331 insertions(+), 60 deletions(-) create mode 100644 ipywidgets/widgets/tests/test_widget_icon.py create mode 100644 packages/controls/src/widget_icon.ts diff --git a/ipywidgets/widgets/__init__.py b/ipywidgets/widgets/__init__.py index 8f00e76baf..7bdd82bf7f 100644 --- a/ipywidgets/widgets/__init__.py +++ b/ipywidgets/widgets/__init__.py @@ -23,5 +23,5 @@ from .interaction import interact, interactive, fixed, interact_manual, interactive_output from .widget_link import jslink, jsdlink from .widget_layout import Layout -from .widget_media import Image, Video, Audio +from .widget_media import Image, Icon, Video, Audio from .widget_style import Style diff --git a/ipywidgets/widgets/tests/test_widget_icon.py b/ipywidgets/widgets/tests/test_widget_icon.py new file mode 100644 index 0000000000..915a2313b6 --- /dev/null +++ b/ipywidgets/widgets/tests/test_widget_icon.py @@ -0,0 +1,54 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +"""Test Icon widget""" + +from ipywidgets import Icon, Button, ToggleButtons, ToggleButton +from .test_widget_image import get_logo_png, LOGO_PNG_DIGEST, assert_equal_hash + + +def test_icon_fontawesome(): + icon = Icon.fontawesome('home') + assert icon.format == 'fontawesome' + assert icon.value.decode('utf-8') == 'home' + +def test_icon_filename(): + with get_logo_png() as LOGO_PNG: + icon = Icon.from_file(LOGO_PNG) + assert icon.format == 'png' + assert_equal_hash(icon.value, LOGO_PNG_DIGEST) + +def test_coerce_button(): + button = Button() + button.icon is None + + # backwards compatible with a string value (which indicates a fontawesome icon) + button = Button(icon='home') + assert button.icon.value.decode('utf-8') == 'home' + assert button.icon.format == 'fontawesome' + + # check if no copy is made + button2 = Button(icon=button.icon) + assert button2.icon is button.icon + +def test_coerce_toggle_button(): + # backwards compatible with a string value (which indicates a fontawesome icon) + button = ToggleButton(icon='home') + assert button.icon.value.decode('utf-8') == 'home' + assert button.icon.format == 'fontawesome' + + # check if no copy is made + button2 = ToggleButton(icon=button.icon) + assert button2.icon is button.icon + +def test_coerce_toggle_buttons(): + icon1 = 'home' + icon2 = Icon.fontawesome('refresh') + buttons = ToggleButtons(values=[''] * 2, icons=[icon1, icon2]) + assert buttons.icons[0].value.decode('utf-8') == 'home' + assert buttons.icons[0].format == 'fontawesome' + assert buttons.icons[1].value.decode('utf-8') == 'refresh' + assert buttons.icons[1].format == 'fontawesome' + assert buttons.icons[1] is icon2 + + diff --git a/ipywidgets/widgets/trait_types.py b/ipywidgets/widgets/trait_types.py index aeb6a70c34..e64164fd01 100644 --- a/ipywidgets/widgets/trait_types.py +++ b/ipywidgets/widgets/trait_types.py @@ -8,6 +8,7 @@ import re import traitlets import datetime as dt +from ipython_genutils.py3compat import string_types, PY3 _color_names = ['aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'honeydew', 'hotpink', 'indianred ', 'indigo ', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'transparent', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen'] @@ -142,6 +143,29 @@ def make_dynamic_default(self): return self.klass(*(self.default_args or ()), **(self.default_kwargs or {})) +class InstanceString(traitlets.Instance): + """An instance trait which coerces a string to a instance. + + The instance is created by the class constructor or the optional + factory. + """ + + def __init__(self, klass, factory=None, **kwargs): + super(InstanceString, self).__init__(klass, **kwargs) + self.factory = factory or klass + + def validate(self, obj, value): + if isinstance(value, string_types): + return super(InstanceString, self).validate(obj, self.factory(value)) + else: + return super(InstanceString, self).validate(obj, value) + + def make_dynamic_default(self): + if not self.default_args: + return None + else: + return self.factory(*self.default_args, **(self.default_kwargs or {})) + # The regexp is taken # from https://github.com/d3/d3-format/blob/master/src/formatSpecifier.js diff --git a/ipywidgets/widgets/widget_bool.py b/ipywidgets/widgets/widget_bool.py index 3703b9fcef..d7f75c4512 100644 --- a/ipywidgets/widgets/widget_bool.py +++ b/ipywidgets/widgets/widget_bool.py @@ -9,8 +9,10 @@ from .widget_description import DescriptionWidget from .widget_core import CoreWidget from .valuewidget import ValueWidget -from .widget import register -from traitlets import Unicode, Bool, CaselessStrEnum +from .widget_media import Icon +from .widget import register, widget_serialization +from .trait_types import InstanceString +from traitlets import Unicode, Bool, CaselessStrEnum, Instance class _Bool(DescriptionWidget, ValueWidget, CoreWidget): @@ -63,7 +65,7 @@ class ToggleButton(_Bool): _model_name = Unicode('ToggleButtonModel').tag(sync=True) tooltip = Unicode(help="Tooltip caption of the toggle button.").tag(sync=True) - icon = Unicode('', help= "Font-awesome icon.").tag(sync=True) + icon = InstanceString(Icon, Icon.fontawesome, default_value=None, allow_none=True, help= "Optional button icon.").tag(sync=True, **widget_serialization) button_style = CaselessStrEnum( values=['primary', 'success', 'info', 'warning', 'danger', ''], default_value='', diff --git a/ipywidgets/widgets/widget_button.py b/ipywidgets/widgets/widget_button.py index 0d0498a71a..857930b1b7 100644 --- a/ipywidgets/widgets/widget_button.py +++ b/ipywidgets/widgets/widget_button.py @@ -11,7 +11,8 @@ from .widget import CallbackDispatcher, register, widget_serialization from .widget_core import CoreWidget from .widget_style import Style -from .trait_types import Color, InstanceDict +from .widget_media import Icon +from .trait_types import Color, InstanceDict, InstanceString from traitlets import Unicode, Bool, CaselessStrEnum, Instance, validate, default import warnings @@ -49,7 +50,7 @@ class Button(DOMWidget, CoreWidget): description = Unicode(help="Button label.").tag(sync=True) tooltip = Unicode(help="Tooltip caption of the button.").tag(sync=True) disabled = Bool(False, help="Enable or disable user changes.").tag(sync=True) - icon = Unicode('', help="Font-awesome icon name, without the 'fa-' prefix.").tag(sync=True) + icon = InstanceString(Icon, Icon.fontawesome, default_value=None, allow_none=True, help= "Optional button icon.").tag(sync=True, **widget_serialization) button_style = CaselessStrEnum( values=['primary', 'success', 'info', 'warning', 'danger', ''], default_value='', @@ -62,15 +63,15 @@ def __init__(self, **kwargs): self._click_handlers = CallbackDispatcher() self.on_msg(self._handle_button_msg) - @validate('icon') - def _validate_icon(self, proposal): - """Strip 'fa-' if necessary'""" - value = proposal['value'] - if value.startswith('fa-'): - warnings.warn("icons names no longer start with 'fa-', " - "just use the class name itself (for example, 'check' instead of 'fa-check')", DeprecationWarning) - value = value[3:] - return value + # @validate('icon') + # def _validate_icon(self, proposal): + # """Strip 'fa-' if necessary'""" + # value = proposal['value'] + # if value.startswith('fa-'): + # warnings.warn("icons names no longer start with 'fa-', " + # "just use the class name itself (for example, 'check' instead of 'fa-check')", DeprecationWarning) + # value = value[3:] + # return value def on_click(self, callback, remove=False): """Register a callback to execute when the button is clicked. diff --git a/ipywidgets/widgets/widget_media.py b/ipywidgets/widgets/widget_media.py index 4f1170de38..e6b78318e5 100644 --- a/ipywidgets/widgets/widget_media.py +++ b/ipywidgets/widgets/widget_media.py @@ -141,11 +141,40 @@ def _get_repr(self, cls): signature = ', '.join(signature) return '%s(%s)' % (class_name, signature) +class Icon(_Media): + _view_name = Unicode('IconView').tag(sync=True) + _model_name = Unicode('IconModel').tag(sync=True) + + # Define the custom state properties to sync with the front-end + format = Unicode('png', help="The format of the icon.").tag(sync=True) + width = CUnicode(help="Width of the icon in pixels.").tag(sync=True) + height = CUnicode(help="Height of the icon in pixels.").tag(sync=True) + + @classmethod + def from_file(cls, filename, **kwargs): + return cls._from_file('image', filename, **kwargs) + + @classmethod + def fontawesome(cls, name, **kwargs): + """Creates an fontawasome icon (without the fa-prefix) + + Example: + + >>> icon = Icon.fontawesome('home') + >>> button = Button(icon=icon, description='Home') + + """ + return cls(value=name.encode('utf-8'), format='fontawesome', **kwargs) + + def __repr__(self): + return self._get_repr(Icon) @register class Image(_Media): """Displays an image as a widget. + '', help="Font-awesome icon name, without the 'fa-' prefix." + The `value` of this widget accepts a byte string. The byte string is the raw image data that you want the browser to display. You can explicitly define the format of the byte string using the `format` trait (which diff --git a/ipywidgets/widgets/widget_selection.py b/ipywidgets/widgets/widget_selection.py index df9a9f3bdc..9feaac615d 100644 --- a/ipywidgets/widgets/widget_selection.py +++ b/ipywidgets/widgets/widget_selection.py @@ -17,10 +17,11 @@ from .valuewidget import ValueWidget from .widget_core import CoreWidget from .widget_style import Style -from .trait_types import InstanceDict, TypedTuple +from .widget_media import Icon +from .trait_types import InstanceDict, TypedTuple, InstanceString from .widget import register, widget_serialization from .docutils import doc_subst -from traitlets import (Unicode, Bool, Int, Any, Dict, TraitError, CaselessStrEnum, +from traitlets import (Unicode, Bool, Int, Any, Dict, List, TraitError, CaselessStrEnum, Tuple, Union, observe, validate) from ipython_genutils.py3compat import unicode_type @@ -431,7 +432,7 @@ class ToggleButtons(_Selection): _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) + icons = TypedTuple(trait=InstanceString(Icon, Icon.fontawesome), help="Icons names for each button (FontAwesome names without the fa- prefix).").tag(sync=True, **widget_serialization) style = InstanceDict(ToggleButtonsStyle).tag(sync=True, **widget_serialization) button_style = CaselessStrEnum( diff --git a/packages/controls/src/index.ts b/packages/controls/src/index.ts index 14e38a1947..a0303c3229 100644 --- a/packages/controls/src/index.ts +++ b/packages/controls/src/index.ts @@ -8,6 +8,7 @@ export * from './widget_bool'; export * from './widget_button'; export * from './widget_box'; export * from './widget_image'; +export * from './widget_icon'; export * from './widget_video'; export * from './widget_audio'; export * from './widget_color'; diff --git a/packages/controls/src/widget_bool.ts b/packages/controls/src/widget_bool.ts index 598879a919..8c002ccf8b 100644 --- a/packages/controls/src/widget_bool.ts +++ b/packages/controls/src/widget_bool.ts @@ -5,12 +5,16 @@ import { CoreDescriptionModel } from './widget_core'; +import { + IconModel, IconView +} from './widget_icon'; + import { DescriptionView } from './widget_description'; import { - DOMWidgetView + DOMWidgetView, unpack_models } from '@jupyter-widgets/base'; import * as _ from 'underscore'; @@ -147,10 +151,14 @@ class ToggleButtonModel extends BoolModel { _view_name: 'ToggleButtonView', _model_name: 'ToggleButtonModel', tooltip: '', - icon: '', + icon: null, button_style: '' }); } + static serializers = { + ...BoolModel.serializers, + icon: {deserialize: unpack_models}, + }; } export @@ -182,7 +190,7 @@ class ToggleButtonView extends DOMWidgetView { * Called when the model is changed. The model may have been * changed by another view or by a state update from the back-end. */ - update(options?){ + async update(options?){ if (this.model.get('value')) { this.el.classList.add('mod-active'); } else { @@ -194,16 +202,22 @@ class ToggleButtonView extends DOMWidgetView { this.el.setAttribute('title', this.model.get('tooltip')); let description = this.model.get('description'); - let icon = this.model.get('icon'); - if (description.trim().length === 0 && icon.trim().length === 0) { + let icon : IconModel = this.model.get('icon'); + if(this.iconView) { + this.iconView.remove() + this.iconView = null; + } + if (description.trim().length === 0 && !icon) { this.el.innerHTML = ' '; // Preserve button height } else { this.el.textContent = ''; - if (icon.trim().length) { - let i = document.createElement('i'); - this.el.appendChild(i); - i.classList.add('fa'); - i.classList.add('fa-' + icon); + if (icon) { + this.iconView = await this.create_child_view(icon) + if (description.length === 0) { + this.iconView.el.classList.add('center'); + } + this.el.appendChild(this.iconView.el); + this.iconView.listenTo(icon, 'change', () => this.update()) } this.el.appendChild(document.createTextNode(description)); } @@ -245,6 +259,7 @@ class ToggleButtonView extends DOMWidgetView { } el: HTMLButtonElement; + iconView: IconView; static class_map = { primary: ['mod-primary'], diff --git a/packages/controls/src/widget_button.ts b/packages/controls/src/widget_button.ts index d090290714..d93b7aa1cc 100644 --- a/packages/controls/src/widget_button.ts +++ b/packages/controls/src/widget_button.ts @@ -2,13 +2,17 @@ // Distributed under the terms of the Modified BSD License. import { - DOMWidgetView, StyleModel + DOMWidgetView, StyleModel, unpack_models } from '@jupyter-widgets/base'; import { CoreDOMWidgetModel } from './widget_core'; +import { + IconModel, IconView +} from './widget_icon'; + import { JUPYTER_CONTROLS_VERSION } from './version'; @@ -47,13 +51,17 @@ class ButtonModel extends CoreDOMWidgetModel { description: '', tooltip: '', disabled: false, - icon: '', + icon: null, button_style: '', _view_name: 'ButtonView', _model_name: 'ButtonModel', style: null }); } + static serializers = { + ...CoreDOMWidgetModel.serializers, + icon: {deserialize: unpack_models}, + }; } export @@ -77,22 +85,25 @@ class ButtonView extends DOMWidgetView { * Called when the model is changed. The model may have been * changed by another view or by a state update from the back-end. */ - update() { + async update() { this.el.disabled = this.model.get('disabled'); this.el.setAttribute('title', this.model.get('tooltip')); let description = this.model.get('description'); - let icon = this.model.get('icon'); - if (description.length || icon.length) { + let icon : IconModel = this.model.get('icon'); + if (description.length || icon) { + if(this.iconView) { + this.iconView.remove() + this.iconView = null; + } this.el.textContent = ''; - if (icon.length) { - let i = document.createElement('i'); - i.classList.add('fa'); - i.classList.add('fa-' + icon); - if (description.length === 0) { - i.classList.add('center'); + if (icon) { + this.iconView = await this.create_child_view(icon) + if (description.length === 0 && this.iconView) { + this.iconView.el.classList.add('center'); } - this.el.appendChild(i); + this.el.appendChild(this.iconView.el); + this.iconView.listenTo(icon, 'change', () => this.update()) } this.el.appendChild(document.createTextNode(description)); } @@ -138,6 +149,7 @@ class ButtonView extends DOMWidgetView { } el: HTMLButtonElement; + iconView: IconView; static class_map = { primary: ['mod-primary'], diff --git a/packages/controls/src/widget_icon.ts b/packages/controls/src/widget_icon.ts new file mode 100644 index 0000000000..7792c950cc --- /dev/null +++ b/packages/controls/src/widget_icon.ts @@ -0,0 +1,131 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + DOMWidgetView +} from '@jupyter-widgets/base'; + +import { + CoreDOMWidgetModel +} from './widget_core'; + +import * as _ from 'underscore'; + +export +class IconModel extends CoreDOMWidgetModel { + defaults() { + return _.extend(super.defaults(), { + _model_name: 'IconModel', + _view_name: 'IconView', + format: 'png', + width: '', + height: '', + value: new DataView(new ArrayBuffer(0)) + }); + } + + static serializers = { + ...CoreDOMWidgetModel.serializers, + value: {serialize: (value, manager) => { + return new DataView(value.buffer.slice(0)); + }} + }; +} + +export +class IconView extends DOMWidgetView { + render() { + /** + * Called when view is rendered. + */ + super.render(); + this.pWidget.addClass('jupyter-widgets'); + this.pWidget.addClass('widget-image'); + this.update(); // Set defaults. + } + + update() { + /** + * Update the contents of this view + * + * Called when the model is changed. The model may have been + * changed by another view or by a state update from the back-end. + */ + + let format = this.model.get('format'); + let value = this.model.get('value'); + if(this.img) { + this.el.removeChild(this.img) + if (this.img.src) { + URL.revokeObjectURL(this.img.src); + } + this.img = null; + } + // remove the fontawesome classes + Array.prototype.slice.call(this.el.classList).forEach((name) => { + if(name === 'fa' || name.startsWith('fa-')) + this.el.classList.remove(name) + }); + if (format === 'fontawesome') { + let iconName = (new TextDecoder('utf-8')).decode(value.buffer); + this.el.classList.add('fa'); + this.el.classList.add('fa-' + iconName); + } else { + + let url; + if (format !== 'url') { + let blob = new Blob([value], {type: `image/${this.model.get('format')}`}); + url = URL.createObjectURL(blob); + } else { + url = (new TextDecoder('utf-8')).decode(value.buffer); + } + + this.img = document.createElement('img'); + this.img.src = url; + // Clean up the old objectURL + // let oldurl = this.el.src; + // this.el.src = url; + // if (oldurl && typeof oldurl !== 'string') { + // URL.revokeObjectURL(oldurl); + // } + let width = this.model.get('width'); + if (width !== undefined && width.length > 0) { + this.img.setAttribute('width', width); + } else { + this.img.removeAttribute('width'); + } + + let height = this.model.get('height'); + if (height !== undefined && height.length > 0) { + this.img.setAttribute('height', height); + } else { + this.img.removeAttribute('height'); + } + this.el.appendChild(this.img); + } + return super.update(); + } + + remove() { + if (this.img && this.img.src) { + URL.revokeObjectURL(this.img.src); + } + super.remove(); + } + + /** + * The default tag name. + * + * #### Notes + * This is a read-only attribute. + */ + get tagName() { + // We can't make this an attribute with a default value + // since it would be set after it is needed in the + // constructor. + return 'span'; + } + + el: HTMLElement; + img: HTMLImageElement; +} diff --git a/packages/controls/src/widget_selection.ts b/packages/controls/src/widget_selection.ts index d75dc71662..6e7818d385 100644 --- a/packages/controls/src/widget_selection.ts +++ b/packages/controls/src/widget_selection.ts @@ -2,9 +2,13 @@ // Distributed under the terms of the Modified BSD License. import { - StyleModel + StyleModel, unpack_models } from '@jupyter-widgets/base'; +import { + IconModel, IconView +} from './widget_icon'; + import { CoreDescriptionModel, } from './widget_core'; @@ -397,6 +401,10 @@ export _view_name: 'ToggleButtonsView' }; } + static serializers = { + ...SelectionModel.serializers, + icons: {deserialize: unpack_models}, + }; } @@ -451,33 +459,21 @@ class ToggleButtonsView extends DescriptionView { } if (stale && (options === undefined || options.updated_view !== this)) { + if(this.iconViews) { + this.iconViews.forEach((i) => i.remove()) + } + this.iconViews = new Array(items.length) // Add items to the DOM. this.buttongroup.textContent = ''; - items.forEach((item: any, index: number) => { + items.forEach(async (item: any, index: number) => { let item_html; - let empty = item.trim().length === 0 && - (!icons[index] || icons[index].trim().length === 0); + let empty = item.trim().length === 0 && !icons[index]; if (empty) { item_html = ' '; } else { item_html = utils.escape_html(item); } - let icon - if (icons[index]) { - if (icons[index].startsWith('data:')) { - icon = document.createElement('img'); - icon.width = '16'; - icon.height = '16'; - icon.src = icons[index]; - } else { - icon = document.createElement('i'); - icon.className = 'fa fa-' + icons[index]; - } - } else { - icon = document.createElement('i'); - } - let button = document.createElement('button'); button.setAttribute('type', 'button'); button.className = 'widget-toggle-button jupyter-button'; @@ -487,13 +483,17 @@ class ToggleButtonsView extends DescriptionView { button.innerHTML = item_html; button.setAttribute('data-value', encodeURIComponent(item)); button.setAttribute('value', index.toString()); - button.appendChild(icon); + // let icon = icons[index]; button.disabled = disabled; if (tooltips[index]) { button.setAttribute('title', tooltips[index]); } view.update_style_traits(button); view.buttongroup.appendChild(button); + if(icons[index]) { + this.iconViews[index] = await this.create_child_view(icons[index]); + button.appendChild(this.iconViews[index].el); + } }); } @@ -572,6 +572,7 @@ class ToggleButtonsView extends DescriptionView { private _css_state: any; buttongroup: HTMLDivElement; + iconViews: Array; } export From 44c71be19c6f711dc4e2769baeb795d2a9b8a8eb Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Wed, 26 Sep 2018 08:48:33 +0200 Subject: [PATCH 03/12] update docstring and spec --- ipywidgets/widgets/widget_bool.py | 6 +- ipywidgets/widgets/widget_button.py | 6 +- ipywidgets/widgets/widget_media.py | 68 +++++++++++-------- ipywidgets/widgets/widget_selection.py | 4 +- packages/schema/jupyterwidgetmodels.latest.md | 23 ++++++- 5 files changed, 67 insertions(+), 40 deletions(-) diff --git a/ipywidgets/widgets/widget_bool.py b/ipywidgets/widgets/widget_bool.py index d7f75c4512..a8c0a1273b 100644 --- a/ipywidgets/widgets/widget_bool.py +++ b/ipywidgets/widgets/widget_bool.py @@ -58,14 +58,14 @@ class ToggleButton(_Bool): description displayed next to the button tooltip: str tooltip caption of the toggle button - icon: str - font-awesome icon name + icon: Icon + button icon """ _view_name = Unicode('ToggleButtonView').tag(sync=True) _model_name = Unicode('ToggleButtonModel').tag(sync=True) tooltip = Unicode(help="Tooltip caption of the toggle button.").tag(sync=True) - icon = InstanceString(Icon, Icon.fontawesome, default_value=None, allow_none=True, help= "Optional button icon.").tag(sync=True, **widget_serialization) + icon = InstanceString(Icon, Icon.fontawesome, default_value=None, allow_none=True, help= "Button icon.").tag(sync=True, **widget_serialization) button_style = CaselessStrEnum( values=['primary', 'success', 'info', 'warning', 'danger', ''], default_value='', diff --git a/ipywidgets/widgets/widget_button.py b/ipywidgets/widgets/widget_button.py index 857930b1b7..acef3a7c81 100644 --- a/ipywidgets/widgets/widget_button.py +++ b/ipywidgets/widgets/widget_button.py @@ -39,8 +39,8 @@ class Button(DOMWidget, CoreWidget): description displayed next to the button tooltip: str tooltip caption of the toggle button - icon: str - font-awesome icon name + icon: Icon + button icon disabled: bool whether user interaction is enabled """ @@ -50,7 +50,7 @@ class Button(DOMWidget, CoreWidget): description = Unicode(help="Button label.").tag(sync=True) tooltip = Unicode(help="Tooltip caption of the button.").tag(sync=True) disabled = Bool(False, help="Enable or disable user changes.").tag(sync=True) - icon = InstanceString(Icon, Icon.fontawesome, default_value=None, allow_none=True, help= "Optional button icon.").tag(sync=True, **widget_serialization) + icon = InstanceString(Icon, Icon.fontawesome, default_value=None, allow_none=True, help= "Button icon.").tag(sync=True, **widget_serialization) button_style = CaselessStrEnum( values=['primary', 'success', 'info', 'warning', 'danger', ''], default_value='', diff --git a/ipywidgets/widgets/widget_media.py b/ipywidgets/widgets/widget_media.py index e6b78318e5..ca7dc2cd4e 100644 --- a/ipywidgets/widgets/widget_media.py +++ b/ipywidgets/widgets/widget_media.py @@ -141,40 +141,11 @@ def _get_repr(self, cls): signature = ', '.join(signature) return '%s(%s)' % (class_name, signature) -class Icon(_Media): - _view_name = Unicode('IconView').tag(sync=True) - _model_name = Unicode('IconModel').tag(sync=True) - - # Define the custom state properties to sync with the front-end - format = Unicode('png', help="The format of the icon.").tag(sync=True) - width = CUnicode(help="Width of the icon in pixels.").tag(sync=True) - height = CUnicode(help="Height of the icon in pixels.").tag(sync=True) - - @classmethod - def from_file(cls, filename, **kwargs): - return cls._from_file('image', filename, **kwargs) - - @classmethod - def fontawesome(cls, name, **kwargs): - """Creates an fontawasome icon (without the fa-prefix) - - Example: - - >>> icon = Icon.fontawesome('home') - >>> button = Button(icon=icon, description='Home') - - """ - return cls(value=name.encode('utf-8'), format='fontawesome', **kwargs) - - def __repr__(self): - return self._get_repr(Icon) @register class Image(_Media): """Displays an image as a widget. - '', help="Font-awesome icon name, without the 'fa-' prefix." - The `value` of this widget accepts a byte string. The byte string is the raw image data that you want the browser to display. You can explicitly define the format of the byte string using the `format` trait (which @@ -199,6 +170,45 @@ def __repr__(self): return self._get_repr(Image) +@register +class Icon(_Media): + """Display a fontawesome icon or image. + + Icon is a superset of Image from the user perspective, but provides a + different API on the front-end. Icon is used for buttons etc. + + The most common way to use Icon is to call the `Icon.fontawesome` factory + method. + + """ + _view_name = Unicode('IconView').tag(sync=True) + _model_name = Unicode('IconModel').tag(sync=True) + + # Define the custom state properties to sync with the front-end + format = Unicode('png', help="The format of the icon.").tag(sync=True) + width = CUnicode(help="Width of the icon in pixels.").tag(sync=True) + height = CUnicode(help="Height of the icon in pixels.").tag(sync=True) + + @classmethod + def from_file(cls, filename, **kwargs): + return cls._from_file('image', filename, **kwargs) + + @classmethod + def fontawesome(cls, name, **kwargs): + """Creates an fontawasome icon (without the fa-prefix) + + Example: + + >>> icon = Icon.fontawesome('home') + >>> button = Button(icon=icon, description='Home') + + """ + return cls(value=name.encode('utf-8'), format='fontawesome', **kwargs) + + def __repr__(self): + return self._get_repr(Icon) + + @register class Video(_Media): """Displays a video as a widget. diff --git a/ipywidgets/widgets/widget_selection.py b/ipywidgets/widgets/widget_selection.py index 9feaac615d..7ff4538cf4 100644 --- a/ipywidgets/widgets/widget_selection.py +++ b/ipywidgets/widgets/widget_selection.py @@ -164,7 +164,7 @@ class _Selection(DescriptionWidget, ValueWidget, CoreWidget): _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) + _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) @@ -432,7 +432,7 @@ class ToggleButtons(_Selection): _model_name = Unicode('ToggleButtonsModel').tag(sync=True) tooltips = TypedTuple(Unicode(), help="Tooltips for each button.").tag(sync=True) - icons = TypedTuple(trait=InstanceString(Icon, Icon.fontawesome), help="Icons names for each button (FontAwesome names without the fa- prefix).").tag(sync=True, **widget_serialization) + icons = TypedTuple(trait=InstanceString(Icon, Icon.fontawesome), help="Icons for each button.").tag(sync=True, **widget_serialization) style = InstanceDict(ToggleButtonsStyle).tag(sync=True, **widget_serialization) button_style = CaselessStrEnum( diff --git a/packages/schema/jupyterwidgetmodels.latest.md b/packages/schema/jupyterwidgetmodels.latest.md index 1e28f8cc47..9dd690a7b5 100644 --- a/packages/schema/jupyterwidgetmodels.latest.md +++ b/packages/schema/jupyterwidgetmodels.latest.md @@ -163,7 +163,7 @@ Attribute | Type | Default | Help `button_style` | string (one of `'primary'`, `'success'`, `'info'`, `'warning'`, `'danger'`, `''`) | `''` | Use a predefined styling for the button. `description` | string | `''` | Button label. `disabled` | boolean | `false` | Enable or disable user changes. -`icon` | string | `''` | Font-awesome icon name, without the 'fa-' prefix. +`icon` | `null` or reference to Icon widget | reference to new instance | Button icon. `layout` | reference to Layout widget | reference to new instance | `style` | reference to ButtonStyle widget | reference to new instance | `tooltip` | string | `''` | Tooltip caption of the button. @@ -527,6 +527,23 @@ Attribute | Type | Default | Help `style` | reference to DescriptionStyle widget | reference to new instance | Styling customizations `value` | string | `''` | String value +### IconModel (@jupyter-widgets/controls, 1.4.0); IconView (@jupyter-widgets/controls, 1.4.0) + +Attribute | Type | Default | Help +-----------------|------------------|------------------|---- +`_dom_classes` | array of string | `[]` | CSS classes applied to widget DOM element +`_model_module` | string | `'@jupyter-widgets/controls'` | +`_model_module_version` | string | `'1.4.0'` | +`_model_name` | string | `'IconModel'` | +`_view_module` | string | `'@jupyter-widgets/controls'` | +`_view_module_version` | string | `'1.4.0'` | +`_view_name` | string | `'IconView'` | +`format` | string | `'png'` | The format of the icon. +`height` | string | `''` | Height of the icon in pixels. +`layout` | reference to Layout widget | reference to new instance | +`value` | Bytes | `b''` | The media data as a byte string. +`width` | string | `''` | Width of the icon in pixels. + ### ImageModel (@jupyter-widgets/controls, 1.4.0); ImageView (@jupyter-widgets/controls, 1.4.0) Attribute | Type | Default | Help @@ -913,7 +930,7 @@ Attribute | Type | Default | Help `description` | string | `''` | Description of the control. `description_tooltip` | `null` or string | `null` | Tooltip for the description (defaults to description). `disabled` | boolean | `false` | Enable or disable user changes. -`icon` | string | `''` | Font-awesome icon. +`icon` | `null` or reference to Icon widget | reference to new instance | Button icon. `layout` | reference to Layout widget | reference to new instance | `style` | reference to DescriptionStyle widget | reference to new instance | Styling customizations `tooltip` | string | `''` | Tooltip caption of the toggle button. @@ -935,7 +952,7 @@ Attribute | Type | Default | Help `description` | string | `''` | Description of the control. `description_tooltip` | `null` or string | `null` | Tooltip for the description (defaults to description). `disabled` | boolean | `false` | Enable or disable user changes -`icons` | array of string | `[]` | Icons names for each button (FontAwesome names without the fa- prefix). +`icons` | array of reference to Icon widget | `[]` | Icons for each button. `index` | `null` or number (integer) | `null` | Selected index `layout` | reference to Layout widget | reference to new instance | `style` | reference to ToggleButtonsStyle widget | reference to new instance | From 5c40113efb58eeafa31ea7edde4ac819f4fce148 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Mon, 1 Oct 2018 13:08:59 +0200 Subject: [PATCH 04/12] do not require the label to be unqique --- packages/controls/src/widget_selection.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/controls/src/widget_selection.ts b/packages/controls/src/widget_selection.ts index 6e7818d385..143db5deae 100644 --- a/packages/controls/src/widget_selection.ts +++ b/packages/controls/src/widget_selection.ts @@ -497,10 +497,12 @@ class ToggleButtonsView extends DescriptionView { }); } + buttons = this.buttongroup.querySelectorAll('button'); // Select active button. items.forEach(function(item: any, index: number) { - let item_query = '[data-value="' + encodeURIComponent(item) + '"]'; - let button = view.buttongroup.querySelector(item_query); + // let item_query = '[data-value="' + encodeURIComponent(item) + '"]'; + // let button = view.buttongroup.querySelector(item_query); + let button = buttons[index]; if (view.model.get('index') === index) { button.classList.add('mod-active'); } else { @@ -563,7 +565,12 @@ class ToggleButtonsView extends DescriptionView { * model to update. */ _handle_click (event) { - this.model.set('index', parseInt(event.target.value), {updated_view: this}); + let clickedButton = event.target; + while(clickedButton.nodeName !== 'BUTTON') + clickedButton = clickedButton.parentElement; + let buttons = Array.prototype.slice.call(this.buttongroup.querySelectorAll('button')); + let index = buttons.indexOf(clickedButton); + this.model.set('index', index, {updated_view: this}); this.touch(); // We also send a clicked event, since the value is only set if it changed. // See https://github.com/jupyter-widgets/ipywidgets/issues/763 From ae6e7cb39cef81edc531d91cfede6987c3427084 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Mon, 1 Oct 2018 13:10:06 +0200 Subject: [PATCH 05/12] renaming: view->this, item->label, items->labels --- packages/controls/src/widget_selection.ts | 38 ++++++++++------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/controls/src/widget_selection.ts b/packages/controls/src/widget_selection.ts index 143db5deae..88567936a6 100644 --- a/packages/controls/src/widget_selection.ts +++ b/packages/controls/src/widget_selection.ts @@ -440,19 +440,18 @@ class ToggleButtonsView extends DescriptionView { * changed by another view or by a state update from the back-end. */ update(options?) { - let view = this; - let items: string[] = this.model.get('_options_labels'); + let labels: string[] = this.model.get('_options_labels'); let icons = this.model.get('icons') || []; let previous_icons = this.model.previous('icons') || []; let previous_bstyle = ToggleButtonsView.classMap[this.model.previous('button_style')] || ''; - let tooltips = view.model.get('tooltips') || []; + let tooltips = this.model.get('tooltips') || []; let disabled = this.model.get('disabled'); let buttons = this.buttongroup.querySelectorAll('button'); let values = _.pluck(buttons, 'value'); let stale = false; - for (let i = 0, len = items.length; i < len; ++i) { - if (values[i] !== items[i] || icons[i] !== previous_icons[i]) { + for (let i = 0, len = labels.length; i < len; ++i) { + if (values[i] !== labels[i] || icons[i] !== previous_icons[i]) { stale = true; break; } @@ -462,16 +461,16 @@ class ToggleButtonsView extends DescriptionView { if(this.iconViews) { this.iconViews.forEach((i) => i.remove()) } - this.iconViews = new Array(items.length) - // Add items to the DOM. + this.iconViews = new Array(labels.length) + // Add labels to the DOM. this.buttongroup.textContent = ''; - items.forEach(async (item: any, index: number) => { - let item_html; - let empty = item.trim().length === 0 && !icons[index]; + labels.forEach(async (label: any, index: number) => { + let label_html; + let empty = label.trim().length === 0 && !icons[index]; if (empty) { - item_html = ' '; + label_html = ' '; } else { - item_html = utils.escape_html(item); + label_html = utils.escape_html(label); } let button = document.createElement('button'); @@ -480,16 +479,15 @@ class ToggleButtonsView extends DescriptionView { if (previous_bstyle) { button.classList.add(previous_bstyle); } - button.innerHTML = item_html; - button.setAttribute('data-value', encodeURIComponent(item)); + button.innerHTML = label_html; + // button.setAttribute('data-value', encodeURIComponent(label)); button.setAttribute('value', index.toString()); - // let icon = icons[index]; button.disabled = disabled; if (tooltips[index]) { button.setAttribute('title', tooltips[index]); } - view.update_style_traits(button); - view.buttongroup.appendChild(button); + this.update_style_traits(button); + this.buttongroup.appendChild(button); if(icons[index]) { this.iconViews[index] = await this.create_child_view(icons[index]); button.appendChild(this.iconViews[index].el); @@ -499,11 +497,9 @@ class ToggleButtonsView extends DescriptionView { buttons = this.buttongroup.querySelectorAll('button'); // Select active button. - items.forEach(function(item: any, index: number) { - // let item_query = '[data-value="' + encodeURIComponent(item) + '"]'; - // let button = view.buttongroup.querySelector(item_query); + labels.forEach((label: any, index: number) => { let button = buttons[index]; - if (view.model.get('index') === index) { + if (this.model.get('index') === index) { button.classList.add('mod-active'); } else { button.classList.remove('mod-active'); From f393b4403f7e636cf933aac2d13984f079410f1d Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Mon, 1 Oct 2018 13:10:42 +0200 Subject: [PATCH 06/12] give icons a pointer icon as well --- packages/controls/css/widgets-base.css | 4 ++++ packages/controls/src/widget_selection.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/controls/css/widgets-base.css b/packages/controls/css/widgets-base.css index 2f618b58c6..6a19f06e29 100644 --- a/packages/controls/css/widgets-base.css +++ b/packages/controls/css/widgets-base.css @@ -264,6 +264,10 @@ width: var(--jp-widgets-inline-width-short); } +.widget-button-icon { + cursor: pointer; +} + /* Widget Label Styling */ /* Override Bootstrap label css */ diff --git a/packages/controls/src/widget_selection.ts b/packages/controls/src/widget_selection.ts index 88567936a6..ab60ddeef7 100644 --- a/packages/controls/src/widget_selection.ts +++ b/packages/controls/src/widget_selection.ts @@ -490,6 +490,7 @@ class ToggleButtonsView extends DescriptionView { this.buttongroup.appendChild(button); if(icons[index]) { this.iconViews[index] = await this.create_child_view(icons[index]); + this.iconViews[index].el.classList.add('widget-button-icon') button.appendChild(this.iconViews[index].el); } }); From afa810af473bc1a850fddb1cd48ed9ff0326160d Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 5 Oct 2018 19:39:46 +0200 Subject: [PATCH 07/12] new: add BooleanGroup as an alternative to _Selection --- ipywidgets/widgets/__init__.py | 3 +- ipywidgets/widgets/widget_bool.py | 21 +++++- ipywidgets/widgets/widget_menu.py | 54 +++++++++++++ packages/controls/css/phosphor.css | 76 +++++++++++++++++++ packages/controls/src/index.ts | 1 + packages/controls/src/widget_bool.ts | 98 +++++++++++++++++++++++- packages/controls/src/widget_menu.ts | 109 +++++++++++++++++++++++++++ 7 files changed, 357 insertions(+), 5 deletions(-) create mode 100644 ipywidgets/widgets/widget_menu.py create mode 100644 packages/controls/src/widget_menu.ts diff --git a/ipywidgets/widgets/__init__.py b/ipywidgets/widgets/__init__.py index 7bdd82bf7f..d4cb55218a 100644 --- a/ipywidgets/widgets/__init__.py +++ b/ipywidgets/widgets/__init__.py @@ -8,7 +8,7 @@ from .trait_types import Color, Datetime, NumberFormat from .widget_core import CoreWidget -from .widget_bool import Checkbox, ToggleButton, Valid +from .widget_bool import Checkbox, ToggleButton, Valid, BooleanGroup from .widget_button import Button, ButtonStyle from .widget_box import Box, HBox, VBox, GridBox from .widget_float import FloatText, BoundedFloatText, FloatSlider, FloatProgress, FloatRangeSlider, FloatLogSlider @@ -24,4 +24,5 @@ from .widget_link import jslink, jsdlink from .widget_layout import Layout from .widget_media import Image, Icon, Video, Audio +from .widget_menu import MenuItem, Menu from .widget_style import Style diff --git a/ipywidgets/widgets/widget_bool.py b/ipywidgets/widgets/widget_bool.py index a8c0a1273b..8e4d084e3d 100644 --- a/ipywidgets/widgets/widget_bool.py +++ b/ipywidgets/widgets/widget_bool.py @@ -10,9 +10,9 @@ from .widget_core import CoreWidget from .valuewidget import ValueWidget from .widget_media import Icon -from .widget import register, widget_serialization -from .trait_types import InstanceString -from traitlets import Unicode, Bool, CaselessStrEnum, Instance +from .widget import Widget, register, widget_serialization +from .trait_types import InstanceString, TypedTuple +from traitlets import Unicode, Bool, CaselessStrEnum, Instance, Int class _Bool(DescriptionWidget, ValueWidget, CoreWidget): @@ -72,6 +72,21 @@ class ToggleButton(_Bool): help="""Use a predefined styling for the button.""").tag(sync=True) +@register +class BooleanGroup(CoreWidget): + _model_name = Unicode('BooleanGroupModel').tag(sync=True) + group = TypedTuple(trait=Instance(_Bool), help="Boolean widgets").tag(sync=True, **widget_serialization).tag(sync=True) + selected = Instance(_Bool, allow_none=True, default_value=None).tag(sync=True, **widget_serialization) + last_selected = Instance(_Bool, allow_none=True, default_value=None).tag(sync=True, **widget_serialization) + + widgets = TypedTuple(trait=Instance(Widget), help="Set of auxilary widgets to choose from").tag(sync=True, **widget_serialization).tag(sync=True) + selected_widget = Instance(Widget, allow_none=True, help="The selected widget").tag(sync=True, readonly=True, **widget_serialization) + last_selected_widget = Instance(Widget, allow_none=True, help="The selected widget").tag(sync=True, readonly=True, **widget_serialization) + + index = Int(None, help="Selected index", allow_none=True).tag(sync=True) + + + @register class Valid(_Bool): """Displays a boolean `value` in the form of a green check (True / valid) diff --git a/ipywidgets/widgets/widget_menu.py b/ipywidgets/widgets/widget_menu.py new file mode 100644 index 0000000000..df814f527e --- /dev/null +++ b/ipywidgets/widgets/widget_menu.py @@ -0,0 +1,54 @@ +from .widget_description import DescriptionWidget +from .widget import Widget, CallbackDispatcher, register, widget_serialization +from .domwidget import DOMWidget +from .widget_core import CoreWidget +from .trait_types import TypedTuple +from traitlets import (Unicode, Instance) + +@register +class MenuItem(DescriptionWidget): + _view_name = Unicode('MenuItemView').tag(sync=True) + # _model_name = Unicode('MenuItemModel').tag(sync=True) + # submenu = Instance('ipywidgets.Menu').tag(sync=True) + + def __init__(self, **kwargs): + super(MenuItem, self).__init__(**kwargs) + self._click_handlers = CallbackDispatcher() + self.on_msg(self._handle_button_msg) + + def on_click(self, callback, remove=False): + """Register a callback to execute when the button is clicked. + + The callback will be called with one argument, the clicked button + widget instance. + + Parameters + ---------- + remove: bool (optional) + Set to true to remove the callback from the list of callbacks. + """ + self._click_handlers.register_callback(callback, remove=remove) + + def click(self): + """Programmatically trigger a click event. + + This will call the callbacks registered to the clicked button + widget instance. + """ + self._click_handlers(self) + + def _handle_button_msg(self, _, content, buffers): + """Handle a msg from the front-end. + + Parameters + ---------- + content: dict + Content of the msg. + """ + if content.get('event', '') == 'click': + self.click() + +class Menu(DOMWidget, CoreWidget): + _view_name = Unicode('MenuView').tag(sync=True) + _model_name = Unicode('MenuModel').tag(sync=True) + items = TypedTuple(trait=Instance(MenuItem), help="Menu items").tag(sync=True, **widget_serialization).tag(sync=True) \ No newline at end of file diff --git a/packages/controls/css/phosphor.css b/packages/controls/css/phosphor.css index d746e663c9..f03c821588 100644 --- a/packages/controls/css/phosphor.css +++ b/packages/controls/css/phosphor.css @@ -120,4 +120,80 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. transition: none; } + + + +.p-Menu { + z-index: 100000; + padding: 3px 0px; + background: white; + color: rgba(0, 0, 0, 0.87); + border: 1px solid #C0C0C0; + font: 12px Helvetica, Arial, sans-serif; + box-shadow: 0px 1px 6px rgba(0, 0, 0, 0.2); +} + + +.p-Menu-item.p-mod-active { + background: #E5E5E5; +} + + +.p-Menu-item.p-mod-disabled { + color: rgba(0, 0, 0, 0.26); +} + + +.p-Menu-itemIcon { + width: 21px; + padding: 4px 2px; +} + + +.p-Menu-itemText { + padding: 4px 35px 4px 2px; +} + + +.p-Menu-itemShortcut { + padding: 4px 0px; +} + + +.p-Menu-itemSubmenuIcon { + width: 16px; + padding: 4px 0px; +} + + +.p-Menu-item.p-type-separator > span { + padding: 0; + height: 9px; +} + + +.p-Menu-item.p-type-separator > span::after { + content: ''; + display: block; + position: relative; + top: 4px; + border-top: 1px solid #DDDDDD; +} + + +.p-Menu-itemIcon::before, +.p-Menu-itemSubmenuIcon::before { + font-family: FontAwesome; +} + + +.p-Menu-item.p-type-check.p-mod-checked > .p-Menu-itemIcon::before { + content: '\f00c'; +} + + +.p-Menu-item.p-type-submenu > .p-Menu-itemSubmenuIcon::before { + content: '\f0da'; +} + /* End tabbar.css */ diff --git a/packages/controls/src/index.ts b/packages/controls/src/index.ts index a0303c3229..ef5b6adbdd 100644 --- a/packages/controls/src/index.ts +++ b/packages/controls/src/index.ts @@ -20,6 +20,7 @@ export * from './widget_selection'; export * from './widget_selectioncontainer'; export * from './widget_string'; export * from './widget_description'; +export * from './widget_menu'; export const version = (require('../package.json') as any).version; diff --git a/packages/controls/src/widget_bool.ts b/packages/controls/src/widget_bool.ts index 8c002ccf8b..ad064a037c 100644 --- a/packages/controls/src/widget_bool.ts +++ b/packages/controls/src/widget_bool.ts @@ -14,7 +14,7 @@ import { } from './widget_description'; import { - DOMWidgetView, unpack_models + WidgetModel, DOMWidgetView, unpack_models } from '@jupyter-widgets/base'; import * as _ from 'underscore'; @@ -31,6 +31,102 @@ class BoolModel extends CoreDescriptionModel { } } +export +class BooleanGroupModel extends WidgetModel { + defaults() { + return _.extend(super.defaults(), { + value: false, + disabled: false, + _model_name: 'BooleanGroupModel', + group: null, + selected: null, + index: null, + widgets: null, + selected_widget: null + }); + } + initialize(attributes, options) { + super.initialize(attributes, options); + this.listenToGroup() + this.on('change:index', this.sync_index) + this.on('change:widget', () => this.find_index(this.get('widgets'), this.get('selected_widget'))) + this.on('change:selected', () => this.find_index(this.get('group'), this.get('selected'))) + this._updating = false; + } + listenToGroup() { + let group = this.get('group'); + if(group) { + group.forEach((boolWidget, widget_index) => { + boolWidget.on('change:value', () => { + if(this._updating) + return; + if(boolWidget.get('value')) { + this.set('index', widget_index) + } else { + // if the user unselects a widget, we might just + // enable it again + this.sync_index() + } + }) + }) + } + } + sync_index() { + console.log('sync_index', this.cid) + if(this._updating) + return; + let index = this.get('index'); + let group = this.get('group'); + let widgets = this.get('widgets'); + if(group && group.length > index) { + if(index !== null) { + this.set('selected', group[index]); + this.set('last_selected', group[index]); + } else { + this.set('selected', null); + } + this._updating = true; + try { + group.forEach((boolWidget, widget_index) => { + boolWidget.set('value', index == widget_index) + boolWidget.save_changes() + }) + } finally { + this._updating = false; + } + } + if(widgets && widgets.length > index) { + if(index !== null) { + this.set('selected_widget', widgets[index]); + this.set('last_selected_widget', widgets[index]); + } else { + this.set('selected_widget', null); + } + } + + this.save_changes() + + } + find_index(widget_list, widget_to_find) { + if(!widget_list) + return; + let index = widget_list.indexOf(widget_to_find); + if(index == -1) + this.set('index', null); + else + this.set('index', index); + + } + static serializers = { + ...WidgetModel.serializers, + group: {deserialize: unpack_models}, + selected: {deserialize: unpack_models}, + widgets: {deserialize: unpack_models}, + selected_widget: {deserialize: unpack_models} + }; + _updating : Boolean; +} + export class CheckboxModel extends CoreDescriptionModel { defaults() { diff --git a/packages/controls/src/widget_menu.ts b/packages/controls/src/widget_menu.ts new file mode 100644 index 0000000000..60883713d9 --- /dev/null +++ b/packages/controls/src/widget_menu.ts @@ -0,0 +1,109 @@ +import { + DescriptionView, DescriptionStyleModel +} from './widget_description'; + +import { + CoreDescriptionModel, CoreDOMWidgetModel +} from './widget_core'; + +import { + DOMWidgetModel, DOMWidgetView, unpack_models +} from '@jupyter-widgets/base'; + +import { + Menu, Widget +} from '@phosphor/widgets'; + +import { + CommandRegistry +} from '@phosphor/commands'; + +export +class MenuItemModel extends CoreDescriptionModel { + defaults() { + return {...super.defaults(), + _model_name: 'MenuItemModel', + _view_name: 'MenuItemView' + }; + } + static serializers = { + ...CoreDescriptionModel.serializers, + items: {deserialize: unpack_models}, + }; +} + + +export +class MenuModel extends CoreDOMWidgetModel { + defaults() { + return {...super.defaults(), + _model_name: 'MenuModel', + _view_name: 'MenuView' + }; + } + static serializers = { + ...CoreDOMWidgetModel.serializers, + items: {deserialize: unpack_models}, + }; +} + +export +class MenuView extends DOMWidgetView { + render() { + super.render(); + + this.el.classList.add('jupyter-widgets'); + this.el.classList.add('widget-inline-hbox'); + this.el.classList.add('widget-toggle-buttons'); + + // this.buttongroup = document.createElement('div'); + // this.el.appendChild(this.buttongroup); + + this.update(); + // this.set_button_style(); + } + + update(options?) { + let items: Array = this.model.get('items'); + let commands = new CommandRegistry(); + this.menu = new Menu({commands: commands}); + let counter = 0; + items.forEach((item: MenuItemModel) => { + let cmd = 'command:'+String(counter); + this.menu.addItem({ command: cmd}); + commands.addCommand(cmd, { + label: item.get('description'), + // mnemonic: 2, + // caption: 'Close the current tab', + execute: () => { + console.log('Click', item.get('description')); + item.send({event: 'click'}, {}) + } + }); + counter++; + + }) + // this.el.appendChild(menu.node) + // menu.attach(this.el); + // menu.add + // let menuItems = items.map((item) => { + // // return new MenuItem(text: item.get('description')) + // }) + // this.displayed.then(() => { + // Widget.attach(this.menu, this.el); + // }) + let button = document.createElement('button') + button.innerHTML = 'Test' + button.onclick = (event) => { + console.log('click') + event.preventDefault(); + var rect = button.getBoundingClientRect(); + var x = rect.left; + var y = rect.bottom; + this.menu.open(x, y); + } + this.el.appendChild(button) + } + menu: Menu; + +} From 0dc1b0ec1fdad7ea308c2403b37777c5e749e7c6 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 5 Oct 2018 19:43:24 +0200 Subject: [PATCH 08/12] refactor of menu+button --- ipywidgets/widgets/__init__.py | 2 +- ipywidgets/widgets/domwidget.py | 3 + ipywidgets/widgets/widget_button.py | 7 +- ipywidgets/widgets/widget_menu.py | 36 +++++--- packages/controls/css/phosphor.css | 14 ++- packages/controls/css/widgets-base.css | 37 ++++++++ packages/controls/src/widget_button.ts | 119 ++++++++++++++++++++++++- packages/controls/src/widget_menu.ts | 110 ++++++++++++++++++----- 8 files changed, 290 insertions(+), 38 deletions(-) diff --git a/ipywidgets/widgets/__init__.py b/ipywidgets/widgets/__init__.py index d4cb55218a..b5ac53d0f0 100644 --- a/ipywidgets/widgets/__init__.py +++ b/ipywidgets/widgets/__init__.py @@ -24,5 +24,5 @@ from .widget_link import jslink, jsdlink from .widget_layout import Layout from .widget_media import Image, Icon, Video, Audio -from .widget_menu import MenuItem, Menu +from .widget_menu import Action, Menu from .widget_style import Style diff --git a/ipywidgets/widgets/domwidget.py b/ipywidgets/widgets/domwidget.py index 7aed8ea113..3c0418bddf 100644 --- a/ipywidgets/widgets/domwidget.py +++ b/ipywidgets/widgets/domwidget.py @@ -8,6 +8,7 @@ from .trait_types import InstanceDict, TypedTuple from .widget_layout import Layout from .widget_style import Style +from traitlets import Instance class DOMWidget(Widget): @@ -16,6 +17,8 @@ class DOMWidget(Widget): _model_name = Unicode('DOMWidgetModel').tag(sync=True) _dom_classes = TypedTuple(trait=Unicode(), help="CSS classes applied to widget DOM element").tag(sync=True) layout = InstanceDict(Layout).tag(sync=True, **widget_serialization) + # BAD: we cannot import ipywidgets.Menu, cicular import, and also see issue in packages/base/src/widget.ts + context_menu = Instance('ipywidgets.Menu', help="Context menu (open by default on right click)", default=None, allow_none=True).tag(sync=True, **widget_serialization).tag(sync=True) def add_class(self, className): """ diff --git a/ipywidgets/widgets/widget_button.py b/ipywidgets/widgets/widget_button.py index acef3a7c81..e844444d69 100644 --- a/ipywidgets/widgets/widget_button.py +++ b/ipywidgets/widgets/widget_button.py @@ -12,9 +12,10 @@ from .widget_core import CoreWidget from .widget_style import Style from .widget_media import Icon +from .widget_menu import Action, Menu from .trait_types import Color, InstanceDict, InstanceString -from traitlets import Unicode, Bool, CaselessStrEnum, Instance, validate, default +from traitlets import Unicode, Bool, CFloat, CaselessStrEnum, Instance, validate, default import warnings @@ -51,6 +52,10 @@ class Button(DOMWidget, CoreWidget): tooltip = Unicode(help="Tooltip caption of the button.").tag(sync=True) disabled = Bool(False, help="Enable or disable user changes.").tag(sync=True) icon = InstanceString(Icon, Icon.fontawesome, default_value=None, allow_none=True, help= "Button icon.").tag(sync=True, **widget_serialization) + menu = Instance(Menu, default_value=None, allow_none=True, help="Button menu.").tag(sync=True, **widget_serialization) + menu_delay = CFloat(None, allow_none=True, help="Delay in seconds before the menu pops up (or only on arrow push when None)").tag(sync=True) + default_action = Instance(Action, default_value=None, allow_none=True,\ + help="If set, it will set the buttons description and icon, and will trigger the menu click event when the button is pressed.").tag(sync=True, **widget_serialization) button_style = CaselessStrEnum( values=['primary', 'success', 'info', 'warning', 'danger', ''], default_value='', diff --git a/ipywidgets/widgets/widget_menu.py b/ipywidgets/widgets/widget_menu.py index df814f527e..0b0815eac3 100644 --- a/ipywidgets/widgets/widget_menu.py +++ b/ipywidgets/widgets/widget_menu.py @@ -2,17 +2,31 @@ from .widget import Widget, CallbackDispatcher, register, widget_serialization from .domwidget import DOMWidget from .widget_core import CoreWidget -from .trait_types import TypedTuple -from traitlets import (Unicode, Instance) +from .widget_bool import _Bool +from .widget_media import Icon +from .trait_types import TypedTuple, InstanceString +from traitlets import Unicode, Instance, CBool + +class Action(_Bool): + _view_name = Unicode('MenuView').tag(sync=True) + _model_name = Unicode('MenuModel').tag(sync=True) + icon = InstanceString(Icon, Icon.fontawesome, default_value=None, allow_none=True, help= "Button icon.").tag(sync=True, **widget_serialization) + checkable = CBool(None, allow_none=True, help="When True, will toggle the value property when clicked.").tag(sync=True) + disabled = CBool(False, help="Enable or disable user changes.").tag(sync=True) + command = Unicode(None, allow_none=True).tag(sync=True) + +class Menu(Action): + _view_name = Unicode('MenuView').tag(sync=True) + _model_name = Unicode('MenuModel').tag(sync=True) + items = TypedTuple(trait=Instance('ipywidgets.Action'), help="Menu items", default=None, allow_none=True).tag(sync=True, **widget_serialization).tag(sync=True) + command = Unicode(None, allow_none=True).tag(sync=True) + # icon = InstanceString(Icon, Icon.fontawesome, default_value=None, allow_none=True, help= "Button icon.").tag(sync=True, **widget_serialization) + # checkable = CBool(None, allow_none=True, help="When True, will toggle the value property when clicked.").tag(sync=True) + # disabled = CBool(False, help="Enable or disable user changes.").tag(sync=True) -@register -class MenuItem(DescriptionWidget): - _view_name = Unicode('MenuItemView').tag(sync=True) - # _model_name = Unicode('MenuItemModel').tag(sync=True) - # submenu = Instance('ipywidgets.Menu').tag(sync=True) def __init__(self, **kwargs): - super(MenuItem, self).__init__(**kwargs) + super(Menu, self).__init__(**kwargs) self._click_handlers = CallbackDispatcher() self.on_msg(self._handle_button_msg) @@ -48,7 +62,5 @@ def _handle_button_msg(self, _, content, buffers): if content.get('event', '') == 'click': self.click() -class Menu(DOMWidget, CoreWidget): - _view_name = Unicode('MenuView').tag(sync=True) - _model_name = Unicode('MenuModel').tag(sync=True) - items = TypedTuple(trait=Instance(MenuItem), help="Menu items").tag(sync=True, **widget_serialization).tag(sync=True) \ No newline at end of file +# this is needed to allow items to be None +Menu.items.default_args = None diff --git a/packages/controls/css/phosphor.css b/packages/controls/css/phosphor.css index f03c821588..4792ed055e 100644 --- a/packages/controls/css/phosphor.css +++ b/packages/controls/css/phosphor.css @@ -187,13 +187,23 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. } -.p-Menu-item.p-type-check.p-mod-checked > .p-Menu-itemIcon::before { +/*.p-Menu-item.p-type-check.p-mod-checked > .p-Menu-itemIcon::before {*/ + +.p-Menu-item.p-mod-toggled > .p-Menu-itemIcon::before { content: '\f00c'; } -.p-Menu-item.p-type-submenu > .p-Menu-itemSubmenuIcon::before { +.p-Menu-item[data-type='submenu'] > .p-Menu-itemSubmenuIcon::before { content: '\f0da'; } + +/*.p-Menu-item[data-type='submenu'] > .p-Menu-itemSubmenuIcon { + background-image: var(--jp-icon-caretright); + background-size: 18px; + background-repeat: no-repeat; + background-position: left; +}*/ + /* End tabbar.css */ diff --git a/packages/controls/css/widgets-base.css b/packages/controls/css/widgets-base.css index 6a19f06e29..5753f5a59f 100644 --- a/packages/controls/css/widgets-base.css +++ b/packages/controls/css/widgets-base.css @@ -268,6 +268,43 @@ cursor: pointer; } +.widget-button-menu-sub-button { + cursor: pointer; + width: 32px; + padding: 0; + margin: 0; + display: inline; +} + + +.widget-button-menu-sub-button { + margin-left: 4px; + margin-right: 4px; + padding-top: 0px; + padding-bottom: 0px; + display: inline; + background-color: transparent; +} + +.widget-button-menu-sub-button i { + cursor: pointer; + width: 32px; + padding: 0; + margin: 0; + display: inline; +} +.widget-button-menu-sub-button { + vertical-align: sub; + +} + +.widget-button-menu-sub-button i.fa { + margin-right: 0; + pointer-events: none; + vertical-align: sub; +} + + /* Widget Label Styling */ /* Override Bootstrap label css */ diff --git a/packages/controls/src/widget_button.ts b/packages/controls/src/widget_button.ts index d93b7aa1cc..e04cf07a75 100644 --- a/packages/controls/src/widget_button.ts +++ b/packages/controls/src/widget_button.ts @@ -13,6 +13,15 @@ import { IconModel, IconView } from './widget_icon'; +import { + MenuModel, MenuView +} from './widget_menu'; + +import { + Menu, Widget +} from '@phosphor/widgets'; + + import { JUPYTER_CONTROLS_VERSION } from './version'; @@ -52,6 +61,9 @@ class ButtonModel extends CoreDOMWidgetModel { tooltip: '', disabled: false, icon: null, + menu: null, + menu_delay: null, + default_action: null, button_style: '', _view_name: 'ButtonView', _model_name: 'ButtonModel', @@ -61,6 +73,8 @@ class ButtonModel extends CoreDOMWidgetModel { static serializers = { ...CoreDOMWidgetModel.serializers, icon: {deserialize: unpack_models}, + menu: {deserialize: unpack_models}, + default_action: {deserialize: unpack_models} }; } @@ -91,25 +105,111 @@ class ButtonView extends DOMWidgetView { let description = this.model.get('description'); let icon : IconModel = this.model.get('icon'); + let default_action = this.model.get('default_action'); + let previous_default_action = this.model.previous('default_action'); + if(previous_default_action) { + previous_default_action.off('change:value', this.update) + } + if(default_action) { + description = description || default_action.get('description'); + icon = icon || default_action.get('icon'); + this.listenTo(default_action, 'change:value', this.update) + } + let menu : MenuModel = this.model.get('menu'); + if(default_action && default_action.get('value') === true) { + this.el.classList.add('mod-active'); + } else { + this.el.classList.remove('mod-active'); + } + this.el.innerHTML = ''; if (description.length || icon) { if(this.iconView) { this.iconView.remove() this.iconView = null; } this.el.textContent = ''; + let el_icon_proxy = document.createElement('div'); + let el_caret_proxy = document.createElement('div'); + if (icon) { + this.el.appendChild(el_icon_proxy); + } + this.el.appendChild(document.createTextNode(description)); + if (menu) { + this.el.appendChild(el_caret_proxy); + } if (icon) { this.iconView = await this.create_child_view(icon) if (description.length === 0 && this.iconView) { this.iconView.el.classList.add('center'); } - this.el.appendChild(this.iconView.el); + // this.el.appendChild(this.iconView.el); + if (el_icon_proxy.parentNode == this.el) + this.el.replaceChild(this.iconView.el, el_icon_proxy); this.iconView.listenTo(icon, 'change', () => this.update()) } - this.el.appendChild(document.createTextNode(description)); + if (menu) { + this.menuView = await this.create_child_view(menu); + // this.listenToMenu(this.menu); + // menu.on('click', (menu : MenuModel) => { + // console.log('clicked', menu) + // // this.model.set('menu_last', menu); + // // if(this.model.get('menu_memory')) { + // // this.model.set('icon', menu.get('icon')) + // // this.model.set('description', menu.get('description')) + // // } + // this.touch() + // }) + let i = document.createElement('i'); + i.classList.add('fa'); + i.classList.add('fa-caret-down'); + i.classList.add('widget-button-menu-sub-button'); + // menuButton.appendChild(i) + // this.el.appendChild(menuButton); + // this.el.appendChild(i); + if (el_caret_proxy.parentNode == this.el) + this.el.replaceChild(i, el_caret_proxy); + var downTimer = null; + let openMenu = () => { + var rect = this.el.getBoundingClientRect(); + var x = rect.left; + var y = rect.bottom; + this.menuView.menu.open(x, y); + + } + this.el.onmousedown = (event) => { + console.log('mouse down') + if(this.model.get('menu_delay') !== null) { + downTimer = window.setTimeout(() => { + console.log('timeout, show menu') + openMenu() + }, this.model.get('menu_delay')*1000) + } + } + this.el.onmouseup = (event) => { + console.log('mouse down') + clearTimeout(downTimer) + } + this.el.onclick = (event) => { + console.log('click') + if(this.model.get('menu_delay') === null) { + console.log('onclick show menu') + event.preventDefault(); + openMenu(); + } + } + + } } return super.update(); } + /*listenToMenu(menu : Menu) { + menu.on() + let items: Array = menu.get('items'); + items.forEach(async (item: MenuModel, index: number) => { + } + }*/ + update_button_style() { this.update_mapped_classes(ButtonView.class_map, 'button_style'); } @@ -132,6 +232,20 @@ class ButtonView extends DOMWidgetView { */ _handle_click(event) { event.preventDefault(); + let default_action = this.model.get('default_action'); + if(default_action) { + if(default_action.get('checkable')) { + // if default_action is part of a boolean group, its state might not even change + default_action.set('value', !default_action.get('value')) + default_action.save_changes() + } + default_action.send({event: 'click'}, {}) + if(default_action && default_action.get('value') === true) { + this.el.classList.add('mod-active'); + } else { + this.el.classList.remove('mod-active'); + } + } this.send({event: 'click'}); } @@ -150,6 +264,7 @@ class ButtonView extends DOMWidgetView { el: HTMLButtonElement; iconView: IconView; + menuView: MenuView; static class_map = { primary: ['mod-primary'], diff --git a/packages/controls/src/widget_menu.ts b/packages/controls/src/widget_menu.ts index 60883713d9..a171c2409f 100644 --- a/packages/controls/src/widget_menu.ts +++ b/packages/controls/src/widget_menu.ts @@ -14,10 +14,19 @@ import { Menu, Widget } from '@phosphor/widgets'; +import { + IconModel, IconView +} from './widget_icon'; + import { CommandRegistry } from '@phosphor/commands'; +import { + MessageLoop +} from '@phosphor/messaging'; + + export class MenuItemModel extends CoreDescriptionModel { defaults() { @@ -34,16 +43,20 @@ class MenuItemModel extends CoreDescriptionModel { export -class MenuModel extends CoreDOMWidgetModel { +class MenuModel extends CoreDescriptionModel { defaults() { return {...super.defaults(), _model_name: 'MenuModel', - _view_name: 'MenuView' + _view_name: 'MenuView', + checked: null, + icon: null, + disabled: false, }; } static serializers = { ...CoreDOMWidgetModel.serializers, items: {deserialize: unpack_models}, + icon: {deserialize: unpack_models}, }; } @@ -56,33 +69,90 @@ class MenuView extends DOMWidgetView { this.el.classList.add('widget-inline-hbox'); this.el.classList.add('widget-toggle-buttons'); - // this.buttongroup = document.createElement('div'); - // this.el.appendChild(this.buttongroup); - this.update(); - // this.set_button_style(); } - - update(options?) { - let items: Array = this.model.get('items'); - let commands = new CommandRegistry(); - this.menu = new Menu({commands: commands}); - let counter = 0; - items.forEach((item: MenuItemModel) => { - let cmd = 'command:'+String(counter); - this.menu.addItem({ command: cmd}); + + buildMenu(menu: MenuModel, commands: CommandRegistry, command_prefix: String) { + let menuWidget = new Menu({commands: commands}); + let items: Array = menu.get('items'); + if(!items) + return; + items.forEach(async (item: MenuModel, index: number) => { + let cmd = command_prefix + String(index); + let subitems = item.get('items'); + if (subitems !== null) { + let subMenu = await this.buildMenu(item, commands, command_prefix + ':sub' + String(index)); + subMenu.title.label = item.get('description'); + menuWidget.addItem({type: 'submenu', submenu: subMenu}); + } else { + menuWidget.addItem({command: cmd}); + } commands.addCommand(cmd, { - label: item.get('description'), // mnemonic: 2, // caption: 'Close the current tab', + label: () => item.get('description'), + isToggled: () => item.get('value') === true, + isEnabled: () => item.get('disabled') !== true, execute: () => { console.log('Click', item.get('description')); + let checked = item.get('checked') + if(item.get('checkable')) { + item.set('value', !item.get('value')) + item.save_changes() + } item.send({event: 'click'}, {}) - } + this.model.trigger('click', item) + }, }); - counter++; + // make sure the dom elements are created + // menuWidget.onUpdateRequest(Msg.UpdateRequest) + MessageLoop.sendMessage(menuWidget, Widget.Msg.UpdateRequest); + let iconDiv = menuWidget.contentNode.querySelector(`.p-Menu-item[data-command="${cmd}"] .p-Menu-itemIcon`) + let icon = item.get('icon'); + if(icon) { + let iconView = await this.create_child_view(icon) + // if (description.length === 0 && this.iconView) { + // this.iconView.el.classList.add('center'); + // } + iconDiv.appendChild(iconView.el); + } + // this.iconView.listenTo(icon, 'change', () => this.update()) + // counter++; + }); + return menuWidget; + } + + buildMainMenu() { + let commands = new CommandRegistry(); + let counter = 0; + let menuWidget = this.buildMenu(this.model, commands, 'command:'); + return menuWidget; + } + + update(options?) { + let commands = new CommandRegistry(); + let counter = 0; + let menuWidget = this.buildMainMenu(); + this.menu = menuWidget; + // let items: Array = this.model.get('items'); + + // this.menu = new Menu({commands: commands}); + // let counter = 0; + // items.forEach((item: MenuItemModel) => { + // let cmd = 'command:'+String(counter); + // this.menu.addItem({ command: cmd}); + // commands.addCommand(cmd, { + // label: item.get('description'), + // // mnemonic: 2, + // // caption: 'Close the current tab', + // execute: () => { + // console.log('Click', item.get('description')); + // item.send({event: 'click'}, {}) + // } + // }); + // counter++; - }) + // }) // this.el.appendChild(menu.node) // menu.attach(this.el); // menu.add @@ -100,7 +170,7 @@ class MenuView extends DOMWidgetView { var rect = button.getBoundingClientRect(); var x = rect.left; var y = rect.bottom; - this.menu.open(x, y); + menuWidget.open(x, y); } this.el.appendChild(button) } From 5abd839195b0495468532047808255205b556b7c Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 5 Oct 2018 19:44:01 +0200 Subject: [PATCH 09/12] context menu support on js side --- packages/base/src/widget.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/base/src/widget.ts b/packages/base/src/widget.ts index 25947e40a3..ed82a93874 100644 --- a/packages/base/src/widget.ts +++ b/packages/base/src/widget.ts @@ -567,6 +567,7 @@ class DOMWidgetModel extends WidgetModel { ...WidgetModel.serializers, layout: {deserialize: unpack_models}, style: {deserialize: unpack_models}, + context_menu: {deserialize: unpack_models}, }; defaults() { @@ -767,6 +768,17 @@ class DOMWidgetView extends WidgetView { this.listenTo(this.model, 'comm_live_update', () => { this._comm_live_update(); }); + this.el.addEventListener('contextmenu', async (event) => { + let menu = this.model.get('context_menu'); + if(menu) { + // BAD: here base kind of depends on controls, we do not want that i guess + let menuView = await this.create_child_view(menu); + let x = event.clientX; + let y = event.clientY; + menuView.menu.open(x, y); + event.preventDefault(); + } + }) } setLayout(layout, oldLayout?) { From 08b85ccb95c99f3754a50fee4b7760b9ee006fee Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 5 Oct 2018 19:45:19 +0200 Subject: [PATCH 10/12] add demo notebook --- menu.ipynb | 660 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 menu.ipynb diff --git a/menu.ipynb b/menu.ipynb new file mode 100644 index 0000000000..8776541f09 --- /dev/null +++ b/menu.ipynb @@ -0,0 +1,660 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as widgets" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "ca2dc6129d3043ea9196b9a31d34901f", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Checkbox(value=False, context_menu=None, description='c1')" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "c1 = widgets.Checkbox(description='c1')\n", + "c1" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "428d363e62f54b42a1e78829a5cbf134", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "ToggleButton(value=False, context_menu=None, description='t1')" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "t1 = widgets.ToggleButton(description='t1')\n", + "t1" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "group = widgets.BooleanGroup(group=[c1, t1])" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(None, None)" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "group.index, group.selected" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "group.index = 0" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "group.index = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "# group.widgets = [c1, t1]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "container = widgets.Container(child=image)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "group.widgets = [interact_brush, interact_rect, interact_lasso]\n", + "group.widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "widgets.jslink((figure, 'interaction'), (group, 'widget'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "group.widgets = [image1, image2, image3]\n", + "widgets.jslink((container, 'child'), (group, 'widget'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "widgets.jslink((group, 'widget'), (vbox, 'children[0]'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import traitlets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class MyList(traitlets.List):\n", + " def validate(self, *args):\n", + " value = super(MyList, self).validate(*args)\n", + " print('validate', *args, value)\n", + " return value\n", + " def instance_init(self, *args):\n", + " value = super(MyList, self).instance_init(*args)\n", + " print('instance init', *args, value)\n", + " return value\n", + " def make_dynamic_default(self):\n", + " value = super(MyList, self).make_dynamic_default()\n", + " print('make_dynamic_default', value)\n", + " return value\n", + "class A(traitlets.HasTraits):\n", + " a = MyList(traitlets.Unicode(), allow_none=True)\n", + " def set_trait(self, name, value):\n", + " import pdb\n", + " pdb.set_trace()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "A.a.default_args = None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "a = A()\n", + "a.a" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "widgets.Menu.items.default_value = 'sa'" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "f2a25f629ac14605bc0a9f7b3d55cdc3", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Output(context_menu=None)" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "subitem1 = widgets.Menu(description='submenu 1')\n", + "subitem2 = widgets.Menu(description='submenu 2')\n", + "submenu = widgets.Menu(items=[subitem1, subitem2], description='submenu')\n", + "\n", + "out = widgets.Output()\n", + "@subitem1.on_click\n", + "@out.capture()\n", + "def do(bla):\n", + " print('click1')\n", + "@subitem2.on_click\n", + "@out.capture()\n", + "def do(bla):\n", + " print('click2')\n", + "out" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "item1 = widgets.Menu(description='menu 1')\n", + "item2 = widgets.Menu(description='menu 2')\n", + "menu = widgets.Menu(items=[item1, item2, submenu])" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "item1.items = None\n", + "item2.items" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "38ec140c1ec74c999a96a154dc18655f", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Output(context_menu=None)" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "out = widgets.Output()\n", + "@item1.on_click\n", + "@out.capture()\n", + "def do(bla):\n", + " print('click1')\n", + "@item2.on_click\n", + "@out.capture()\n", + "def do(bla):\n", + " print('click2')\n", + "out" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "e36bde2eeb644689ba7105edef6927d8", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Menu(value=False, context_menu=None, items=(Menu(value=False, context_menu=None, description='menu 1'), Menu(v…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "menu" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "item1.icon = 'refresh'" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "item2.icon = 'home'" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "item2.checkable = True" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "item2.value = not item2.value" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "item1.value = not item1.value" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "e36bde2eeb644689ba7105edef6927d8", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Menu(value=False, context_menu=None, items=(Menu(value=True, context_menu=None, description='menu 1', icon=Ico…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "menu" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "b = widgets.Button(description='submenu', menu=menu, icon='home')" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "item2.checkable = item1.checkable = True" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [], + "source": [ + "item1.value = False" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "False" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "item1.value" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "805cfe52b9ea48f2baed5b9dcbc7ec05", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Button(context_menu=None, description='submenu', icon=Icon(value=b'home', context_menu='None', format='fontawe…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "b" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [], + "source": [ + "b.default_action = item2" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [], + "source": [ + "group = widgets.BooleanGroup(group=[item1, item2])\n", + "# group.widgets = group.group" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(None,\n", + " BooleanGroup(group=(Menu(value=False, checkable=True, context_menu=None, description='menu 1', icon=Icon(value=b'refresh', context_menu='None', format='fontawesome')), Menu(value=True, checkable=True, context_menu=None, description='menu 2', icon=Icon(value=b'home', context_menu='None', format='fontawesome'))), selected_widget=None))" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "item1.checkable = True\n", + "group.index, group" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [], + "source": [ + "widgets.jslink((group, 'selected'), (b, 'default_action'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "b_delay = widgets.Button(description='submenu', menu=menu, icon='home', menu_delay=0.8)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "b_delay" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "b_delay0 = widgets.Button(description='submenu', menu=menu, icon='home', menu_delay=0.0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "b_delay0" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "item1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "b_delay = widgets.Button(description='submenu', menu=menu, icon='home', menu_delay=0.8, menu_memory=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "b_delay" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "b_delay.menu_last" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "widgets.jslink((b_delay, 'menu_last', 'icon'), (b_delay, 'icon'))\n", + "widgets.jslink((b_delay, 'menu_last', 'description'), (b_delay, 'description'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "widgets.jslink((b_delay, 'menu_last', 'description'), (b_delay, 'description'))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From 7a8e4f699f06d0c7661f8f32ab86887c45408b45 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Mon, 8 Oct 2018 16:51:41 +0200 Subject: [PATCH 11/12] added Application widget showing 'menubar' and toolbar --- ipywidgets/widgets/__init__.py | 2 +- ipywidgets/widgets/widget_menu.py | 9 +- packages/controls/css/phosphor.css | 51 +++++ packages/controls/css/widgets-base.css | 16 ++ packages/controls/src/index.ts | 1 + packages/controls/src/widget_application.ts | 240 ++++++++++++++++++++ packages/controls/src/widget_button.ts | 6 + packages/controls/src/widget_menu.ts | 8 + 8 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 packages/controls/src/widget_application.ts diff --git a/ipywidgets/widgets/__init__.py b/ipywidgets/widgets/__init__.py index b5ac53d0f0..7def7bcbe6 100644 --- a/ipywidgets/widgets/__init__.py +++ b/ipywidgets/widgets/__init__.py @@ -24,5 +24,5 @@ from .widget_link import jslink, jsdlink from .widget_layout import Layout from .widget_media import Image, Icon, Video, Audio -from .widget_menu import Action, Menu +from .widget_menu import Application, Action, Menu from .widget_style import Style diff --git a/ipywidgets/widgets/widget_menu.py b/ipywidgets/widgets/widget_menu.py index 0b0815eac3..0096dcc554 100644 --- a/ipywidgets/widgets/widget_menu.py +++ b/ipywidgets/widgets/widget_menu.py @@ -19,7 +19,6 @@ class Menu(Action): _view_name = Unicode('MenuView').tag(sync=True) _model_name = Unicode('MenuModel').tag(sync=True) items = TypedTuple(trait=Instance('ipywidgets.Action'), help="Menu items", default=None, allow_none=True).tag(sync=True, **widget_serialization).tag(sync=True) - command = Unicode(None, allow_none=True).tag(sync=True) # icon = InstanceString(Icon, Icon.fontawesome, default_value=None, allow_none=True, help= "Button icon.").tag(sync=True, **widget_serialization) # checkable = CBool(None, allow_none=True, help="When True, will toggle the value property when clicked.").tag(sync=True) # disabled = CBool(False, help="Enable or disable user changes.").tag(sync=True) @@ -64,3 +63,11 @@ def _handle_button_msg(self, _, content, buffers): # this is needed to allow items to be None Menu.items.default_args = None + + +class Application(DescriptionWidget): + _view_name = Unicode('ApplicationView').tag(sync=True) + _model_name = Unicode('ApplicationModel').tag(sync=True) + menubar = Instance('ipywidgets.Menu', allow_none=True).tag(sync=True, **widget_serialization) + toolbar = TypedTuple(trait=Instance('ipywidgets.DOMWidget'), help="List of widgets to be shown between the menu bar and central widget", default=None, allow_none=True).tag(sync=True, **widget_serialization).tag(sync=True) + central_widget = Instance('ipywidgets.DOMWidget', help='The main widget shown in the center').tag(sync=True, **widget_serialization) diff --git a/packages/controls/css/phosphor.css b/packages/controls/css/phosphor.css index 4792ed055e..4f01b7207a 100644 --- a/packages/controls/css/phosphor.css +++ b/packages/controls/css/phosphor.css @@ -122,6 +122,57 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +/* Menubar */ + + + +.p-MenuBar { + padding-left: 5px; + background: #FEFEFE; + color: rgba(0, 0, 0, 0.87); + border-bottom: 1px solid #DDDDDD; + font: 13px Helvetica, Arial, sans-serif; +} + + +.p-MenuBar-menu { + transform: translateY(-1px); +} + + +.p-MenuBar-item { + padding: 4px 8px; + border-left: 1px solid transparent; + border-right: 1px solid transparent; +} + + +.p-MenuBar-item.p-mod-active { + background: #E5E5E5; +} + + +.p-MenuBar-item.p-mod-disabled { + color: rgba(0, 0, 0, 0.26); +} + + +.p-MenuBar-item.p-mod-separator-type { + margin: 2px; + padding: 0; + border: none; + border-left: 1px solid #DDDDDD; +} + + +.p-MenuBar.p-mod-active .p-MenuBar-item.p-mod-active { + z-index: 1000000; + background: white; + border-left: 1px solid #C0C0C0; + border-right: 1px solid #C0C0C0; + box-shadow: 0px 0px 6px rgba(0, 0, 0, 0.2); +} + .p-Menu { z-index: 100000; diff --git a/packages/controls/css/widgets-base.css b/packages/controls/css/widgets-base.css index 5753f5a59f..fdfef4e6a2 100644 --- a/packages/controls/css/widgets-base.css +++ b/packages/controls/css/widgets-base.css @@ -112,6 +112,8 @@ /* General Button Styling */ + + .jupyter-button { padding-left: 10px; padding-right: 10px; @@ -1115,3 +1117,17 @@ /* Make it possible to have absolutely-positioned elements in the html */ position: relative; } + +/* Application widget */ + +.widget-application { + border: 1px solid black; +} + +/* Specific css for when widgets are used as a toolbar */ + +.widget-toolbar .jupyter-button { + background-color: transparent; + padding-left: 0; + padding-right: 0; +} diff --git a/packages/controls/src/index.ts b/packages/controls/src/index.ts index ef5b6adbdd..17b47aea7d 100644 --- a/packages/controls/src/index.ts +++ b/packages/controls/src/index.ts @@ -21,6 +21,7 @@ export * from './widget_selectioncontainer'; export * from './widget_string'; export * from './widget_description'; export * from './widget_menu'; +export * from './widget_application'; export const version = (require('../package.json') as any).version; diff --git a/packages/controls/src/widget_application.ts b/packages/controls/src/widget_application.ts new file mode 100644 index 0000000000..1cf2f23a65 --- /dev/null +++ b/packages/controls/src/widget_application.ts @@ -0,0 +1,240 @@ +import { + DescriptionView, DescriptionStyleModel, +} from './widget_description'; + +import { + CoreDescriptionModel, CoreDOMWidgetModel +} from './widget_core'; + +import { + DOMWidgetModel, DOMWidgetView, ViewList, unpack_models +} from '@jupyter-widgets/base'; + +import { + Menu, Widget, MenuBar +} from '@phosphor/widgets'; + +import { + IconModel, IconView +} from './widget_icon'; + +import { + MenuModel, MenuView +} from './widget_menu'; + +import * as screenfull from 'screenfull'; + +/*import { + CommandRegistry +} from '@phosphor/commands'; +*/ + +import { + MessageLoop +} from '@phosphor/messaging'; + + +export +class ApplicationModel extends CoreDescriptionModel { + defaults() { + return {...super.defaults(), + _model_name: 'ApplicationModel', + _view_name: 'ApplicationView', + menubar: null, + toolbar: null, + central_widget: null, + }; + } + static serializers = { + ...CoreDescriptionModel.serializers, + menubar: {deserialize: unpack_models}, + toolbar: {deserialize: unpack_models}, + central_widget: {deserialize: unpack_models}, + }; +} + +export +class ApplicationView extends DescriptionView { + async render() { + super.render(); + + this.el.classList.add('jupyter-widgets'); + // this.el.classList.add('widget-inline-vbox'); + // this.el.classList.add('widget-toggle-buttons'); + this.el.classList.add('widget-application'); + + this.el_toolbar = document.createElement('div') + this.el_toolbar.classList.add('widget-inline-hbox'); + this.el_toolbar.classList.add('jupyter-widgets') + this.el_toolbar.classList.add('widget-container') + this.el_toolbar.classList.add('widget-toolbar') + + this.el_menubar = document.createElement('div') + // this.el_menubar.classList.add('widget-inline-vbox'); + // this.el_menubar.classList.add('jupyter-widgets') + // this.el_menubar.classList.add('widget-container') + // this.el_menubar.classList.add('widget-toolbar') + + this.el.appendChild(this.el_menubar); + this.el.appendChild(this.el_toolbar); + + // this.el.appendChild(this.el_content) + + + var toolbarViewList = new ViewList(async (model, index) => { + var viewPromise = > this.create_child_view(model); + var view : DOMWidgetView = await viewPromise; + view.el.addEventListener('jupyter-widgets-action', this.onChildCommandEvent.bind(this)); + if(view.el.nodeName === 'BUTTON') { + view.el.onclick = () => { + console.log('click', index) + var action = model.get('default_action'); + if(!action) + return; + var command = action.get('command'); + if(!command) + return; + if(this.central_widget_view[command]) { + this.central_widget_view[command]() + } else if(this[command]) { + this[command](); + } else if(!command) { + console.log('no event handler found for command, sending event ', command); + this.central_widget_view.trigger(command) + return; + } + } + } + this.el_toolbar.appendChild(view.el) + this.displayed.then(() => { + view.trigger('displayed', this); + }); + return viewPromise; + + }, () => { + + }, 1); + toolbarViewList.update(this.model.get('toolbar'));//.map((tuple) => tuple[0])); + + let central_widget = this.model.get('central_widget'); + this.central_widget_view = await this.create_child_view(central_widget); + this.el.appendChild(this.central_widget_view.el) + this.displayed.then(() => { + this.central_widget_view.trigger('displayed', this); + });/**/ + + this.update(); + } + + onChildCommandEvent(e) { + console.log('custom event command', e) + var action = e.detail.action; + var command = action.get('command'); + if(command !== null) { + var default_command = `default_action_${command}`; + // simple way to handle commands + if(this.central_widget_view[command]) { + this.central_widget_view[command](); + } else if(this[default_command]) { + this[default_command](); + } + } + + // in case the widget that wants to receive it can be down the DOM hierarchy, it can listen to a + // DOM event. + // we 'clone' the event, and send it down the central_widget_view, we don't want it to bubble up + // otherwise we receive it again + let event = new CustomEvent('jupyter-widgets-action', {bubbles: false, detail: e.detail}); + this.central_widget_view.el.dispatchEvent(event); + } + + default_action_fullscreen() { + var el = this.central_widget_view.el; + var old_width = el.style.width + var old_height = el.style.height + var restore = () => { + if(!screenfull.isFullscreen) { + el.style.width = old_width; + el.style.height = old_height + screenfull.off('change', restore) + } else { + el.style.width = '100vw' + el.style.height = '100vh' + } + MessageLoop.postMessage(this.central_widget_view.pWidget, Widget.ResizeMessage.UnknownSize); + // phosphor_messaging.MessageLoop.postMessage(this.childView.pWidget, phosphor_widget.Widget.ResizeMessage.UnknownSize); + } + screenfull.onchange(restore) + screenfull.request(el); + + } + + default_action_restore() { + // var el = this.el; + // var previous_body_elements = body.childNodes; + // while (document.body.firstChild) + // document.body.removeChild(document.body.firstChild); + // document.body.appendChild(el); + if(this.last_parent !== null) { + document.body.removeChild(document.body.firstChild); + this.last_body_elements.forEach((node) => { + document.body.appendChild(node); + }) + // TODO: are we really the last child? + this.last_parent.appendChild(this.el) + this.last_parent = null; + MessageLoop.postMessage(this.pWidget, Widget.ResizeMessage.UnknownSize); + } + } + + + default_action_maximize() { + var el = this.el; + if (this.last_parent === null) { + this.last_parent = this.el.parentNode; + this.last_body_elements = Array.prototype.slice.call(document.body.childNodes); + while (document.body.firstChild) + document.body.removeChild(document.body.firstChild); + document.body.appendChild(el); + MessageLoop.postMessage(this.pWidget, Widget.ResizeMessage.UnknownSize); + } + + + } + + + async update(options?) { + var menuRoot = this.model.get('menubar'); + if(menuRoot) { + var menuRootView = await this.create_child_view(menuRoot); + var menuBar = new MenuBar(); + // let commands = new CommandRegistry(); + let counter = 0; + var menuItems = menuRoot + // if(menuRootView.menu. + console.log(menuRootView.menu) + menuRootView.menu.items.forEach((menuItem) => { + if(menuItem.type == "submenu") { + menuBar.addMenu(menuItem.submenu); + menuItem.submenu.node.addEventListener('jupyter-widgets-action', this.onChildCommandEvent.bind(this)); + // menuItem.submenu.node.addEventListener('command', this.onChildCommandEvent); + (window as any).last_menu_item = menuItem; + console.log(menuItem.submenu.node) + } + }) + // this.el_menubar.appendChild(menuBar.node) + + this.displayed.then(() => { + Widget.attach(menuBar, this.el_menubar); + // menuBar.node.addEventListener('command', this.onChildCommandEvent) + }) + } + } + menu: Menu; + last_body_elements: Node[] = null; + last_parent: Node = null; + el_toolbar: HTMLDivElement; + el_menubar: HTMLDivElement; + central_widget_view: DOMWidgetView; + +} diff --git a/packages/controls/src/widget_button.ts b/packages/controls/src/widget_button.ts index e04cf07a75..3af808fc5f 100644 --- a/packages/controls/src/widget_button.ts +++ b/packages/controls/src/widget_button.ts @@ -149,6 +149,8 @@ class ButtonView extends DOMWidgetView { } if (menu) { this.menuView = await this.create_child_view(menu); + // forward events from the menu, since it is not DOM attached + this.menuView.menu.node.addEventListener('jupyter-widgets-action', (e: CustomEvent) => this.el.dispatchEvent(new CustomEvent('jupyter-widgets-action', {bubbles: true, detail: e.detail}))); // this.listenToMenu(this.menu); // menu.on('click', (menu : MenuModel) => { // console.log('clicked', menu) @@ -202,6 +204,10 @@ class ButtonView extends DOMWidgetView { } return super.update(); } + onChildCommandEvent(e) { + console.log('forwarding event'); + this.el.dispatchEvent(e); + } /*listenToMenu(menu : Menu) { menu.on() diff --git a/packages/controls/src/widget_menu.ts b/packages/controls/src/widget_menu.ts index a171c2409f..a52a1a82ed 100644 --- a/packages/controls/src/widget_menu.ts +++ b/packages/controls/src/widget_menu.ts @@ -102,12 +102,20 @@ class MenuView extends DOMWidgetView { } item.send({event: 'click'}, {}) this.model.trigger('click', item) + // TODO: are we gonna use view in the detail, since it is the root view., e.g. this.model != item always + var event = new CustomEvent('jupyter-widgets-action', {bubbles: true, detail: {view: this, action: item}}); + // this.el.dispatchEvent(event); + menuWidget.node.dispatchEvent(event); + }, }); // make sure the dom elements are created // menuWidget.onUpdateRequest(Msg.UpdateRequest) MessageLoop.sendMessage(menuWidget, Widget.Msg.UpdateRequest); let iconDiv = menuWidget.contentNode.querySelector(`.p-Menu-item[data-command="${cmd}"] .p-Menu-itemIcon`) + if(iconDiv === null && subitems !== null) + iconDiv = menuWidget.contentNode.querySelector(`.p-Menu-item:last-child .p-Menu-itemIcon`) + let icon = item.get('icon'); if(icon) { let iconView = await this.create_child_view(icon) From bcf2670177769250448ea802c76e281d8e8a410c Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Mon, 8 Oct 2018 16:57:35 +0200 Subject: [PATCH 12/12] application example notebook --- application.ipynb | 318 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 application.ipynb diff --git a/application.ipynb b/application.ipynb new file mode 100644 index 0000000000..abc5916303 --- /dev/null +++ b/application.ipynb @@ -0,0 +1,318 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Simple example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as widgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "menu_maximize = widgets.Menu(description='Fullscreen', icon='arrows-alt', command='fullscreen')\n", + "menu_fullscreen = widgets.Menu(description='Maximize', icon='window-maximize', command='maximize')\n", + "menu_restore = widgets.Menu(description='Restore', icon='window-restore', command='restore')\n", + "menu_window = widgets.Menu(items=[menu_maximize, menu_fullscreen, menu_restore], description='Window', icon='save')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "menubar = widgets.Menu(items=[menu_window])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "text = widgets.Text(value='hi there')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "button_hi = widgets.Button(description='Hi')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "app = widgets.Application(central_widget=text, menubar=menubar, toolbar=[button_hi])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "app" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# bqplot example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import bqplot.pyplot as plt\n", + "import ipywidgets as widgets\n", + "import numpy as np\n", + "import bqplot\n", + "fig = plt.figure()\n", + "x = np.linspace(0, 2, 10)\n", + "y = x**2\n", + "s = plt.scatter(x, y, display_legend=True)\n", + "s.selected_style = {'fill': 'orange', 'stroke': 'blue'}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "icon_brush = icon_brush_x = icon_brush_y = icon_brush_lasso = None#'pencil-square-o'\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "icon_brush = icon_brush_x = icon_brush_y = icon_brush_lasso = None#'pencil-square-o'\n", + "\n", + "\n", + "# toggle_button_pan = widgets.ToggleButton(description='Pan/zoom', icon=icon_pan)\n", + "\n", + "\n", + "# action_fullscreen = widgets.Action(command='fullscreen', icon='arrows-alt', description='')\n", + "# button_fullscreen = widgets.Button(description='', default_action=action_fullscreen)\n", + "\n", + "# action_png = widgets.Menu(command='save_png', icon='save', description='Save to PNG')\n", + "# action_svg = widgets.Menu(command='save_svg', icon='save', description='Save to SVG')\n", + "# menu_save = widgets.Menu(items=[action_png, action_svg])\n", + "# button_save = widgets.Button(menu=menu_save, description='Save', icon='save', menu_delay=0)\n", + "\n", + "\n", + "menu_select_rectangle = widgets.Menu(description='Rectangle', icon=icon_brush)\n", + "menu_select_x = widgets.Menu(description='X region', icon=icon_brush_x)\n", + "menu_select_y = widgets.Menu(description='Y region', icon=icon_brush_y)\n", + "menu_select_lasso = widgets.Menu(description='Lasso', icon=icon_brush_lasso)\n", + "menu_items_select = [menu_select_rectangle, menu_select_x, menu_select_y, menu_select_lasso]\n", + "for menu_item in menu_items_select:\n", + " menu_item.checkable = True\n", + "menu_select_rectangle.value = True\n", + "menu_select = widgets.Menu(items=menu_items_select)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "icon_pan = 'arrows'\n", + "toggle_button_panzoom = widgets.ToggleButton(description='Pan/zoom', icon=icon_pan)\n", + "menu_interact_panzoom = widgets.Menu(description='Pan/zoom', icon=icon_pan)\n", + "menu_interact_panzoom.checkable = True\n", + "menu_items_interact = [menu_interact_panzoom] + menu_items_select\n", + "menu_interact = widgets.Menu(description='Interaction', items=menu_items_interact)\n", + "widgets.jslink((toggle_button_panzoom, 'value'), (menu_interact_panzoom, 'value'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "scale_x = s.scales['x']\n", + "scale_y = s.scales['y']\n", + "marks = fig.marks\n", + "\n", + "button_select = widgets.Button(menu=menu_select, default_action=menu_select_rectangle, menu_delay=0.5, icon='pencil-square-o')\n", + "\n", + "interact_panzoom = bqplot.PanZoom(scales={'x': [scale_x], 'y': [scale_y]})\n", + "interact_brush = bqplot.interacts.BrushSelector(x_scale=scale_x, y_scale=scale_y, marks=marks)\n", + "interact_brush_x = bqplot.interacts.BrushIntervalSelector(scale=scale_x, color=\"green\", marks=marks)\n", + "interact_brush_y = bqplot.interacts.BrushIntervalSelector(scale=scale_y, color=\"green\", orientation='vertical', marks=marks)\n", + "interact_lasso = bqplot.interacts.LassoSelector(x_scale=scale_x, y_scale=scale_y, marks=marks)\n", + "interactions = [interact_panzoom, interact_brush, interact_brush_x, interact_brush_y, interact_lasso]\n", + "group_interact = widgets.BooleanGroup(group=menu_items_interact, widgets=interactions)\n", + "group_select = widgets.BooleanGroup(group=menu_items_select)#, widgets=interactions[1:])\n", + "widgets.jslink((group_select, 'last_selected'), (button_select, 'default_action'))\n", + "group_select.index = 0\n", + "widgets.jslink((group_interact, 'selected_widget'), (fig, 'interaction'))\n", + "widgets.jslink((group_interact, 'selected'), (group_select, 'selected'))\n", + "group_interact.index = 0\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "menu_maximize = widgets.Menu(description='Fullscreen', icon='arrows-alt', command='fullscreen')\n", + "menu_fullscreen = widgets.Menu(description='Maximize', icon='window-maximize', command='maximize')\n", + "menu_restore = widgets.Menu(description='Restore', icon='window-restore', command='restore')\n", + "# subitem2 = widgets.Menu(description='submenu 2', icon='cloud')\n", + "# TODO: icons don't work yet for submenu\n", + "menu_window = widgets.Menu(items=[menu_maximize, menu_fullscreen, menu_restore], description='Window', icon='save')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "menu_save_svg = widgets.Menu(description='Save as svg', icon='save', command='save_svg')\n", + "menu_save_png = widgets.Menu(description='Save as png', icon='save', command='save_png')\n", + "# item2 = widgets.Menu(description='menu 2', icon='pencil')\n", + "menu_file = widgets.Menu(description='File', items=[menu_save_svg, menu_save_png])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# this menu will expand on click\n", + "button_window = widgets.Button(description='Window', menu=menu_window, icon='window-maximize')\n", + "button_window" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# this menu will expand directly\n", + "button_file = widgets.Button(description='File', menu=menu_file, icon='save', menu_delay=0)\n", + "button_file" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "text = widgets.Text(description=\"Name\")\n", + "text" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# file_menu = widgets.Menu(description='File', items=[item1, item2], icon='save')\n", + "# menu_view = widgets.Menu(description='View', items=[item1, item2])\n", + "menubar = widgets.Menu(items=[menu_file, menu_interact, menu_window])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "app = widgets.Application(central_widget=fig, menubar=menubar, toolbar=[toggle_button_panzoom, button_select, button_file])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "scrolled": false + }, + "outputs": [], + "source": [ + "app" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "widgets.jslink((group_interact, 'selected_widget'), (fig, 'interaction'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +}