Skip to content

Instantly share code, notes, and snippets.

@gnestor
Last active March 17, 2020 11:41
Show Gist options
  • Save gnestor/6180c91a78dae41ad081af3377b217b8 to your computer and use it in GitHub Desktop.
Save gnestor/6180c91a78dae41ad081af3377b217b8 to your computer and use it in GitHub Desktop.
Demo of tangle-like UI in Jupyter notebook
Display the source blob
Display the rendered blob
Raw
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Tangle components for vdom"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"This notebook demonstrates how to use [vdom](https://github.com/nteract/vdom) to create [tangle](http://worrydream.com/Tangle/)-like components that allow users to interact with values in text and see the results of those interactions instantly."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"First, let's import vdom:"
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"from vdom import *"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Next, let's create a simple `span` that will contain text and that's styled to look like a _tangle_ element:"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [
{
"data": {
"application/vdom.v1+json": {
"attributes": {
"style": {
"borderBottom": "1px dashed",
"color": "var(--jp-content-link-color)",
"cursor": "ew-resize",
"userSelect": "none"
}
},
"children": [
"1"
],
"tagName": "span"
},
"text/html": [
"<span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">1</span>"
],
"text/plain": [
"<span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">1</span>"
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"def tangle_span(value):\n",
" return span(\n",
" str(value),\n",
" style={\n",
" 'color': 'var(--jp-content-link-color)',\n",
" 'borderBottom': '1px dashed',\n",
" 'cursor': 'ew-resize',\n",
" 'userSelect': 'none'\n",
" }\n",
" )\n",
"\n",
"tangle_span(1)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Excellent! Now let's add an event handler to this element so that it will handle mouse events and update the value of the element:"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [
{
"data": {
"application/vdom.v1+json": {
"attributes": {},
"children": [
"the value is ",
{
"attributes": {
"style": {
"borderBottom": "1px dashed",
"color": "var(--jp-content-link-color)",
"cursor": "ew-resize",
"userSelect": "none"
}
},
"children": [
"1"
],
"eventHandlers": {
"onMouseDown": "-9223372036567025166_onMouseDown"
},
"tagName": "span"
}
],
"eventHandlers": {
"onMouseMove": "287759117_onMouseMove",
"onMouseUp": "-9223372036567016700_onMouseUp"
},
"tagName": "div"
},
"text/html": [
"<div>the value is <span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">1</span></div>"
],
"text/plain": [
"<div>the value is <span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">1</span></div>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"from IPython.display import display\n",
"\n",
"x_start = 0\n",
"value = 1\n",
"\n",
"def update_tangle(value):\n",
" my_tangle_handle.update(my_tangle(value))\n",
"\n",
"def handle_mouse_down(event):\n",
" global x_start\n",
" x_start = event['clientX']\n",
" \n",
"def handle_mouse_up(event):\n",
" global x_start, value\n",
" if x_start > 0:\n",
" x = event['clientX']\n",
" value = value + round(x - x_start)\n",
" x_start = 0\n",
" update_tangle(value)\n",
" \n",
"def handle_mouse_move(event):\n",
" global x_start\n",
" if x_start > 0:\n",
" x = event['clientX']\n",
" update_tangle(value + round(x - x_start))\n",
" \n",
"def tangle_span(value):\n",
" return span(\n",
" str(value),\n",
" style={\n",
" 'color': 'var(--jp-content-link-color)',\n",
" 'borderBottom': '1px dashed',\n",
" 'cursor': 'ew-resize',\n",
" 'userSelect': 'none'\n",
" },\n",
" onMouseDown=handle_mouse_down,\n",
" \n",
" )\n",
"\n",
"def my_tangle(value):\n",
" return div(\n",
" ['the value is ', tangle_span(value)],\n",
" onMouseUp=handle_mouse_up,\n",
" onMouseMove=handle_mouse_move\n",
" )\n",
"\n",
"my_tangle_handle = display(my_tangle(value), display_id=True)\n",
"\n",
"my_tangle_handle;"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"It works! Let's review the code for a moment:\n",
"\n",
"* Events like `mousemove` or `drag` only fire for mouse moves or drags inside of an element, so attaching an `onMouseMove` event handler to our `span` would stop firing once we drag off of the element. For that reason, we need a _container_ element to handle `mousemove` and `mousedown` events because those may (and probably will) occur outside of the `span` element. \n",
"* We register an `x_start` value on `mousedown` so that we can calculate the difference between it and future mouse positions. \n",
"* On `mousemove` and `mouseup`, we calculate the difference between the current `x` value and `x_start` and display that plus the current value.\n",
"* On `mouseup`, we set `value`, update our element, and reset `x_start` to ignore `mousemove` events.\n",
"\n",
"\n",
"Next, let's try to create a sentence that contains multiple dynamic values and a sum of those values:"
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [
{
"data": {
"application/vdom.v1+json": {
"attributes": {},
"children": [
"If we have ",
{
"attributes": {
"style": {
"borderBottom": "1px dashed",
"color": "var(--jp-content-link-color)",
"cursor": "ew-resize",
"userSelect": "none"
}
},
"children": [
"1"
],
"eventHandlers": {
"onMouseDown": "-9223372036567025217_onMouseDown"
},
"tagName": "span"
},
" banana(s) and ",
{
"attributes": {
"style": {
"borderBottom": "1px dashed",
"color": "var(--jp-content-link-color)",
"cursor": "ew-resize",
"userSelect": "none"
}
},
"children": [
"2"
],
"eventHandlers": {
"onMouseDown": "287759236_onMouseDown"
},
"tagName": "span"
},
" apple(s), then we have a total of 3 fruits."
],
"eventHandlers": {
"onMouseMove": "287759151_onMouseMove",
"onMouseUp": "-9223372036567016666_onMouseUp"
},
"tagName": "div"
},
"text/html": [
"<div>If we have <span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">1</span> banana(s) and <span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">2</span> apple(s), then we have a total of 3 fruits.</div>"
],
"text/plain": [
"<div>If we have <span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">1</span> banana(s) and <span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">2</span> apple(s), then we have a total of 3 fruits.</div>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"from IPython.display import display\n",
"\n",
"bananas = 1\n",
"apples = 2\n",
"client_x_start = 0\n",
"bananas_start = 0\n",
"apples_start = 0\n",
"\n",
"def normalize_change(change):\n",
" return round(change / 4)\n",
"\n",
"def handle_mouse_down(event, active):\n",
" global client_x_start, bananas_start, apples_start\n",
" client_x_start = event['clientX']\n",
" if active == 'bananas':\n",
" bananas_start = bananas\n",
" if active == 'apples':\n",
" apples_start = apples\n",
" \n",
"def handle_mouse_up(event):\n",
" global client_x_start, bananas_start, apples_start\n",
" client_x_start = bananas_start = apples_start = 0\n",
" \n",
"def handle_mouse_move(event):\n",
" global client_x_start, bananas_start, apples_start, apples, bananas\n",
" if client_x_start > 0:\n",
" if bananas_start > 0:\n",
" bananas = bananas_start + normalize_change(event['clientX'] - client_x_start)\n",
" if apples_start > 0:\n",
" apples = apples_start + normalize_change(event['clientX'] - client_x_start)\n",
" my_tangle_handle.update(my_tangle(bananas, apples))\n",
" \n",
"def tangle_container(children):\n",
" return div(\n",
" children,\n",
" onMouseUp=handle_mouse_up,\n",
" onMouseMove=handle_mouse_move\n",
" )\n",
"\n",
"def tangle_span(value, name):\n",
" return span(\n",
" str(value),\n",
" style={\n",
" 'color': 'var(--jp-content-link-color)',\n",
" 'borderBottom': '1px dashed',\n",
" 'cursor': 'ew-resize',\n",
" 'userSelect': 'none'\n",
" },\n",
" onMouseDown=lambda event: handle_mouse_down(event, name),\n",
" )\n",
"\n",
"def my_tangle(bananas, apples):\n",
" return tangle_container([\n",
" 'If we have ',\n",
" tangle_span(bananas, 'bananas'),\n",
" ' banana(s) and ',\n",
" tangle_span(apples, 'apples'),\n",
" f' apple(s), then we have a total of {str(apples + bananas)} fruits.'\n",
" ])\n",
"\n",
"my_tangle_handle = display(my_tangle(bananas, apples), display_id=True)\n",
"\n",
"my_tangle_handle;"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Great! Lastly, let's refactor our `tangle_container` and `tangle_span` components into something more generic so that we can share these components and potentially publish them as a Python package:"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {},
"outputs": [],
"source": [
"from copy import copy\n",
"\n",
"# These need to be global since the tangle_container object is re-created on each update\n",
"tangle_values = {}\n",
"tangle_values_prev = {}\n",
"\n",
"def tangle_span(value, handle_mouse_down):\n",
" return span(\n",
" str(value),\n",
" style={\n",
" 'color': 'var(--jp-content-link-color)',\n",
" 'borderBottom': '1px dashed',\n",
" 'cursor': 'ew-resize',\n",
" 'userSelect': 'none'\n",
" },\n",
" onMouseDown=handle_mouse_down,\n",
" )\n",
"\n",
"def tangle_container(render_children, update_tangle, **kwargs):\n",
" def normalize_change(change):\n",
" return round(change / 4)\n",
" \n",
" def handle_mouse_move(event):\n",
" x = event['clientX']\n",
" for key, _ in tangle_values.items():\n",
" value = tangle_values_prev[key]['value']\n",
" x_start = tangle_values[key]['x_start']\n",
" if x_start > 0:\n",
" tangle_values[key]['value'] = round(value + normalize_change(x - x_start))\n",
" update_tangle()\n",
" \n",
" def handle_mouse_up(event):\n",
" for key, _ in tangle_values.items():\n",
" tangle_values[key]['x_start'] = 0\n",
" update_tangle()\n",
" \n",
" def create_children(values):\n",
" children = []\n",
" for key, value in values.items():\n",
" if key not in tangle_values:\n",
" tangle_values[key] = {'value': value, 'x_start': 0}\n",
" def handle_mouse_down(event, _key=key):\n",
" global tangle_values_prev\n",
" tangle_values_prev = copy(tangle_values)\n",
" tangle_values[_key]['x_start'] = event['clientX']\n",
" children.append(tangle_span(tangle_values[key]['value'], handle_mouse_down))\n",
" return children\n",
" \n",
" children = create_children(kwargs)\n",
" \n",
" return div(\n",
" render_children(*children),\n",
" onMouseUp=handle_mouse_up,\n",
" onMouseMove=handle_mouse_move\n",
" )"
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {},
"outputs": [
{
"data": {
"application/vdom.v1+json": {
"attributes": {},
"children": [
"If we have ",
{
"attributes": {
"style": {
"borderBottom": "1px dashed",
"color": "var(--jp-content-link-color)",
"cursor": "ew-resize",
"userSelect": "none"
}
},
"children": [
"11"
],
"eventHandlers": {
"onMouseDown": "287770654_onMouseDown"
},
"tagName": "span"
},
" banana(s) and ",
{
"attributes": {
"style": {
"borderBottom": "1px dashed",
"color": "var(--jp-content-link-color)",
"cursor": "ew-resize",
"userSelect": "none"
}
},
"children": [
"2"
],
"eventHandlers": {
"onMouseDown": "-9223372036567005095_onMouseDown"
},
"tagName": "span"
},
" apple(s), then we have a total of 13 fruits."
],
"eventHandlers": {
"onMouseMove": "-9223372036567005078_onMouseMove",
"onMouseUp": "-9223372036567005180_onMouseUp"
},
"tagName": "div"
},
"text/html": [
"<div>If we have <span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">11</span> banana(s) and <span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">2</span> apple(s), then we have a total of 13 fruits.</div>"
],
"text/plain": [
"<div>If we have <span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">11</span> banana(s) and <span style=\"border-bottom: 1px dashed; color: var(--jp-content-link-color); cursor: ew-resize; user-select: none\">2</span> apple(s), then we have a total of 13 fruits.</div>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"def render_children(bananas, apples):\n",
" sum = int(bananas.children[0]) + int(apples.children[0])\n",
" return [\n",
" 'If we have ',\n",
" bananas,\n",
" ' banana(s) and ',\n",
" apples,\n",
" f' apple(s), then we have a total of {str(sum)} fruits.'\n",
" ]\n",
"\n",
"def my_tangle():\n",
" return tangle_container(\n",
" render_children,\n",
" update_tangle=update_my_tangle,\n",
" bananas=1,\n",
" apples=2\n",
" )\n",
"\n",
"def update_my_tangle():\n",
" my_tangle_handle.update(my_tangle())\n",
"\n",
"my_tangle_handle = display(my_tangle(), display_id=True)\n",
"\n",
"my_tangle_handle;"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"This is **much** better! As a consumer of these components, we only need to define a `render_children` function and provide initial values for my tangle values. \n",
"\n",
"If the `render_children` function is confusing, allow me me explain. In React, there is a common pattern called [render props](https://reactjs.org/docs/render-props.html) that allows a React component to do non-rendering things (in our case, track mouse move and mouse up events and maintain state) and then provide that to children components via a _render prop_. A render prop is function passed as a prop to a child component that returns a React element.\n",
"\n",
"In JSX, render props look like:\n",
"\n",
"```jsx\n",
"<ParentComponent render={state => <ChildComponent data={state} />} />\n",
"```\n",
"\n",
"Render props can also be passed as _children_ and this pattern–although confusing at first–seems to be the most common way that render props are used. In JSX, render props as children look like:\n",
"\n",
"```jsx\n",
"<ParentComponent>\n",
" {state => <ChildComponent data={state} />}\n",
"</ParentComponent>\n",
"```\n",
"\n",
"\n",
"We are using render props because we need a `tangle_container` to properly detect mouse move and mouse up events that occur outside of our `tangle_span`s (since we don't have access to the DOM's `window` from Python). We also want to provide users with a simple interface that doesn't require that they maintain the state of tangle values themselves. We can maintain the state of all tangle values in the `tangle_container` and render props allow us to pass that state to it's children components.\n",
"\n",
"Our `update_tangle` prop is not very _React-y_. VDOM doesn't provide a built-in way to update components in response to events or user interactions. This is primarily because VDOM didn't support events or user interactions at first. Now that it does, we may make changes to VDOM to make this more seamless. \n",
"\n",
"There is a lot of room to improve this specific tangle component interface and VDOM in general. The goal of VDOM is to provide a higher-level library for creating UI in Python (much like React did for the Javascript world). On the surface, it works much like React does but it sti"
]
},
{
"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.7"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment