diff --git a/css/widget.css b/css/widget.css index 7315f08..2e721f5 100644 --- a/css/widget.css +++ b/css/widget.css @@ -30,6 +30,7 @@ position: unset !important; overflow: unset !important; height: 100%; + background: var(--jp-layout-color0); } .flexlayout__tabset-selected { @@ -83,478 +84,41 @@ div.inner__flexlayout__tabset > div.flexlayout__tabset_tabbar_outer_top { border-style: solid; border-color: #e2e2e2; } -/* .custom-widget .panel { - height:100%; - display:flex; - justify-content:center; - align-items:center; - background-color:white; - border:1px solid #555; - box-sizing: border-box; -} */ -/* -.flexlayout__layout { - height: 100%; -} -.flexlayout__splitter { - background-color: #f7f7f7; -} -@media (hover: hover) { - .flexlayout__splitter:hover { - background-color: #e2e2e2; - } -} -.flexlayout__splitter_border { - z-index: 10; -} -.flexlayout__splitter_drag { - z-index: 1000; - background-color: #e2e2e2; -} -.flexlayout__splitter_extra { - background-color: transparent; -} -.flexlayout__outline_rect { - position: absolute; - pointer-events: none; - box-sizing: border-box; - border: 2px solid red; - box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.2); - border-radius: 5px; - z-index: 1000; -} -.flexlayout__outline_rect_edge { - pointer-events: none; - border: 2px solid green; - box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.2); - border-radius: 5px; - z-index: 1000; - box-sizing: border-box; -} -.flexlayout__edge_rect { - position: absolute; - z-index: 1000; - box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2); - background-color: gray; - pointer-events: none; -} -.flexlayout__drag_rect { - position: absolute; - cursor: move; - color: black; - background-color: #f7f7f7; - border: 2px solid #e2e2e2; - box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.3); - border-radius: 5px; - z-index: 1000; - box-sizing: border-box; - opacity: 0.9; - text-align: center; - display: flex; - justify-content: center; - flex-direction: column; - overflow: hidden; - padding: 10px; - word-wrap: break-word; - font-size: medium; - font-family: Roboto, Arial, sans-serif; -} -.flexlayout__tabset { - overflow: hidden; - background-color: #f7f7f7; - box-sizing: border-box; - font-size: medium; - font-family: Roboto, Arial, sans-serif; - background-color: white; -} -.flexlayout__tabset_header { - position: absolute; - display: flex; - align-items: center; - left: 0; - right: 0; - padding: 3px 3px 3px 5px; - box-sizing: border-box; - border-bottom: 1px solid #e9e9e9; - color: black; - background-color: white; -} -.flexlayout__tabset_header_content { - flex-grow: 1; -} -.flexlayout__tabset_tabbar_outer { - box-sizing: border-box; - background-color: #f7f7f7; - position: absolute; - left: 0; - right: 0; - overflow: hidden; - display: flex; - background-color: white; -} -.flexlayout__tabset_tabbar_outer_top { - border-bottom: 1px solid #e9e9e9; -} -.flexlayout__tabset_tabbar_outer_bottom { - border-top: 1px solid #e9e9e9; -} -.flexlayout__tabset_tabbar_inner { - position: relative; - box-sizing: border-box; - display: flex; - flex-grow: 1; - overflow: hidden; -} -.flexlayout__tabset_tabbar_inner_tab_container { - display: flex; - box-sizing: border-box; - position: absolute; - top: 0; - bottom: 0; - width: 10000px; -} -.flexlayout__tabset_tabbar_inner_tab_container_top { - border-top: 2px solid transparent; -} -.flexlayout__tabset_tabbar_inner_tab_container_bottom { - border-bottom: 2px solid transparent; -} -.flexlayout__tabset-selected { - background-color: #f7f7f7; -} -.flexlayout__tabset-maximized { - background-color: #d4d4d4; -} -.flexlayout__tab { - overflow: auto; - position: absolute; - box-sizing: border-box; - color: black; - background-color: white; -} -.flexlayout__tab_button { - display: inline-flex; - align-items: center; - box-sizing: border-box; - padding: 3px 8px; - margin: 0px 2px; - cursor: pointer; -} -.flexlayout__tab_button--selected { - background-color: #e9e9e9; - color: black; -} -@media (hover: hover) { - .flexlayout__tab_button:hover { - background-color: #e9e9e9; - color: black; - } -} -.flexlayout__tab_button--unselected { - color: gray; -} -.flexlayout__tab_button_leading { - display: inline-block; -} -.flexlayout__tab_button_content { - display: inline-block; -} -.flexlayout__tab_button_textbox { - border: none; - color: green; - background-color: #e9e9e9; -} -.flexlayout__tab_button_textbox:focus { - outline: none; -} -.flexlayout__tab_button_trailing { - display: inline-block; - margin-left: 8px; - min-width: 8px; - min-height: 8px; -} -@media (pointer: coarse) { - .flexlayout__tab_button_trailing { - min-width: 20px; - min-height: 20px; - } -} -@media (hover: hover) { - .flexlayout__tab_button:hover .flexlayout__tab_button_trailing { - background: transparent url("../images/close.png") no-repeat center; - } -} -.flexlayout__tab_button--selected .flexlayout__tab_button_trailing { - background: transparent url("../images/close.png") no-repeat center; -} -.flexlayout__tab_button_overflow { - margin-left: 10px; - padding-left: 12px; - border: none; - color: gray; - font-size: inherit; - background: transparent url("../images/more2.png") no-repeat left; -} -.flexlayout__tab_toolbar { - display: flex; - align-items: center; -} -.flexlayout__tab_toolbar_button { - min-width: 20px; - min-height: 20px; - border: none; - outline: none; -} -.flexlayout__tab_toolbar_button-min { - background: transparent url("../images/maximize.png") no-repeat center; -} -.flexlayout__tab_toolbar_button-max { - background: transparent url("../images/restore.png") no-repeat center; -} -.flexlayout__tab_toolbar_button-float { - background: transparent url("../images/popout.png") no-repeat center; -} -.flexlayout__tab_toolbar_button-close { - background: transparent url("../images/close.png") no-repeat center; -} -.flexlayout__tab_toolbar_sticky_buttons_container { - display: flex; - align-items: center; -} -.flexlayout__tab_floating { - overflow: auto; - position: absolute; - box-sizing: border-box; - color: black; - background-color: white; - display: flex; - justify-content: center; - align-items: center; -} -.flexlayout__tab_floating_inner { - overflow: auto; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} -.flexlayout__tab_floating_inner div { - margin-bottom: 5px; - text-align: center; -} -.flexlayout__tab_floating_inner div a { - color: royalblue; -} -.flexlayout__border { - box-sizing: border-box; - overflow: hidden; - display: flex; - font-size: medium; - font-family: Roboto, Arial, sans-serif; - background-color: white; -} -.flexlayout__border_top { - border-bottom: 1px solid #e9e9e9; - align-items: center; -} -.flexlayout__border_bottom { - border-top: 1px solid #e9e9e9; - align-items: center; -} -.flexlayout__border_left { - border-right: 1px solid #e9e9e9; - align-content: center; - flex-direction: column; -} -.flexlayout__border_right { - border-left: 1px solid #e9e9e9; - align-content: center; - flex-direction: column; +hr.ipyflex-divider { + margin: 1px 4px 1px 4px; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + border-width: 0; + border-style: solid; + border-color: rgb(0 0 0 / 45%); + border-bottom-width: thin; + list-style: none; } -.flexlayout__border_inner { + +.ipyflex-input-label-group { position: relative; - box-sizing: border-box; - display: flex; - overflow: hidden; - flex-grow: 1; -} -.flexlayout__border_inner_tab_container { - white-space: nowrap; - display: flex; - box-sizing: border-box; - position: absolute; - top: 0; - bottom: 0; - width: 10000px; -} -.flexlayout__border_inner_tab_container_right { - transform-origin: top left; - transform: rotate(90deg); -} -.flexlayout__border_inner_tab_container_left { - flex-direction: row-reverse; - transform-origin: top right; - transform: rotate(-90deg); -} -.flexlayout__border_button { - display: flex; - align-items: center; - cursor: pointer; - padding: 3px 8px; - margin: 2px; - box-sizing: border-box; - white-space: nowrap; - background-color: #f0f0f0; -} -.flexlayout__border_button--selected { - background-color: #e9e9e9; - color: black; -} -@media (hover: hover) { - .flexlayout__border_button:hover { - background-color: #e9e9e9; - color: black; - } -} -.flexlayout__border_button--unselected { - color: gray; -} -.flexlayout__border_button_leading { - display: inline; -} -.flexlayout__border_button_content { - display: inline-block; -} -.flexlayout__border_button_trailing { - display: inline-block; - margin-left: 8px; - min-width: 8px; - min-height: 8px; -} -@media (pointer: coarse) { - .flexlayout__border_button_trailing { - min-width: 20px; - min-height: 20px; - } -} -@media (hover: hover) { - .flexlayout__border_button:hover .flexlayout__border_button_trailing { - background: transparent url("../images/close.png") no-repeat center; - } -} -.flexlayout__border_button--selected .flexlayout__border_button_trailing { - background: transparent url("../images/close.png") no-repeat center; -} -.flexlayout__border_toolbar { - display: flex; - align-items: center; -} -.flexlayout__border_toolbar_left { - flex-direction: column; -} -.flexlayout__border_toolbar_right { - flex-direction: column; -} -.flexlayout__border_toolbar_button { - min-width: 20px; - min-height: 20px; - border: none; - outline: none; -} -.flexlayout__border_toolbar_button-float { - background: transparent url("../images/popout.png") no-repeat center; -} -.flexlayout__border_toolbar_button_overflow { - border: none; - padding-left: 12px; - color: gray; - font-size: inherit; - background: transparent url("../images/more2.png") no-repeat left; -} -.flexlayout__border_toolbar_button_overflow_top, .flexlayout__border_toolbar_button_overflow_bottom { - margin-left: 10px; -} -.flexlayout__border_toolbar_button_overflow_right, .flexlayout__border_toolbar_button_overflow_left { - padding-right: 0px; - margin-top: 5px; -} -.flexlayout__popup_menu { - font-size: medium; - font-family: Roboto, Arial, sans-serif; -} -.flexlayout__popup_menu_item { - padding: 2px 10px 2px 10px; - white-space: nowrap; -} -@media (hover: hover) { - .flexlayout__popup_menu_item:hover { - background-color: #d4d4d4; - } -} -.flexlayout__popup_menu_container { - box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.15); - border: 1px solid #d4d4d4; - color: black; - background: white; - border-radius: 3px; - position: absolute; - z-index: 1000; - max-height: 50%; - min-width: 100px; - overflow: auto; -} -.flexlayout__floating_window _body { - height: 100%; + margin-top: 0.75rem; + margin-bottom: 0.75rem; } -.flexlayout__floating_window_content { - left: 0; - top: 0; - right: 0; - bottom: 0; - position: absolute; + +.ipyflex-float-input{ + width: 100%; } -.flexlayout__floating_window_tab { - overflow: auto; - left: 0; - top: 0; - right: 0; - bottom: 0; + +.ipyflex-float-label { position: absolute; - box-sizing: border-box; - background-color: white; - color: black; -} -.flexlayout__error_boundary_container { - left: 0; top: 0; - right: 0; - bottom: 0; - position: absolute; - display: flex; - justify-content: center; + left:0; + padding: 7px 0 0 13px; + transition: all 200ms; + opacity: 0.75; } -.flexlayout__error_boundary_content { - display: flex; - align-items: center; -} -.flexlayout__tabset_sizer { - padding-top: 5px; - padding-bottom: 3px; - font-size: medium; - font-family: Roboto, Arial, sans-serif; -} -.flexlayout__tabset_header_sizer { - padding-top: 3px; - padding-bottom: 3px; - font-size: medium; - font-family: Roboto, Arial, sans-serif; -} -.flexlayout__border_sizer { - padding-top: 6px; - padding-bottom: 5px; - font-size: medium; - font-family: Roboto, Arial, sans-serif; -} */ -/*# sourceMappingURL=light.css.map */ +.ipyflex-float-input:focus + .ipyflex-float-label, +.ipyflex-float-input:valid + .ipyflex-float-label { + font-size: 75%; + transform: translate3d(0, -100%, 0); + opacity: 1; +} diff --git a/docs/source/examples/factory.nblink b/docs/source/examples/factory.nblink new file mode 100644 index 0000000..1320d60 --- /dev/null +++ b/docs/source/examples/factory.nblink @@ -0,0 +1,4 @@ +{ + "path": "../../../examples/widget_factory/widget_factory.ipynb", + "extra-media": ["../../../examples/widget_factory/"] +} diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index 10ff997..9e87a9a 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -6,6 +6,7 @@ This section contains several examples generated from Jupyter notebooks. The widgets have been embedded into the page for demonstrative purposes. .. toctree:: + :maxdepth: 1 :glob: * diff --git a/docs/source/images/factory_widget.gif b/docs/source/images/factory_widget.gif new file mode 100644 index 0000000..8aab162 Binary files /dev/null and b/docs/source/images/factory_widget.gif differ diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 1f59344..33f488c 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -2,12 +2,12 @@ Usage ============= -**ipyflex** is meant to be used with widgets based on `ipywidgets`_. The entry point of **ipyflex** is the `FlexLayout` class, it allows users to dynamically customize the layout and fill their dashboard from the existing widgets. +**ipyflex** is meant to be used with widgets based on `ipywidgets`_. The entry point of **ipyflex** is the *FlexLayout* class, it allows users to dynamically customize the layout and fill their dashboard from the existing widgets. Create a dashboard from existing widgets ========================================== -The simplest way to create an **ipyflex** dashboard is to create a dictionary of existing widgets with the `keys` are the names of the widget and `values` are the instances of widgets and then use `FlexLayout` to compose the layout. +The simplest way to create an **ipyflex** dashboard is to create a dictionary of existing widgets with the `keys` are the names of the widget and `values` are the instances of widgets and then use *FlexLayout* to compose the layout. .. code:: Python @@ -36,6 +36,28 @@ Users can pass some configurations to the constructor of *FlexLayout* to set the - *style*: CSS styles to be passed to the root element of the dashboard, it accepts any CSS rules but the keys need to be in *camelCase* format. - *editable*: flag to enable or disable the editable mode. In non-editable mode, the toolbar with the *Save template* button is removed, tabs can not be removed, dragged, or renamed. +-------------------------------------- +Create widgets from factory functions +-------------------------------------- + +In the case of using existing widgets in *FlexLayout* dashboard, users can create multiple views of a widget, so all tabs are linked. If users want to have the independent widget in each tab, *FlexLayout* allows users to define the factories to create widgets from the interface. + +.. code:: Python + + def slider_factory(label: 'Label of slider', value: 'Initial value'): + return ipywidgets.FloatSlider(value=float(value), description=label ) + + factories = {"Slider factory": slider_factory} + + dashboard = FlexLayout(widgets, factories=factories) + +.. image:: images/factory_widget.gif + +If the factory function needs parameters, *FlexLayout* will build an input form to get parameters from the interface. Users can define annotations to have the label of the input form. + +.. note:: + *FlexLayout* will pass all parameters as string, users need to convert the inputs to their appropriate type in the factory function. + ---------------------------- FlexLayout interface ---------------------------- diff --git a/examples/widget_factory/widget_factory.ipynb b/examples/widget_factory/widget_factory.ipynb new file mode 100644 index 0000000..9963c69 --- /dev/null +++ b/examples/widget_factory/widget_factory.ipynb @@ -0,0 +1,135 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d4c569c9-9060-4245-abdf-3a434f2c3a67", + "metadata": {}, + "source": [ + "## A widget factory example" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "8e9ceab0-5b1d-413b-8744-bb3ea92dc6cb", + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets\n", + "from ipyflex import FlexLayout" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "887ba9a0-a506-49ae-8324-aeb36535b791", + "metadata": {}, + "outputs": [], + "source": [ + "slider = ipywidgets.FloatSlider(description='Linked slider')" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "94b10897-0011-4c76-b14f-663710581d05", + "metadata": {}, + "outputs": [], + "source": [ + "def slider_factory(label: 'Label of slider', value: 'Initial value'):\n", + " return ipywidgets.FloatSlider(value=float(value), description=label )" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6b13a1d1-183f-478a-8ddc-f4ee44208efc", + "metadata": {}, + "outputs": [], + "source": [ + "widgets = {'Linked slider': slider}\n", + "factories = {\"Slider factory\": slider_factory}" + ] + }, + { + "cell_type": "markdown", + "id": "ac5f292c-581f-4ad7-9eb5-29b677278307", + "metadata": {}, + "source": [ + "### Create an empty dashboard with factory" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "05e001ae-28a9-410d-ba96-33aaa92576a2", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "11166ad0399740f8a64fc5e9b863f376", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "FlexLayout(children={'Linked slider': FloatSlider(value=0.0, description='Linked slider')}, layout_config={'bo…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "FlexLayout(widgets, factories=factories, style={'height':'300px'})" + ] + }, + { + "cell_type": "markdown", + "id": "e53da48e-56f5-4413-bc40-1e95588b74f9", + "metadata": {}, + "source": [ + "### Load dashboard from template" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ef565dce-efc6-4481-9ae1-6acd2257a01f", + "metadata": {}, + "outputs": [], + "source": [ + "FlexLayout(widgets, factories=factories, style={'height':'300px'}, template = 'widget_factory.json')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6219ba62-23b8-441f-843a-1e650243bba5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.9.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/widget_factory/widget_factory.json b/examples/widget_factory/widget_factory.json new file mode 100644 index 0000000..11e5b3d --- /dev/null +++ b/examples/widget_factory/widget_factory.json @@ -0,0 +1 @@ +{"global": {"tabSetEnableClose": true, "tabSetTabLocation": "bottom"}, "borders": [], "layout": {"type": "row", "id": "#1", "children": [{"type": "tabset", "id": "#2", "children": [{"type": "tab", "id": "#3", "name": "New section ", "component": "sub", "config": {"model": {"global": {"tabSetEnableClose": true}, "borders": [], "layout": {"type": "row", "id": "#1", "children": [{"type": "row", "id": "#1399287c-e9b4-493c-abb3-54168c374352", "weight": 50, "children": [{"type": "tabset", "id": "#d95f9720-fb3d-43bb-a10f-10a4c0a61807", "weight": 50, "children": [{"type": "tab", "id": "#6d9d8b09-f17b-43ef-bb53-fbf936f74da0", "name": "Linked slider", "component": "Widget", "config": {"layoutID": "#3"}}]}, {"type": "tabset", "id": "#061a7542-6b94-454e-bb90-7a6d0bed7f1e", "weight": 50, "children": [{"type": "tab", "id": "#f95061fa-87c5-476e-98a3-5e7bf447deba", "name": "Linked slider", "component": "Widget", "config": {"layoutID": "#3"}}]}]}, {"type": "row", "id": "#69568e98-a2e4-4643-8cf2-2b3451718b15", "weight": 50, "children": [{"type": "tabset", "id": "#fab8e962-3886-47c3-9697-9ce8472ae8fc", "weight": 50, "children": [{"type": "tab", "id": "#6823abc0-557b-4720-b99b-a093cdd2b3a4", "name": "Slider factory", "component": "Widget", "config": {"layoutID": "#3", "extraData": {"label": "Slider 1", "value": "10"}}}]}, {"type": "tabset", "id": "#f5e77e9e-863d-443c-88cf-fe5457874302", "weight": 50, "children": [{"type": "tab", "id": "#cfa489e0-249c-4fa7-bf15-0dc54619d8d9", "name": "Slider factory", "component": "Widget", "config": {"layoutID": "#3", "extraData": {"label": "Slider 2", "value": "20"}}}], "active": true}]}]}}}}], "active": true}]}} \ No newline at end of file diff --git a/ipyflex/flex_layout.py b/ipyflex/flex_layout.py index 164412e..3f93e7a 100644 --- a/ipyflex/flex_layout.py +++ b/ipyflex/flex_layout.py @@ -4,28 +4,28 @@ # Copyright (c) Trung Le. # Distributed under the terms of the Modified BSD License. -""" -TODO: Add module docstring -""" - import json import os from enum import Enum -from typing import Dict as TypeDict +from typing import Callable, Dict as TypeDict from typing import List as TypeList from typing import Union from ipywidgets import DOMWidget, Widget, widget_serialization -from traitlets.traitlets import Bool, Dict, Instance, Unicode +from traitlets.traitlets import Bool, Dict, Instance, Unicode, List from ._frontend import module_name, module_version -from .utils import get_nonexistant_path +from .utils import get_nonexistant_path, get_function_signature import copy class MESSAGE_ACTION(str, Enum): SAVE_TEMPLATE = 'save_template' UPDATE_CHILDREN = 'update_children' + REQUEST_FACTORY = 'request_factory' + RENDER_FACTORY = 'render_factory' + RENDER_ERROR = 'render_error' + ADD_WIDGET = 'add_widget' class FlexLayout(DOMWidget): @@ -39,15 +39,18 @@ class FlexLayout(DOMWidget): _view_module = Unicode(module_name).tag(sync=True) _view_module_version = Unicode(module_version).tag(sync=True) - # children = TypedTuple( - # trait=Instance(Widget), help="List of widget children" - # ).tag(sync=True, **widget_serialization) children = Dict( key_trait=Unicode, value_trait=Instance(Widget), help='Dict of widget children', ).tag(sync=True, **widget_serialization) + widget_factories = Dict( + key_trait=Unicode, value_trait=Dict, default_value={} + ).tag(sync=True) + + placeholder_widget = List(trait=Unicode, default_value=[]).tag(sync=True) + layout_config = Dict( {'borderLeft': False, 'borderRight': False}, help='Layout configuration', @@ -73,7 +76,7 @@ class FlexLayout(DOMWidget): def __init__( self, widgets: Union[TypeDict, TypeList] = [], - # layout_config: TypeDict, + factories: TypeDict[str, Callable] = {}, **kwargs, ): super().__init__(**kwargs) @@ -86,9 +89,27 @@ def __init__( } else: raise TypeError('Invalid input!') - if len(list(self.RESERVED_NAME & set(self.children))) > 0: - raise KeyError('Please do not use widget name in reserved list!') - + children_set = set(self.children) + factories_set = set(factories) + if ( + len(self.RESERVED_NAME & children_set) > 0 + or len(self.RESERVED_NAME & factories_set) > 0 + ): + raise KeyError( + f'Please do not use widget name in reserved list: {self.RESERVED_NAME}' + ) + + if len(children_set & factories_set) > 0: + raise ValueError( + 'Please do not use a same name for both widget and factory' + ) + factories_sig = {} + for key, factory in factories.items(): + factories_sig[key] = get_function_signature(factory) + + self.widget_factories = factories_sig + self.placeholder_widget = [] + self._factories = factories self.template_json = None if self.template is not None: try: @@ -107,6 +128,13 @@ def add(self, name: str, widget: Widget) -> None: return if name in self.RESERVED_NAME: raise KeyError('Please do not use widget name in reserved list!') + error = ( + lambda type: f'A {type} with the same name is already registered!' + ) + if name in self.widget_factories: + raise KeyError(error('factory')) + if name in self.children: + raise KeyError(error('widget')) old = copy.copy(self.children) old[name] = widget @@ -137,3 +165,38 @@ def _handle_frontend_msg( with open(file_path, 'w') as f: json.dump(json_data, f) self.template = file_path + elif action == MESSAGE_ACTION.REQUEST_FACTORY: + factory_name = payload['factory_name'] + uuid = payload['uuid'] + if factory_name in self.widget_factories: + if 'extraData' in payload: + params = payload['extraData'] + else: + params = {} + try: + w_model = self._factories[factory_name](**params) + model_msg = widget_serialization['to_json'](w_model, None) + self.send( + { + 'action': MESSAGE_ACTION.RENDER_FACTORY, + 'payload': {'model_id': model_msg, 'uuid': uuid}, + } + ) + except Exception as e: + self.send( + { + 'action': MESSAGE_ACTION.RENDER_ERROR, + 'payload': {'error_msg': str(e), 'uuid': uuid}, + } + ) + + elif action == MESSAGE_ACTION.ADD_WIDGET: + widget_name = payload['name'] + old = copy.copy(self.placeholder_widget) + if widget_name not in old: + old.append(widget_name) + self.placeholder_widget = old + else: + raise KeyError( + 'A widget with the same name is already registered!' + ) diff --git a/ipyflex/utils.py b/ipyflex/utils.py index df07469..fd73d68 100644 --- a/ipyflex/utils.py +++ b/ipyflex/utils.py @@ -1,7 +1,9 @@ import os +import inspect +from typing import Callable, Dict -def get_nonexistant_path(fname_path): +def get_nonexistant_path(fname_path) -> str: if not os.path.exists(fname_path): return fname_path filename, extension = os.path.splitext(fname_path) @@ -12,3 +14,17 @@ def get_nonexistant_path(fname_path): i += 1 new_fname = get_name(i) return new_fname + + +def get_function_signature(f: Callable) -> Dict: + sig = inspect.signature(f) + + params = sig.parameters + ret = {} + for key in sig.parameters: + param = params.get(key) + ret[key] = { + 'annotation': str(param.annotation) if param.annotation != inspect._empty + else None + } + return ret diff --git a/src/dialogWidget.tsx b/src/dialogWidget.tsx index ae2a494..2ecc607 100644 --- a/src/dialogWidget.tsx +++ b/src/dialogWidget.tsx @@ -1,7 +1,9 @@ +import { uuid } from '@jupyter-widgets/base'; import { Dialog } from '@jupyterlab/apputils'; import { Widget } from '@lumino/widgets'; +import { IDict } from './utils'; -class BodyWidget extends Widget { +class TemplateNameWidget extends Widget { constructor(el: HTMLInputElement) { super({ node: el }); } @@ -11,12 +13,69 @@ class BodyWidget extends Widget { } } -export default function dialogBody( +class FactoryParameterWidget extends Widget { + constructor(signature: IDict) { + super(); + this._paramInputs = {}; + const params = Object.keys(signature); + params.forEach((p) => { + const id = uuid(); + const inp = document.createElement('input'); + inp.required = true; + this._paramInputs[p] = inp; + inp.id = id; + inp.classList.add('ipyflex-float-input'); + const label = document.createElement('label'); + label.htmlFor = id; + if (signature[p]['annotation']) { + label.innerText = `${p} : ${signature[p]['annotation']}`; + } else { + label.innerText = `${p}`; + } + label.classList.add('ipyflex-float-label'); + + const group = document.createElement('div'); + group.classList.add('ipyflex-input-label-group'); + group.appendChild(inp); + group.appendChild(label); + this.node.appendChild(group); + }); + } + + getValue(): IDict { + const ret = {}; + for (const [key, inp] of Object.entries(this._paramInputs)) { + ret[key] = inp.value; + } + return ret; + } + + private _paramInputs: IDict; +} + +export function factoryDialog( + title: string, + signature: IDict +): { + title: string; + body: FactoryParameterWidget; + buttons: Array; +} { + const saveBtn = Dialog.okButton({ label: 'Create' }); + const cancelBtn = Dialog.cancelButton({ label: 'Cancel' }); + return { + title, + body: new FactoryParameterWidget(signature), + buttons: [cancelBtn, saveBtn], + }; +} + +export function dialogBody( title: string, defaultValue: string = null ): { title: string; - body: BodyWidget; + body: TemplateNameWidget; buttons: Array; } { const saveBtn = Dialog.okButton({ label: 'Save' }); @@ -26,5 +85,9 @@ export default function dialogBody( if (defaultValue) { input.value = defaultValue; } - return { title, body: new BodyWidget(input), buttons: [cancelBtn, saveBtn] }; + return { + title, + body: new TemplateNameWidget(input), + buttons: [cancelBtn, saveBtn], + }; } diff --git a/src/menuWidget.tsx b/src/menuWidget.tsx index 715e30e..c6406f3 100644 --- a/src/menuWidget.tsx +++ b/src/menuWidget.tsx @@ -1,20 +1,24 @@ import React, { Component } from 'react'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; -import dialogBody from './dialogWidget'; +import { dialogBody, factoryDialog } from './dialogWidget'; import { showDialog } from '@jupyterlab/apputils'; -import { JUPYTER_BUTTON_CLASS } from './utils'; +import { IDict, JUPYTER_BUTTON_CLASS, MESSAGE_ACTION } from './utils'; interface IProps { nodeId: string; tabsetId: string; widgetList: Array; - addTabToTabset: (name: string, nodeId: string, tabsetId: string) => void; + placeholderList: Array; + factoryDict: IDict; + addTabToTabset: (name: string, extraData?: IDict) => void; model: any; + send_msg: ({ action: string, payload: any }) => void; } interface IState { anchorEl: HTMLElement; widgetList: Array; + placeholderList: Array; } const CREATE_NEW = 'Create new'; @@ -22,22 +26,44 @@ export class WidgetMenu extends Component { constructor(props: IProps) { super(props); props.model.listenTo(props.model, 'msg:custom', this.on_msg); + props.model.listenTo( + props.model, + 'change:placeholder_widget', + this.on_placeholder_change + ); this.state = { anchorEl: null, widgetList: [...props.widgetList, CREATE_NEW], + placeholderList: [...props.placeholderList], }; } + on_placeholder_change = ( + model: any, + newValue: Array, + change: any + ): void => { + this.setState((old) => ({ + ...old, + placeholderList: newValue, + })); + }; + on_msg = (data: { action: string; payload: any }, buffer: any[]): void => { const { action, payload } = data; switch (action) { - case 'update_children': + case MESSAGE_ACTION.UPDATE_CHILDREN: { const wName: string = payload.name; - this.setState((old) => ({ - ...old, - widgetList: [...old.widgetList, wName], - })); + if ( + !this.state.widgetList.includes(wName) && + !this.state.placeholderList.includes(wName) + ) { + this.setState((old) => ({ + ...old, + widgetList: [...old.widgetList, wName], + })); + } } return null; @@ -49,24 +75,23 @@ export class WidgetMenu extends Component { this.setState((oldState) => ({ ...oldState, anchorEl: target })); }; - handleClose = () => { + handleClose = (): void => { this.setState((oldState) => ({ ...oldState, anchorEl: null })); }; render(): JSX.Element { const menuId = `add_widget_menu_${this.props.tabsetId}@${this.props.nodeId}`; - const menuItem = []; - for (const name of this.state.widgetList) { + const widgetItems = []; + for (const name of [ + ...this.state.widgetList, + ...this.state.placeholderList, + ]) { if (name !== CREATE_NEW) { - menuItem.push( + widgetItems.push( { - this.props.addTabToTabset( - name, - this.props.nodeId, - this.props.tabsetId - ); + this.props.addTabToTabset(name); this.handleClose(); }} > @@ -75,6 +100,34 @@ export class WidgetMenu extends Component { ); } } + + const factoryItems = []; + for (const [name, signature] of Object.entries(this.props.factoryDict)) { + factoryItems.push( + { + this.handleClose(); + const paramList = Object.keys(signature); + if (paramList.length > 0) { + const result = await showDialog>( + factoryDialog('Factory parameters', signature) + ); + if (result.button.label === 'Create' && result.value) { + this.props.addTabToTabset(name, result.value); + } else { + return; + } + } else { + this.props.addTabToTabset(name); + } + }} + > + {name} + + ); + } + const createNew: JSX.Element = ( { ); if (result.button.label === 'Save' && result.value) { widgetName = result.value; - this.setState((old) => ({ - ...old, - widgetList: [...old.widgetList, widgetName], - })); + if (this.state.widgetList.includes(widgetName)) { + alert('A widget with the same name is already registered!'); + return; + } + if (widgetName in this.props.factoryDict) { + alert('A factory with the same name is already registered!'); + return; + } + this.props.send_msg({ + action: MESSAGE_ACTION.ADD_WIDGET, + payload: { name: widgetName }, + }); } else { return; } - this.props.addTabToTabset( - widgetName, - this.props.nodeId, - this.props.tabsetId - ); + this.props.addTabToTabset(widgetName); }} > {CREATE_NEW} ); - menuItem.push(createNew); + // menuItem.push(createNew); return (