From a0cad283de894448df24edb135548eb2d559d162 Mon Sep 17 00:00:00 2001 From: David Bau Date: Thu, 17 Mar 2022 19:38:15 -0400 Subject: [PATCH] Update labwidget and show to latest. --- torchkit/labwidget.py | 706 +++++++++++++++++++++++++++++++--------- torchkit/paintwidget.py | 94 ++++-- torchkit/show.py | 50 ++- 3 files changed, 654 insertions(+), 196 deletions(-) diff --git a/torchkit/labwidget.py b/torchkit/labwidget.py index 3736735..79d2f29 100644 --- a/torchkit/labwidget.py +++ b/torchkit/labwidget.py @@ -13,16 +13,42 @@ This module also defines Label, Textbox, Range, Choice, and Div widgets; the code for these are good examples of usage of Widget, Trigger, and Property objects. -User interaction should update the javascript model using -model.set('propname', value); this will propagate to the python -model and notify any registered python listeners. +Within HTML widgets, user interaction should update the javascript +model using model.set('propname', value); this will propagate to +the python model and notify any registered python listeners; similarly +model.on('propname', callback) will listen for property changes +that come from python. + +MIT LICENSE + +Copyright 2020 David Bau + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. -TODO: Support jupyterlab also. """ -import json, html, re, base64, io +import json +import html +import re from inspect import signature + class Model(object): ''' Abstract base class that supports data binding. Within __init__, @@ -40,6 +66,7 @@ class Model(object): In both these cases, any registered listeners will be called with the given value. ''' + def on(self, name, cb): ''' Registers a listener for named events and properties. @@ -49,7 +76,7 @@ class Model(object): self.prop(n).on(cb) return self - def off(self, name, cb): + def off(self, name, cb=None): ''' Unregisters a listener for named events and properties. A space-separated list of names can be provided as `name`. @@ -66,7 +93,7 @@ class Model(object): curvalue = super().__getattribute__(name) if not isinstance(curvalue, Trigger): raise AttributeError('%s not a property or trigger but %s' - % (name, str(type(curvalue)))) + % (name, str(type(curvalue)))) return curvalue def _initprop_(self, name, value): @@ -74,6 +101,8 @@ class Model(object): To be overridden in base classes. Handles initialization of a new Trigger or Property member. ''' + value.name = name + value.target = self return def __setattr__(self, name, value): @@ -98,13 +127,16 @@ class Model(object): def __getattribute__(self, name): ''' When a member is a Property, then property getter - notation is delegated to the peoperty object. + notation is delegated to the peoperty object. If you + wish to acccess the Property object itself, use the + prop('propname') method. ''' curvalue = super().__getattribute__(name) if isinstance(curvalue, Property): return curvalue.value return curvalue + class Widget(Model): ''' Base class for an HTML widget that uses a Javascript model object @@ -139,9 +171,14 @@ class Widget(Model): js model.set -> | -> js.model.on callback python prop.trigger -> | -> python prop.notify python prop.handle + + Finally, all widgets provide standard databinding for style and data + properties, which are write-only (python-to-js) properties that + let python directly control CSS styles and HTML dataset attributes + for the top-level widget element. ''' - def __init__(self): + def __init__(self, style=None, data=None, className=None): # In the jupyter case, there can be some delay between js injection # and comm creation, so we need to queue some initial messages. if WIDGET_ENV == 'jupyter': @@ -150,16 +187,26 @@ class Widget(Model): # Each call to _repr_html_ creates a unique view instance. self._viewcount = 0 # Python notification is handled by Property objects. + def handle_remote_set(name, value): - self.prop(name).trigger(value) + with capture_output(self): # make errors visible. + self.prop(name).trigger(value) self._recv_from_js_(handle_remote_set) + # The style and data properties come standard, and are used to + # control the style and data attributes on the toplevel element. + self.style = Property(style) + self.className = Property(className) + self.data = Property(data) + # Each widget has a "write" event that is used to insert + # html before the widget. + self.write = Trigger() def widget_js(self): ''' Override to define the javascript logic for the widget. Should render the initial view based on the current model state (if not already rendered using widget_html) and set up listeners to keep - the model and the view synchornized. + the model and the view synchronized. ''' return '' @@ -168,7 +215,7 @@ class Widget(Model): Override to define the initial HTML view of the widget. Should define an element with id given by view_id(). ''' - return f'
' + return f'
' def view_id(self): ''' @@ -177,43 +224,71 @@ class Widget(Model): ''' return f"_{id(self)}_{self._viewcount}" + def std_attrs(self): + ''' + Returns id and (if applicable) style attributes, escaped and + formatted for use within the top-level element of widget HTML. + ''' + return (f'id="{self.view_id()}"' + + style_attr(self.style) + + class_attr(self.className) + + data_attrs(self.data)) + def _repr_html_(self): ''' Returns the HTML code for the widget. ''' self._viewcount += 1 json_data = json.dumps({ - k: v.value for k, v in vars(self).items() - if isinstance(v, Property)}) + k: v.value for k, v in vars(self).items() + if isinstance(v, Property)}) json_data = re.sub(' - (function() {{ - {WIDGET_MODEL_JS} - var model = new Model("{id(self)}", {json_data}); - var element = document.getElementById("{self.view_id()}"); - {self.widget_js()} - }})(); - - """ + + std_widget_js = minify(f''' + var model = new Model("{id(self)}", {json_data}); + var element = document.getElementById("{self.view_id()}"); + model.on('write', (ev) => {{ + var dummy = document.createElement('div'); + dummy.innerHTML = ev.value.trim(); + dummy.childNodes.forEach((item) => {{ + element.parentNode.insertBefore(item, element); + }}); + }}); + function upd(a) {{ return (e) => {{ for (k in e.value) {{ + element[a][k] = e.value[k]; + }}}}}} + model.on('style', upd('style')); + model.on('style', upd('style')); + model.on('data', upd('dataset')); + ''') + + return ''.join([ + self.widget_html(), + '' + ]) def _initprop_(self, name, value): if not hasattr(self, '_viewcount'): raise ValueError('base Model __init__ must be called') - def notify_js(value): - self._send_to_js_(id(self), name, value) - if isinstance(value, Trigger): - value.on(notify_js) + super()._initprop_(name, value) - def _send_to_js_(self, *args): + if isinstance(value, Trigger): + value.on(self._send_to_js_, internal=True) + + def _send_to_js_(self, event): if self._viewcount > 0: + assert self == event.target + args = (id(event.target), event.name, event.value) if WIDGET_ENV == 'colab': - colab_output.eval_js(f""" + colab_output.eval_js(minify(f""" (window.send_{id(self)} = window.send_{id(self)} || new BroadcastChannel("channel_{id(self)}") ).postMessage({json.dumps(args)}); - """, ignore_result=True) + """), ignore_result=True) elif WIDGET_ENV == 'jupyter': if not self._comms: self._queue.append(args) @@ -228,9 +303,11 @@ class Widget(Model): def handle_comm(msg): fn(*(msg['content']['data'])) # TODO: handle closing also. + def handle_close(close_msg): comm_id = close_msg['content']['comm_id'] self._comms = [c for c in self._comms if c.comm_id != comm_id] + def open_comm(comm, open_msg): self._comms.append(comm) comm.on_msg(handle_comm) @@ -245,6 +322,12 @@ class Widget(Model): cname = "comm_" + str(id(self)) COMM_MANAGER.register_target(cname, open_comm) + def display(self): + from IPython.core.display import display + display(self) + return self + + class Trigger(object): """ Trigger is the base class for Property and other data-bound @@ -260,16 +343,24 @@ class Trigger(object): root Trigger to be handled. By default, the root handler accepts events by notifying all listeners and children in the tree. """ + def __init__(self): self._listeners = [] self.parent = None + # name and target are set in Model._initprop_. + self.name = None + self.target = None + def handle(self, value): ''' Method to override; called at the root when an event has been triggered, and on a child when the parent has notified. By default notifies all listeners. ''' + if isinstance(value, Event): + value = value.value self.notify(value) + def trigger(self, value=None): ''' Triggers an event to be handled by the root. By default, the root @@ -279,6 +370,7 @@ class Trigger(object): self.parent.trigger(value) else: self.handle(value) + def set(self, value): ''' Sets the parent Trigger. Child Triggers trigger events by @@ -295,51 +387,64 @@ class Trigger(object): raise ValueError('bound properties should not form a loop') ancestor = ancestor.parent self.parent = value - self.parent.on(self.handle) + self.parent.on(self.handle, internal=True) elif not isinstance(self, Property): raise ValueError('only properties can be set to a value') + def notify(self, value=None): ''' Notifies listeners and children. If a listener accepts an argument, the value will be passed as a single argument. ''' - for cb in self._listeners: - if len(signature(cb).parameters) == 0: - cb() # no-parameter callback. - else: - cb(value) - # TODO: consider adding a two-parameter callback form - # where a detailed event object can be added. - def on(self, cb): + for cb, internal in self._listeners: + with enter_handler(self.name, internal) as ctx: + if ctx.silence: + # do not notify recursively... + # print(f'silenced recursive {self.name} {cb.__name__}') + pass + elif len(signature(cb).parameters) == 0: + cb() # no-parameter callback. + else: + cb(Event(value, self.name, self.target)) + + def on(self, cb, internal=False): ''' Registers a listener. Calling multiple times registers multiple listeners. ''' - self._listeners.append(cb) - def off(self, cb): + self._listeners.append((cb, internal)) + + def off(self, cb=None): ''' Unregisters a listener. ''' - self._listeners = [c for c in self._listeners if c != cb] + self._listeners = [(c, i) for c, i in self._listeners + if c != cb and cb is not None] + class Property(Trigger): """ A Property is just an Trigger that remembers its last value. """ + def __init__(self, value=None): ''' Can be initialized with a starting value. ''' super().__init__() self.set(value) + def handle(self, value): ''' The default handling for a Property is to store the value, then notify listeners. This method can be overridden, for example to validate values. ''' + if isinstance(value, Event): + value = value.value self.value = value self.notify(value) + def set(self, value): ''' When a Property value is set to an ordinary value, it @@ -357,33 +462,106 @@ class Property(Trigger): self.trigger(value) +class Event(object): + ''' + When a trigger is activated, python callbacks will be notified + by passing a single argument which is an Event object. Every + Event object holds a main value, and it also informs the callback + about the property name and the Widget object that is notifying. + ''' + def __init__(self, value, name, target, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + if isinstance(value, Event): + value = value.value + self.value = value + self.name = name + self.target = target + + +entered_handler_stack = [] + + +class enter_handler(object): + def __init__(self, name, internal): + global entered_handler_stack + self.internal = internal + self.name = name + self.silence = (not internal) and (len(entered_handler_stack) > 0) + + def __enter__(self): + global entered_handler_stack + if not self.internal: + entered_handler_stack.append(self) + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + global entered_handler_stack + if not self.internal: + entered_handler_stack.pop() + + +class capture_output(object): + """Context manager for capturing stdout/stderr. This is used, + by default, to wrap handler code that is invoked by a triggering + event coming from javascript. Any stdout/stderr or exceptions + that are thrown are formatted and written above the relevant widget.""" + + def __init__(self, widget): + from io import StringIO + self.widget = widget + self.buffer = StringIO() + + def __enter__(self): + import sys + self.saved = dict(stdout=sys.stdout, stderr=sys.stderr) + sys.stdout = self.buffer + sys.stderr = self.buffer + + def __exit__(self, exc_type, exc_value, exc_tb): + import sys + import traceback + captured = self.buffer.getvalue() + if len(captured): + self.widget.write.trigger(f'
{html.escape(captured)}
') + if exc_type: + import traceback + tbtxt = ''.join( + traceback.format_exception(exc_type, exc_value, exc_tb)) + self.widget.write.trigger( + f'
{tbtxt}
') + sys.stdout = self.saved['stdout'] + sys.stderr = self.saved['stderr'] + + ########################################################################## -## Specific widgets +# Specific widgets ########################################################################## class Button(Widget): - def __init__(self, label='button'): - super().__init__() + def __init__(self, label='button', style=None, **kwargs): + super().__init__(style=defaulted(style, display='block'), **kwargs) self.click = Trigger() self.label = Property(label) + def widget_js(self): - return ''' + return minify(''' element.addEventListener('click', (e) => { model.trigger('click'); }) - model.on('label', (v) => { - element.value = v; + model.on('label', (ev) => { + element.value = ev.value; }) - ''' + ''') + def widget_html(self): - return f''' - - ''' + return f'''''' + class Label(Widget): - def __init__(self, value=''): - super().__init__() + def __init__(self, value='', **kwargs): + super().__init__(**kwargs) # databinding is defined using Property objects. self.value = Property(value) @@ -391,28 +569,30 @@ class Label(Widget): # Both "model" and "element" objects are defined within the scope # where the js is run. "element" looks for the element with id # self.view_id(); if widget_html is overridden, this id should be used. - return ''' - model.on('value', (value) => { + return minify(''' + model.on('value', (ev) => { element.innerText = model.get('value'); }); - ''' + ''') + def widget_html(self): - return f''' - - ''' + return f'''''' + class Textbox(Widget): - def __init__(self, value='', size=20): - super().__init__() + def __init__(self, value='', size=20, style=None, desc=None, **kwargs): + super().__init__(style=defaulted(style, display='inline-block'), **kwargs) # databinding is defined using Property objects. self.value = Property(value) self.size = Property(size) + self.desc = Property(desc) def widget_js(self): # Both "model" and "element" objects are defined within the scope # where the js is run. "element" looks for the element with id # self.view_id(); if widget_html is overridden, this id should be used. - return ''' + return minify(''' element.value = model.get('value'); element.size = model.get('size'); element.addEventListener('keydown', (e) => { @@ -420,66 +600,105 @@ class Textbox(Widget): model.set('value', element.value); } }); - model.on('value', (value) => { + element.addEventListener('blur', (e) => { + model.set('value', element.value); + }); + model.on('value', (ev) => { element.value = model.get('value'); }); - model.on('size', (value) => { + model.on('size', (ev) => { element.size = model.get('size'); }); - ''' + ''') + def widget_html(self): - return f''' - - ''' + + html_str = f'''''' + if self.desc is not None: + html_str = f"""{self.desc}{html_str}""" + return html_str + class Range(Widget): - def __init__(self, value=50, min=0, max=100, step=1.0, changeevent='change'): - super().__init__() + def __init__(self, value=50, min=0, max=100, **kwargs): + super().__init__(**kwargs) # databinding is defined using Property objects. self.value = Property(value) self.min = Property(min) self.max = Property(max) - self.step = Property(step) - self.changeevent = Property(changeevent) def widget_js(self): # Note that the 'input' event would enable during-drag feedback, # but this is pretty slow on google colab. - return ''' - element.addEventListener(model.get('changeevent'), (e) => { - model.set('value', +element.value); + return minify(''' + element.addEventListener('change', (e) => { + model.set('value', element.value); }); - model.on('value', (value) => { + model.on('value', (e) => { if (!element.matches(':active')) { - element.value = value; + element.value = e.value; } }) - ''' + ''') + def widget_html(self): - return f''' - - ''' + return f'''''' + + +class ColorPicker(Widget): + def __init__(self, value='#00000', desc=None, **kwargs): + super().__init__(**kwargs) + # databinding is defined using Property objects. + self.value = Property(value) + self.desc = Property(desc) + + def widget_js(self): + # Note that the 'input' event would enable during-drag feedback, + # but this is pretty slow on google colab. + return minify(''' + element.addEventListener('change', (e) => { + model.set('value', element.value); + }); + model.on('value', (e) => { + if (!element.matches(':active')) { + element.value = e.value; + } + }) + ''') + + def widget_html(self): + html_str = f'''''' + if self.desc is not None: + html_str = f"""{self.desc}{html_str}""" + return html_str + class Choice(Widget): - def __init__(self, choices=None, selection=None, horizontal=False): - super().__init__() + """ + A set of radio button choices. + """ + + def __init__(self, choices=None, selection=None, horizontal=False, + **kwargs): + super().__init__(**kwargs) if choices is None: choices = [] self.choices = Property(choices) self.horizontal = Property(horizontal) self.selection = Property(selection) + def widget_js(self): # Note that the 'input' event would enable during-drag feedback, # but this is pretty slow on google colab. - return ''' + return minify(''' function esc(unsafe) { return unsafe.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function render() { - console.log('rendering'); var lines = model.get('choices').map((c) => { return '' @@ -487,38 +706,166 @@ class Choice(Widget): element.innerHTML = lines.join(model.get('horizontal')?' ':'
'); } model.on('choices horizontal', render); - model.on('selection', (selection) => { + model.on('selection', (ev) => { [...element.querySelectorAll('input')].forEach((e) => { - e.checked = (e.value == selection); + e.checked = (e.value == ev.value); }) }); element.addEventListener('change', (e) => { model.set('selection', element.choice.value); }); - ''' + ''') + def widget_html(self): radios = [ f"""""" - for value in self.choices ] + for value in self.choices] sep = " " if self.horizontal else "
" - return f'
{sep.join(radios)}
' + return f'
{sep.join(radios)}
' + + +class Menu(Widget): + """ + A dropdown choice. + """ + + def __init__(self, choices=None, selection=None, **kwargs): + super().__init__(**kwargs) + if choices is None: + choices = [] + self.choices = Property(choices) + self.selection = Property(selection) + + def widget_js(self): + return minify(''' + function esc(unsafe) { + return unsafe.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function render() { + var selection = model.get('selection'); + var lines = model.get('choices').map((c) => { + return ''; + }); + element.menu.innerHTML = lines.join('\\n'); + } + model.on('choices horizontal', render); + model.on('selection', (ev) => { + [...element.querySelectorAll('option')].forEach((e) => { + e.selected = (e.value == ev.value); + }) + }); + element.addEventListener('change', (e) => { + model.set('selection', element.menu.value); + }); + ''') + + def widget_html(self): + options = [ + f"""""" + for value in self.choices] + sep = "\n" + return f'''
''' + + +class Datalist(Widget): + """ + An input with a dropdown choice. + """ + + def __init__(self, choices=None, value=None, **kwargs): + super().__init__(**kwargs) + if choices is None: + choices = [] + self.choices = Property(choices) + self.value = Property(value) + + def datalist_id(self): + return self.view_id() + '-dl' + + def widget_js(self): + # The mousedown/mouseleave dance defeats the prefix-matching behavior + # of the built-in datalist by erasing value momentarily on mousedown. + return minify(''' + function esc(unsafe) { + return unsafe.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function render() { + var lines = model.get('choices').map((c) => { + return '