File size: 34,858 Bytes
92f0e98 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 |
"""
labwidget by David Bau.
Base class for a lightweight javascript notebook widget framework
that is portable across Google colab and Jupyter notebooks.
No use of requirejs: the design uses all inline javascript.
Defines Model, Widget, Trigger, and Property, which set up data binding
using the communication channels available in either google colab
environment or jupyter notebook.
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.
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.
TODO: Support jupyterlab also.
"""
import json, html, re
from inspect import signature
class Model(object):
'''
Abstract base class that supports data binding. Within __init__,
a model subclass defines databound events and properties using:
self.evtname = Trigger()
self.propname = Property(initval)
Any Trigger or Property member can be watched by registering a
listener with `model.on('propname', callback)`.
An event can be triggered by `model.evtname.trigger(value)`.
A property can be read with `model.propname`, and can be set by
`model.propname = value`; this also triggers notifications.
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.
A space-separated list of names can be provided as `name`.
'''
for n in name.split():
self.prop(n).on(cb)
return self
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`.
'''
for n in name.split():
self.prop(n).off(cb)
return self
def prop(self, name):
'''
Returns the underlying Trigger or Property object for a
property, rather than its held value.
'''
curvalue = super().__getattribute__(name)
if not isinstance(curvalue, Trigger):
raise AttributeError('%s not a property or trigger but %s'
% (name, str(type(curvalue))))
return curvalue
def _initprop_(self, name, value):
'''
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):
'''
When a member is an Trigger or Property, then assignment notation
is delegated to the Trigger or Property so that notifications
and reparenting can be handled. That is, `model.name = value`
turns into `prop(name).set(value)`.
'''
if hasattr(self, name):
curvalue = super().__getattribute__(name)
if isinstance(curvalue, Trigger):
# Delegte "set" to the underlying Property.
curvalue.set(value)
else:
super().__setattr__(name, value)
else:
super().__setattr__(name, value)
if isinstance(value, Trigger):
self._initprop_(name, value)
def __getattribute__(self, name):
'''
When a member is a Property, then property getter
notation is delegated to the peoperty object.
'''
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
to syncrhonize HTML view state with the backend Python model state.
Each widget subclass overrides widget_js to provide Javascript code
that defines the widget's behavior. This javascript will be wrapped
in an immediately-invoked function and included in the widget's HTML
representation (_repr_html_) when the widget is viewed.
A widget's javascript is provided with two local variables:
element - the widget's root HTML element. By default this is
a <div> but can be overridden in widget_html.
model - the object representing the data model for the widget.
within javascript.
The model object provides the following javascript API:
model.get('propname') obtains a current property value.
model.set('propname', 'value') requests a change in value.
model.on('propname', callback) listens for property changes.
model.trigger('evtname', value) triggers an event.
Note that model.set just requests a change but does not change the
value immediately: model.get will not reflect the change until the
python backend has handled it and notified the javascript of the new
value, which will trigger any callbacks previously registered using
.on('propname', callback). Thus Widget impelements a V-shaped
notification protocol:
User entry -> | -> User-visible feedback
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, style=None, data=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':
self._comms = []
self._queue = []
# 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):
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.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.
'''
return ''
def widget_html(self):
'''
Override to define the initial HTML view of the widget. Should
define an element with id given by view_id().
'''
return f'<div {self.std_attrs()}></div>'
def view_id(self):
'''
Returns an HTML element id for the view currently being rendered.
Note that each time _repr_html_ is called, this id will change.
'''
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) +
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)})
json_data = re.sub('</', '<\\/', json_data)
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('data', upd('dataset'));
''')
return ''.join([
self.widget_html(),
'<script>(function() {',
WIDGET_MODEL_JS,
std_widget_js,
self.widget_js(),
'})();</script>'
]);
def _initprop_(self, name, value):
if not hasattr(self, '_viewcount'):
raise ValueError('base Model __init__ must be called')
super()._initprop_(name, value)
def notify_js(event):
self._send_to_js_(id(self), name, event.value)
if isinstance(value, Trigger):
value.on(notify_js, internal=True)
def _send_to_js_(self, *args):
if self._viewcount > 0:
if WIDGET_ENV == 'colab':
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)
elif WIDGET_ENV == 'jupyter':
if not self._comms:
self._queue.append(args)
return
for comm in self._comms:
comm.send(args)
def _recv_from_js_(self, fn):
if WIDGET_ENV == 'colab':
colab_output.register_callback(f"invoke_{id(self)}", fn)
elif WIDGET_ENV == 'jupyter':
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)
comm.on_close(handle_close)
comm.send('ok')
if self._queue:
for args in self._queue:
comm.send(args)
self._queue.clear()
if open_msg['content']['data']:
handle_comm(open_msg)
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
field objects. Trigger holds a list of listeners that need to
be notified about the event.
Multple Trigger objects can be tied (typically a parent Model can
have Triggers that are triggered by children models). To support
this, each Trigger can have a parent.
Trigger objects provide a notification protocol where view
interactions trigger events at a leaf that are sent up to the
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.
'''
self.notify(value)
def trigger(self, value=None):
'''
Triggers an event to be handled by the root. By default, the root
handler will accept the event so all the listeners will be notified.
'''
if self.parent is not None:
self.parent.trigger(value)
else:
self.handle(value)
def set(self, value):
'''
Sets the parent Trigger. Child Triggers trigger events by
triggering parents, and in turn they handle notifications
that come from parents.
'''
if self.parent is not None:
self.parent.off(self.handle)
self.parent = None
if isinstance(value, Trigger):
ancestor = value.parent
while ancestor is not None:
if ancestor == self:
raise ValueError('bound properties should not form a loop')
ancestor = ancestor.parent
self.parent = value
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, 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, internal))
def off(self, cb=None):
'''
Unregisters a listener.
'''
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.
'''
self.value = value
self.notify(value)
def set(self, value):
'''
When a Property value is set to an ordinary value, it
triggers an event which causes a notification to be
sent to update all linked Properties. A Property set
to another Property becomes a child of the value.
'''
# Handle setting a parent Property
if isinstance(value, Property):
super().set(value)
self.handle(value.value)
elif isinstance(value, Trigger):
raise ValueError('Cannot set a Property to an Trigger')
else:
self.trigger(value)
class Event(object):
def __init__(self, value, name, target, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
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, traceback
captured = self.buffer.getvalue()
if len(captured):
self.widget.write.trigger(f'<pre>{html.escape(captured)}</pre>')
if exc_type:
import traceback
tbtxt = ''.join(
traceback.format_exception(exc_type, exc_value, exc_tb))
self.widget.write.trigger(
f'<pre style="color:red;text-align:left">{tbtxt}</pre>')
sys.stdout = self.saved['stdout']
sys.stderr = self.saved['stderr']
##########################################################################
## Specific widgets
##########################################################################
class Button(Widget):
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 minify('''
element.addEventListener('click', (e) => {
model.trigger('click');
})
model.on('label', (ev) => {
element.value = ev.value;
})
''')
def widget_html(self):
return f'''<input {self.std_attrs()} type="button" value="{
html.escape(str(self.label))}">'''
class Label(Widget):
def __init__(self, value='', **kwargs):
super().__init__(**kwargs)
# databinding is defined using Property objects.
self.value = Property(value)
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 minify('''
model.on('value', (ev) => {
element.innerText = model.get('value');
});
''')
def widget_html(self):
return f'''<label {self.std_attrs()}>{
html.escape(str(self.value))}</label>'''
class Textbox(Widget):
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 minify('''
element.value = model.get('value');
element.size = model.get('size');
element.addEventListener('keydown', (e) => {
if (e.code == 'Enter') {
model.set('value', element.value);
}
});
element.addEventListener('blur', (e) => {
model.set('value', element.value);
});
model.on('value', (ev) => {
element.value = model.get('value');
});
model.on('size', (ev) => {
element.size = model.get('size');
});
''')
def widget_html(self):
html_str = f'''<input {self.std_attrs()} value="{
html.escape(str(self.value))}" size="{self.size}">'''
if self.desc is not None:
html_str = f"""<span>{self.desc}</span>{html_str}"""
return html_str
class Range(Widget):
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)
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):
return f'''<input {self.std_attrs()} type="range" value="{
self.value}" min="{self.min}" max="{self.max}">'''
class Choice(Widget):
"""
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 minify('''
function esc(unsafe) {
return unsafe.replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """);
}
function render() {
var lines = model.get('choices').map((c) => {
return '<label><input type="radio" name="choice" value="' +
esc(c) + '">' + esc(c) + '</label>'
});
element.innerHTML = lines.join(model.get('horizontal')?' ':'<br>');
}
model.on('choices horizontal', render);
model.on('selection', (ev) => {
[...element.querySelectorAll('input')].forEach((e) => {
e.checked = (e.value == ev.value);
})
});
element.addEventListener('change', (e) => {
model.set('selection', element.choice.value);
});
''')
def widget_html(self):
radios = [
f"""<label><input name="choice" type="radio" {
'checked' if value == self.selection else ''
} value="{html.escape(value)}">{html.escape(value)}</label>"""
for value in self.choices ]
sep = " " if self.horizontal else "<br>"
return f'<form {self.std_attrs()}>{sep.join(radios)}</form>'
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, ">").replace(/"/g, """);
}
function render() {
var selection = model.get('selection');
var lines = model.get('choices').map((c) => {
return '<option value="' + esc(''+c) + '"' +
(c == selection ? ' selected' : '') +
'>' + esc(''+c) + '</option>';
});
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"""<option value="{html.escape(str(value))}" {
'selected' if value == self.selection else ''
}>{html.escape(str(value))}</option>"""
for value in self.choices ]
sep = "\n"
return f'''<form {self.std_attrs()}"><select name="menu">{
sep.join(options)}</select></form>'''
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, ">").replace(/"/g, """);
}
function render() {
var lines = model.get('choices').map((c) => {
return '<option value="' + esc(''+c) + '">';
});
element.inp.list.innerHTML = lines.join('\\n');
}
model.on('choices', render);
model.on('value', (ev) => {
element.inp.value = ev.value;
});
function restoreValue() {
var inp = element.inp;
if (inp.value == '') {
inp.value = inp.placeholder;
inp.placeholder = '';
}
}
element.inp.addEventListener('mousedown', (e) => {
var inp = element.inp;
if (inp.value != '') {
inp.placeholder = inp.value;
inp.value = '';
if (e.clientX < inp.getBoundingClientRect().right - 25) {
setTimeout(restoreValue, 0);
}
}
});
element.inp.addEventListener('mouseleave', restoreValue)
element.inp.addEventListener('change', (e) => {
model.set('value', element.inp.value);
});
''')
def widget_html(self):
options = [
f"""<option value="{html.escape(str(value))}">"""
for value in self.choices ]
return ''.join([
f'<form {self.std_attrs()} onsubmit="return false;">',
f'<input name="inp" list="{self.datalist_id()}" autocomplete="off">',
f'<datalist id="{self.datalist_id()}">',
''.join(options),
f'</datalist></form>'])
class Div(Widget):
"""
Just an empty DIV element. Use the innerHTML property to
change its contents, or use the clear() and print() method.
"""
def __init__(self, innerHTML='', **kwargs):
super().__init__(**kwargs)
# TODO: unify more closely with the show() library.
self.innerHTML = Property(innerHTML)
def clear(self):
"""Clears the contents of the div."""
self.innerHTML = ''
def show(self, *args):
from . import show
self.innerHTML = show.html(args)
def print(self, *args, replace=False):
"""Appends plain text (as a pre) into the div."""
newHTML = '<pre>%s</pre>' % ' '.join(
html.escape(str(text)) for text in args)
if replace:
self.innerHTML = newHTML
else:
self.innerHTML += newHTML
def widget_js(self):
# Note that if we want innerHTML to support script execution,
# we need to do it explicitly, like this.
return minify('''
model.on('innerHTML', (ev) => {
element.innerHTML = ev.value;
Array.from(element.querySelectorAll("script")).forEach(old=>{
const newScript = document.createElement("script");
Array.from(old.attributes).forEach(attr =>
newScript.setAttribute(attr.name, attr.value));
newScript.appendChild(document.createTextNode(old.innerHTML));
old.parentNode.replaceChild(newScript, old);
});
});
''')
def widget_html(self):
return f'''<div {self.std_attrs()}>{self.innerHTML}</div>'''
class ClickDiv(Div):
'''
A Div that triggers click events when anything inside them is clicked.
If a clicked element contains a data-click value, then that value is
sent as the click event value.
'''
def __init__(self, innerHTML='', **kwargs):
super().__init__(innertHTML, **kwargs)
self.click = Trigger()
def widget_js(self):
return super().widget_js() + minify('''
element.addEventListener('click', (ev) => {
var target = ev.target;
while (target && target != element && !target.dataset.click) {
target = target.parentElement;
}
var value = target.dataset.click;
model.trigger('click', value);
});
''')
class Image(Widget):
"""
Just a IMG element. Use the src property to change its contents by url,
or use the clear() and render(imgdata) methods to convert PIL or
tensor image data to a url to display.
"""
def __init__(self, src='', style=None, **kwargs):
super().__init__(style=defaulted(style, margin=0), **kwargs)
self.src = Property(src)
self.click = Trigger()
def clear(self):
"""Clears the image."""
self.src = ''
def render(self, imgdata, source=None):
"""Converts a pil image or some tensor to a url to show inline."""
from . import renormalize
self.src = renormalize.as_url(imgdata, source=source)
def widget_js(self):
return minify('''
model.on('src', (ev) => { element.src = ev.value; });
element.addEventListener('click', (ev) => {
model.trigger('click');
});
''')
def widget_html(self):
return f'''<img {self.std_attrs()} src="{html.escape(self.src)}">'''
##########################################################################
## Utils
##########################################################################
def minify(t):
# TODO: plug in some more real minification.
return re.sub(r'\n\s*', '\n', t)
def style_attr(d):
if not d:
return ''
return ' style="%s"' % html.escape(css_style_from_dict(d))
def data_attrs(d):
if not d:
return ''
return ''.join([
' data-%s="%s"' % (k, html.escape(str(v))) for k, v in d.items()])
def css_style_from_dict(d):
return ';'.join(
re.sub('([A-Z]+)', r'-\1',k).lower() + ':' +
re.sub('([][\\!"#$%&\'()*+,./:;<=>?@^`{|}~])', r'\\\1', str(v))
for k, v in d.items())
def defaulted(d, **kwargs):
if d is None:
return kwargs
result = dict(kwargs)
result.update(d)
return result
##########################################################################
## Implementation Details
##########################################################################
WIDGET_ENV = None
if WIDGET_ENV is None:
try:
from google.colab import output as colab_output
WIDGET_ENV = 'colab'
except:
pass
if WIDGET_ENV is None:
try:
from ipykernel.comm import Comm as jupyter_comm
COMM_MANAGER = get_ipython().kernel.comm_manager
WIDGET_ENV = 'jupyter'
except:
pass
SEND_RECV_JS = """
function recvFromPython(obj_id, fn) {
var recvname = "recv_" + obj_id;
if (window[recvname] === undefined) {
window[recvname] = new BroadcastChannel("channel_" + obj_id);
}
window[recvname].addEventListener("message", (ev) => {
if (ev.data == 'ok') {
window[recvname].ok = true;
return;
}
fn.apply(null, ev.data.slice(1));
});
}
function sendToPython(obj_id, ...args) {
google.colab.kernel.invokeFunction('invoke_' + obj_id, args, {})
}
""" if WIDGET_ENV == 'colab' else """
function getChan(obj_id) {
var cname = "comm_" + obj_id;
if (!window[cname]) { window[cname] = []; }
var chan = window[cname];
if (!chan.comm && Jupyter.notebook.kernel) {
chan.comm = Jupyter.notebook.kernel.comm_manager.new_comm(cname, {});
chan.comm.on_msg((ev) => {
if (chan.retry) { clearInterval(chan.retry); chan.retry = null; }
if (ev.content.data == 'ok') { return; }
var args = ev.content.data.slice(1);
for (fn of chan) { fn.apply(null, args); }
});
chan.retries = 5;
chan.retry = setInterval(() => {
if (chan.retries) { chan.retries -= 1; chan.comm.open(); }
else { clearInterval(chan.retry); chan.retry = null; }
}, 2000);
}
return chan;
}
function recvFromPython(obj_id, fn) {
getChan(obj_id).push(fn);
}
function sendToPython(obj_id, ...args) {
var comm = getChan(obj_id).comm;
if (comm) { comm.send(args); }
}
"""
WIDGET_MODEL_JS = minify(SEND_RECV_JS + """
class Model {
constructor(obj_id, init) {
this._id = obj_id;
this._listeners = {};
this._data = Object.assign({}, init)
recvFromPython(this._id, (name, value) => {
this._data[name] = value;
var e = new Event(name); e.value = value;
if (this._listeners.hasOwnProperty(name)) {
this._listeners[name].forEach((fn) => { fn(e); });
}
})
}
trigger(name, value) {
sendToPython(this._id, name, value);
}
get(name) {
return this._data[name];
}
set(name, value) {
this.trigger(name, value);
}
on(name, fn) {
name.split(/\s+/).forEach((n) => {
if (!this._listeners.hasOwnProperty(n)) {
this._listeners[n] = [];
}
this._listeners[n].push(fn);
});
}
off(name, fn) {
name.split(/\s+/).forEach((n) => {
if (!fn) {
delete this._listeners[n];
} else if (this._listeners.hasOwnProperty(n)) {
this._listeners[n] = this._listeners[n].filter(
(e) => { return e !== fn; });
}
});
}
}
""")
|