Plottery is an in-notebook graphical interface for Matplotlib (MPL). Instead of a static image, with Plottery plt.show() produces a rich graphical interface that lets you add plot elements, drag and resize shapes, tweak plot arguments with live preview, and prompt an LLM to make changes. All changes are written to the code live—the code is the ground truth—and code editing is supported at any time.
Naming note: internally the code still uses the old name SNP ("Sketch-n-Plot").
Create a virtual environment (recommended).
python3 -m venv .venv
source .venv/bin/activate
Install Python packages.
pip install -r requirements.txt
Install node packages.
npm install
The JupyterLab / Notebook v7 extension has its own npm package; install its dependencies too.
cd src/snp_jupyter
npm install
cd ../..
Build and install the extension. This will build the JupyterLab / Notebook v7 labextension and copy it into Jupyter's labextensions/ directory:
npm run build
For classic Jupyter Notebook (v6), npm run build-nbv6 installs + enables the nbextension:
npm run build-nbv6
While developing the frontend, npm run watch-ts rebuilds dist/plugin.js on change. The bundle is re-inlined into the output on each manual cell re-run, so you just re-run the cell to pick up changes.
Test it by opening jupyter and running src/snp.ipynb.
OPENAI_API_KEY=your_key jupyter notebook
If OPENAI_API_KEY is not provided, the AI panel will not show.
It should also work in Jupyter Lab:
OPENAI_API_KEY=your_key jupyter lab
If making screenshots, set window.sessionStorage.setItem('make_stuff_nice_for_screenshots', 'true'). This whitens the background, remove the execution counts and cell selection styling, and recenters the Plottery UI.
%%javascript
window.sessionStorage.removeItem('make_stuff_nice_for_screenshots')
window.sessionStorage.setItem('make_stuff_nice_for_screenshots', 'true')If making videos, set in_demo_mode. In addition to adding the make_stuff_nice_for_screenshots styling, this will make the plot slightly smaller and will surpress some distracting errors and warnings from Matplotlib.
window.sessionStorage.removeItem('in_demo_mode')
window.sessionStorage.setItem('in_demo_mode', 'true')The remaining sections below are a technical explanation of Plottery's operation.
There are roughly three technical parts.
- Jupyter/JupyterLab extension (
src/nbextension_snp/main.js/src/snp_jupyter/snp_jupyter.js) sets up three things: (a) automaticimport snpso user does not have to do anything to access Plottery, (b) adds a "New Plot" button to the interface, and (c) sets ups a cell code rewriter to changeplt.show()tosnp.show()(stashes the figure instead of showing it) and appendsnp.show_ui()to the end of the cell (which actually shows the figure and UI). The JupyterLab version also lifts some Jupyter and CodeMirror modules to globals so we can reference them later. - The Python backend (
src/snp.py) gathers all the information required to build the UI (function arguments and types via mypy, which user variables are type compatible with which arguments, the user's names for axes etc, and the regions where MPL shapes are on the plot). - The Typescript frontend (
src/frontend/main.ts) builds the UI, attaches layer "marks" to text regions of the code editor, and coordinates event handling.
Jupyter browser extension Python kernel
(nbextension OR labextension)
kernel ───► tells Python kernel to import snp ───► import snp
startup snp.py adds an AST rewriter that, on
each cell execution, wraps function
calls with logging code to set
_snp_came_from_call_id on the call
results so we know which MPL shapes
came from which calls.
user runs ───► rewrites the cell code: ───► the AST rewritter adds call logging
a cell plt.show(...) → snp.show(...) snp.show(...) stashes the figure
also appends snp.show_ui(fig_idx=N) snp.show_ui() builds an `SNP` object
to end of cell that has a ._repr_html_() function
that builds the SNP UI and bundles its
Javascript and styles. It returns one big
HTML blob: <img> of the plot,
an SVG of "hover regions", the inlined
built JS (dist/plugin.js), inlined CSS,
and a <style onload="attach_snp(...serialized
types/calls/AST...)"> that triggers the
Javascript to initialize the UI
attach_snp() builds the UI ◄──────────────────────────────────────────────────────────────┘
later, on GUI edit:
frontend edits CodeMirror text ─► re-executes the cell with a trailing
snp.show_ui(snp_class=SNPFigureOnly|SNPFigureAndHoverRegions) ─►
kernel returns a new PNG (and potentially SVG hover regions) over iopub ─►
frontend swaps the <img>/<svg> in place (no full re-render unless needed)
| Path | What it is |
|---|---|
src/snp.py |
The Python core. Rendering, type inference, hover regions, provenance, serialization, the SNP output object classes, show() / show_ui(). |
src/frontend/ |
The actual UI (TypeScript, built with Vite → dist/plugin.js). Entry point is attach_snp() in main.ts. |
src/nbextension_snp/main.js |
Browser extension for Jupyter Notebook (classic, v6). |
src/snp_jupyter/ |
Browser extension for JupyterLab / Jupyter Notebook v7 (its own npm package + build). |
src/serialize.py |
Turns arbitrary Python/mypy object graphs into JSON (arbitrary_to_json). Inverse of the frontend's deserialize.ts. |
src/visitor_mypy.py |
A TraverserVisitor over mypy AST nodes (vendored/derived from mypy). Used to walk typed calls. |
src/python-type-stubs-main/ |
Our fork of Microsoft's .pyi type stubs (matplotlib, numpy, etc.). These determine the arguments in the Properties panel for each MPL call. |
vite.config.js, tsconfig.json, package.json |
Frontend build + the npm run scripts that install/enable the extensions. |
requirements.txt |
Pinned Python deps (note notebook==6.2.0, mypy==1.8.0, numpy<2, shapely==2.0.2). |
src/see.py |
See(...) — a dev-only HTML object inspector for poking at values in the notebook. Not part of the product flow. |
src/*.ipynb, study_2_*/, *.rb |
Demo notebooks and user-study data/analysis. Not part of the tool. |
Jupyter Notebook v6 (classic) and JupyterLab/Notebook v7 have different frontend APIs, so Plottery ships the same behavior twice. They do the same three jobs:
import snpautomatically whenever the kernel (re)starts.- Rewrite
plt.show(...)in any executed cell into asnp.show(...)call that also forwards the notebook context the kernel can't otherwise see. - Append
snp.show_ui(fig_idx=N)to the cell so the rich UI is produced as the cell's output, and add a "New Plot" toolbar button with starter code.
The rewrite (identical logic in both) looks for plt.show / matplotlib.pyplot.show / aliased imports and turns:
plt.show()into:
snp.show(globals() | locals(), cell_lineno, plt_show_lineno_in_cell,
provenance_is_off_by_n_lines, "<entire notebook code through this cell>")
last_snp = snp.show_ui(fig_idx=0) # appended
last_snpsnp.show() needs notebook_code_through_cell because the IPython kernel only ever sees one cell at a time, but type inference needs the whole notebook so far.
src/nbextension_snp/main.js |
src/snp_jupyter/snp_jupyter.js |
|
|---|---|---|
| Target | Jupyter Notebook classic (v6) | JupyterLab / Notebook v7 |
| Module system | RequireJS AMD (define([...])), entry load_ipython_extension |
ES module exporting a JupyterFrontEndPlugin (activate) |
| Notebook access | global Jupyter.notebook / IPython |
INotebookTracker injected by Lab |
| Hooks the rewrite via | listens to execution_request.Kernel event; monkeypatches IPython.CodeCell.prototype.execute to know the running cell |
monkeypatches kernel.sendShellMessage and inspects execute_request messages |
Per-cell UI state (fig_idx) |
read from the cell DOM output_area … dataset.fig_idx |
read from sessionStorage key cell-<id>-snp-<key> |
| Editor it drives | CodeMirror 5 | CodeMirror 6 — so it stashes CM6 classes on window (__CM6EditorView, __CM6Decoration, StateField, …) and Lab classes (__JupyterCodeCellModule, __JupyterNotebookActionsModule) for the Plottery frontend to use |
| Build | none — plain JS, installed via jupyter nbextension install/enable |
its own npm package; jupyter labextension build → src/snp_jupyter/labextension, then copied into Lab's labextensions/ |
Both also pass a doesnt_need_snp_show_ui flag through: when the frontend triggers a re-execute that already includes its own snp.show_ui(...) (e.g. a fast figure-only redraw), the extension must not append a second show_ui.
The frontend itself (src/frontend/) is shared by both — it's injected into the page by Python (see §5), not by the extensions. The extensions only handle code rewriting of plt.show(), kernel bootstrapping, and the toolbar button.
| Concern | Where |
|---|---|
Rewriting plt.show → snp.show / appending show_ui |
Browser extension (main.js / snp_jupyter.js) |
| Rendering the plot to PNG / SVG hover-regions | Python (SNP*._repr_png_ / _repr_svg_) |
| Type inference (what args a call takes, their types) | Python (mypy, in snp.py) |
| Provenance: which MPL shapes came from which line of code | Python (IPython AST transformer + tag_with_call_provenance) |
| Serializing types/calls/AST to JSON for the UI | Python (serialize.py + snp.py) |
| Building the UI, widgets, drag handlers, layers, AI panel | Frontend (src/frontend/) |
| Mapping a drag/GUI change back to a code edit | Frontend writes into CodeMirror, then asks the kernel to re-render |
| Coordinate math (plot pixels ↔ data values) | Frontend, using data-*-bounds attributes Python writes onto the SVG |
| Talking to the LLM | Frontend (utils/llm.ts); key comes from OPENAI_API_KEY env, passed through to attach_snp |
snp.py is imported into the kernel as the module snp. Highlights, in rough order:
show(locals, cell_lineno, …, notebook_code_through_cell)— replacement forplt.show(). Just stashes the current figure + context intocell_figs. Returns nothing (so multipleshow()s in a cell are fine; only the one is surfaced).show_ui(fig_idx=0, snp_class=SNP)— pops one of the stashed figures and returns an instance of one of the render classes.snp_classcontrols how much work is done:SNPFigureOnly— just_repr_png_(). Used for fast live redraws during drags (it even drops DPI mid-drag, capping at ~1000px wide).SNPFigureAndHoverRegions— adds_repr_svg_(), the interactive overlay with clickable shapes on the plot. Builds a map of artist→name and computes per-artist bounding regions (regions2).SNP(the default, full) — additionally runs mypy inference, gathers typedcalls, availablemethodson each artist, the typed AST, parseable comments, and user-defined iterable variables. Its_repr_html_()assembles the final output: the plot<img>, the hover-region<svg>, an inlined<script>ofdist/plugin.js, allfrontend/**/*.cssinlined, and a<style onload="attach_snp(...)">that hands every serialized blob to the frontend.
- Provenance tracking — at import time,
snp.pyinstalls an IPython AST transformer (IPython.get_ipython().kernel.shell.ast_transformers = [RootProvenanceTagger()]). It wraps every call result insnp.tag_with_call_provenance(value, "ax.plot #1"), attaching_snp_came_from_call_idto the returned objects (including tuple/str/list/int/float/etc, using special includedTagged*subclasses). That id is later written onto the SVG regions (data-call-id=...) so the frontend can map a clicked region to the source call. - Variable-sharing provenance — the same transformer also tracks how a value reached a matplotlib argument through variable bindings, so the Properties panel can show an editable chain like
w2 ▸ w1 ▸ 0.5. It attaches a nested_snp_provenancenode to values at each binding (snp.tag_with_var_provenanceon assignment RHSes and same-cell user-function arguments), and at each matplotlib call site reads the argument's provenance into a globalvar_provenance_at_calllog keyed by(call_id, arg_key)(snp.note_arg_provenance).snp.show()snapshots that log per figure;GatherTypedCallslooks it up per argument and serializes it (converted to notebook coordinates) asgiven_arg["provenance"]. serialize_type/GatherTypedCalls— convert mypy types and call sites into the JSON the widgets need (arg names, kinds, types, defaults, source positions).- Helpers worth knowing:
serialize.arbitrary_to_json(generic object-graph → JSON with id-based cycle handling),artist_names(names every matplotlib artist for the UI),methods_for/method_associations(which possible new function calls to offer on which artist, for on-plot buttons/menus).
Plottery needs accurate argument types to choose the right widget (slider vs color picker vs dropdown of literals vs free code). It does this by running mypy not as a type checker, but as a type oracle.
do_mypy_inference(code) in snp.py:
- Writes the whole notebook-so-far to a temp file
__plottery_mypy_temp.py. This is so the file is in the same place on the filesystem as the user's notebook. Currently, the file is also left on disk for debugging. - Builds a fine-grained incremental mypy build and caches it across cell runs; it only does a full rebuild when the set of
importlines changes (cheap edits reuse the cache). - Sets options tuned for inference over checking:
export_types,preserve_asts,check_untyped_defs=True(so bodies of user functions get inferred), andignore_errors=True(type errors in the user's code must not break the tool). - Points
mypy_pathat the bundled stubs (see §7).
The result exposes mypy_result.types (expression → type) and .graph (the typed module tree), which GatherTypedCalls and serialize_type walk to produce calls, methods, and notebook_typed_ast.
An updated copy of Microsoft's python-type-stubs repo — .pyi files describing the public API of matplotlib, numpy, scipy, sklearn, networkx, etc. mypy reads these (via options.mypy_path = ".../python-type-stubs-main/stubs") to know, e.g., that Axes.bar(x, height, width=..., color=...) takes a color, an array-like, etc., so the UI can render the right control. Only the stubs/ subtree is used at runtime; the rest (tests/, utils/, its own README) is upstream baggage. Also includes types for potentially irrelevant projects.
TypeScript, bundled by Vite (vite.config.js) from src/frontend/main.ts into dist/plugin.js. It is not loaded by the Jupyter extensions. The Python SNP class's _repr_html_() result bundles the Javascript inline (on every manual cell re-run) for faster dev cycles.
main.ts— exposeswindow.attach_snp(...). Given the cell element plus all the serialized blobs from Python, it builds theState, wires up the sidebar, AI panel, layers panel, plot widgets, hover-region drag handlers, selection sync, and keyboard shortcuts.code_sync/code_sync.ts— the heart of "GUI change → code change → re-render". It writes generated code into CodeMirror "marks" (the regions of code that correspond to layers), then runs the cell again via the right kernel API (CM5/Notebook vs CM6/Lab) with a trailingshow_ui(snp_class=…), and swaps the returned PNG/SVG in place. Hasredraw_cell(fast PNG),refresh_hover_regions(SVG overlay), andhard_rerun(full re-execute, used after AI/structural edits).sidebar/— the inspector.call/call.ts(one matplotlib call + its plot-drag → arg mapping),arg/arg.ts(one argument row),methods/method.ts(insert a new method call from an on-plot button/menu),hover-regions/hover_regions.ts(the SVG overlay: select/drag/add-method),plot-widget/plot_widget.ts(inline on-plot text editing).sidebar/widgets/— the typed controls, chosen from mypy types bywidget.ts:code_and_control(primary scalar control: a code box synced to a slider/toggle/picker),int,float,bool,color,literal(enum-like values),alias,arbitrary_code(freeform Python fallback), anddropdown(picks among alternatives).layer_panel/layer_panel.ts— a structural list of the cell's code, one row per typed AST node (and parseable comments), with reorder-by-drag and show/hide (comment/uncomment) toggles.properties_panel/properties_panel.ts— pops the selected call's args out next to the plot.ai_panel/ai_panel.ts— natural-language editing; builds a prompt from the code so far, calls the LLM, replaces the cell text,hard_reruns, and highlights changed lines. Hidden unlessOPENAI_API_KEYis set.menus/menus.ts— reusable dropdown-menu + keyboard-shortcut framework.utils/—types.ts(aCellabstraction that papers over CM5/Notebook vs CM6/Lab),deserialize.ts(rebuilds Python's id-based JSON graphs into JS objects — inverse ofserialize.py),llm.ts(OpenAI client),instrumentation.ts(study logging tolocalhost:7777),misc.ts,stdlib.ts, and a vendored CodeMirror 5codemirror/type-defs folder.types.ts/ast_types.ts— app domain types and a (largely unused) mirror of Pythonastnodes.
This section traces what happens for common interactions. All paths below are under src/frontend/.
The State object (types.ts). A single State is built by attach_snp() for each rendered Plottery cell and handed to almost every function — it holds the only references to the live DOM and the editor, so "do X to the plot" almost always means "call something with state." Its fields fall into three groups:
- Static inputs from Python (set once, at build):
cell(the CM5/CM6-agnosticCell, see §8),cell_lineno/plt_show_lineno_in_cell/provenance_is_off_by_n_lines(used to map notebook line numbers ↔ in-cell line numbers),calls/calls_with_args,methods/methods_with_code,notebook_typed_defs,user_iterables,avoid_names(names already in use, fornon_colliding_name), andllm_api_key. - Mutable runtime state:
last_cell_code_executedandoutstanding_kernel_request_time(drive the busy/changed checks below),dragging_layers(the in-flight layer drag), andis_in_dom()— flips tofalseafter a hard rerun, which is how the per-piece change watchers know to stop theirrequestAnimationFrameloop. - View handles:
snp_outer,plot_area(the plot<img>lives here),stdout_stderr,hover_regions_container+hover_regions_svg()/set_hover_regions_html(),plot_widgets,sidebar_el,layers_panel,properties_el, andcommand_shortcuts(the keymap).
Per-cell persistent storage (get_persistent_item / set_persistent_item, utils/misc.ts). A cell re-run rebuilds the entire UI DOM, so a little UI state has to be persisted as strings, keyed per cell, with two backends: on Notebook v6 the cell's .output element dataset (classic cells can lack stable ids); on JupyterLab sessionStorage under cell-<sharedModel.id>-snp-<key> (the labextension reads the same keys). Only three keys exist:
fig_idx— which stashed figure this cell renders; read into thesnp.show_ui(...)postfix on every redraw.selected_calls— the selected layers (keyed bycall_id); written bysave_selected_layers, restored byload_selected_layersso selection survives a redraw.new_calls— ids of just-inserted calls, somain.tscan auto-select/focus them after the next rebuild, then clears the key.
The kernel request/response contract (kernel_execute, code_sync.ts). Every kernel path funnels through kernel_execute(code, postfix, on_iopub_output, on_shell_reply, state), which papers over v6 vs. Lab:
- Notebook v6:
cell.kernel.execute(code + postfix, callbacks, opts)withcallbacks.iopub.output/callbacks.shell.replyset to the two handlers.optscarriesdoesnt_need_snp_show_ui: true(so the extension won't append a secondshow_ui) andcell(which the nbextension strips back off before anything reaches Python, to avoid "message too large" crashes). - JupyterLab:
kernel.requestExecute({ code: code + postfix, … }, false, metadata); the returnedfuture's.onIOPub/.onReplyget the same two handlers, withdoesnt_need_snp_show_uiriding along inmetadata. Both backends usestore_history: false,stop_on_error: true.
Replies come back as CellMessages (typed in utils/types.ts), dispatched on msg.header.msg_type:
- the rendered plot is an
execute_resultwhosecontent.data["image/png"]is base64 — the frontend just setsimg.src = "data:image/png;base64," + …; hover-region refreshes additionally readcontent.data["image/svg+xml"]. status/execute_input(Lab-only chatter) are swallowed.- everything else goes to
handle_error_or_stdout_stderr:error→content.evalue,stream→content.text, appended tostate.stdout_stderr, with line numbers shifted back byprovenance_is_off_by_n_linesso they point at the user's code, not the lines Plottery prepended.
Two "run one request at a time, always finish on the latest code" guards live here and are assumed by every redraw:
kernel_is_busy(state)trustsstate.outstanding_kernel_request_timefirst (a request < 500 ms old counts as in-flight, because the kernel's own busy flag lags), then falls back to the real kernel status (v6_pending_messages/iopub_done; Labkernel.status !== 'idle').- the reply handler clears
outstanding_kernel_request_time, then re-runs immediately if the cell text changed while the kernel was busy, and otherwise settles (e.g. intorefresh_hover_regions).
The object model (layer → call_view → arg_view → widget, plus the marks). The sidebar mirrors the cell's code as a tree of plain objects (types.ts, layer_panel/layer_panel.ts):
state.layers_panel.layers: Layer[]— oneLayerper top-level typed AST statement (and per parseable comment). ALayeris{ el, mark, target_mark, calls_with_args, call_views }. Itsmarkspans the whole statement (used for line-highlighting, show/hide, and as the drag source range);target_markspans just the displayed header — identical tomarkfor a one-line statement, but for afor/defit covers the header so a drop "below" it lands inside the block. Block layers recurse: afor/deflayer is immediately followed in the flatlayersarray by its body's sub-layers (with a deeperindent-Nclass).Layer.call_views: CallView[]— oneCallViewper matplotlib (or user-defined) call on that statement's line. ACallViewis{ els: { el, header_el, name_el, properties_el }, mark, arguments }; itsmarkspans that call's source text and is exactly what the call's change-watcher writes into (add_sync_code_on_change_watcher(() => call_to_code(call_view), [mark], state), set up increate_call_view).CallView.arguments: { arg: Arg, view: ArgView }[]— oneArgView({ el, widget, disabled, positional }) per argument row.ArgView.widget: Widget— the actual typed control, and the only object that must implementto_code()/set_code().
So one editable argument is reached as layer → call_views[i] → arguments[j].view → widget. marks live at the layer level (whole node + header) and the call level (the call's text); standalone editable bits — for-headers, def names (and all their use-sites), def arg-lists — get their own marks + watchers in layers_from_typed_node.
Why call_views is plural. layers_from_typed_node keeps every call whose call.pos.line equals the statement's line, filtered to matplotlib / user-defined ones (the rest are logged and dropped), so a line like ax.plot(x); ax.set_title("t") produces one layer with two call views. In practice it's almost always 0 or 1 (hence the // Layers in practice only have zero or one calls note in attach_events_to_hover_regions). All of a layer's call views are rendered into the layer row; selecting the layer only lifts call_views[0] (or a specific one passed via select_call_view, e.g. clicking that call's on-plot widget) into the Properties panel — the other calls' args stay visible in the layer row, they are not dropped.
DOM ownership across a redraw. A call's argument rows are a single DOM node (call_view.els.properties_el) that is moved, never copied: set_properties_panel_on does args_el.remove(); properties_el.append(args_el) to lift it next to the plot, and clear_properties_panel (run on every (de)selection) moves the same node back under its call view's el in the layer row. Because redraw_cell / refresh_hover_regions only swap the <img> / SVG (see below), this entire object tree — layers, call views, marks, watchers, and the transplanted panel — survives those paths untouched. Only hard_rerun tears it down: it clears all marks, re-executes, and attach_snp rebuilds a brand-new State and tree (the old state.is_in_dom() flips to false). So a transplant can never dangle — between redraws it's moved home by clear_properties_panel, and a structural change rebuilds everything from scratch (selection is then restored from the persisted selected_calls / new_calls).
The shared "edit → redraw" mechanism. Most GUI edits never call the kernel directly. Instead, each editable piece of code (a call, an argument, a for-loop header, a function name) owns a CodeMirror mark — a tracked text range — plus a polling watcher created by add_sync_code_on_change_watcher(get_code, marks, state) (code_sync/code_sync.ts). On every animation frame it compares get_code() (e.g. call_to_code(call_view)) to its last value; on a change it calls sync_code_range, which writes the new text into the mark with cm.replaceRange(...) and then calls redraw_cell(state). So "change a widget" really means "change the text the widget generates" — the watcher notices and re-runs the cell. Widgets therefore only need a working to_code() / set_code().
The three kernel paths (all in code_sync.ts, all routed through kernel_execute, which handles both Notebook v6 cell.kernel.execute and JupyterLab kernel.requestExecute and sets doesnt_need_snp_show_ui so the extension doesn't append another show_ui):
redraw_cell— fast. Appendssnp.show_ui(..., snp_class=snp.SNPFigureOnly), then swaps the plot<img>with the returned PNG. Guards against overlapping requests (kernel_is_busy) and re-runs if the code changed again while the kernel was busy.refresh_hover_regions— appendssnp.show_ui(..., snp_class=snp.SNPFigureAndHoverRegions), replaces the SVG overlay, re-attaches drag handlers, and swaps in a higher-DPI PNG. Runs once a drag settles.hard_rerun— clears all CodeMirror marks and fully re-executes the cell, so Python rebuilds the entire UI from scratch via_repr_html_. Used whenever the code structure changes (new line, reorder, AI rewrite).
Entry: the click listener in add_listeners_and_checkbox_to_layer (layer_panel/layer_panel.ts) → select_layer(layer, state). No kernel round-trip; this is pure DOM/selection:
deselect_all_layers(state)clears the previous selection.- Adds the
selectedclass tolayer.el. set_properties_panel_on(layer.call_views[0], state)(properties_panel/properties_panel.ts) moves that call's argument-rows node (.snp-args, i.e.call_view.els.properties_el) out of the layer row and into the Properties panel next to the plot. (If selection arrived viaselect_call_view— e.g. clicking a specific call's on-plot widget — that call view is lifted instead ofcall_views[0]; see "The object model" above for why this is a move, not a copy, and how it survives redraws.)compute_selected_hover_regions(state)(hover-regions/hover_regions.ts) clears.selectedon the SVG, then re-adds it to every[data-call-id]region whose id matches a selected call — this is the on-plot highlight.highlight_lines_for_selected_layers(state)adds gutter/background line classes in CodeMirror over the layer's line range.save_selected_layers(state)persists the selectedcall_ids to per-cell storage so the selection survives the next cell re-run (restored byload_selected_layers).
Selection is keyed on call_id, so it's robust across redraws (and restored after a hard_rerun by load_selected_layers).
Selection is single, despite the plural names. select_layer calls deselect_all_layers first, so at most one layer is ever selected; the selection state is nothing more than the selected CSS class on layer.el (queried by is_layer_selected). The plural API — selected_layers(), deselect_all_layers, highlight_lines_for_selected_layers, the array written by save_selected_layers — is structural/forward-looking (selected_layers even carries a // Should only be 0 or 1 right now note); nothing currently adds to a selection rather than replacing it. That same select_layer is the single funnel reached from every selection gesture: the layer-row click here, an on-plot region click (a mouseup with no drag), a plot-widget click (via select_call_view), the cursor-tracking below, and the post-rerun restore (new_calls / load_selected_layers).
In CodeMirror 5 (classic Notebook) only, moving the text cursor into a layer's range also selects that layer: main.ts listens for cursorActivity, finds the innermost layer whose mark contains the cursor, and selects it (it bails while the user has an active text selection, to avoid fighting a highlight). The JupyterLab CM6 facade doesn't expose on / getCursor, so this is feature-detected and simply skipped there.
Layer rows are draggable; handlers live in layer_panel.ts:
dragstart→state.dragging_layers = [layer].dragovercomputes the drop side (top vs. bottom half of the row) and a target indent level from the mouse X (Math.floor(mouseX / 29)), showing feedback viadragover-top/dragover-bottom+drag-indent-Nclasses.dropdoes the actual code move:- Reads the chosen indent from the
drag-indent-Nclass and re-indents the dragged layers' source text to match. - Computes the insertion point from the target layer's
target_mark(distinct frommark:target_markspans the displayed header, so dropping just below e.g. aforheader lands inside the loop body). - Temporarily sets every mark's
inclusiveLeft = false(so marks don't swallow the insertion), inserts withcm.replaceRange, then deletes each source layer's original lines. clean_up_passes(cm)adds/removespassstatements so emptied or newly-filled blocks stay valid Python.hard_rerun(state)— structure changed, so rerun the cell to do a full UI rebuild.
- Reads the chosen indent from the
replace_all_preserving_marks (same file) is the regex-replace helper used here and by the visibility checkbox; it edits text without clobbering existing marks. The per-layer show/hide checkbox uses it to comment/uncomment the layer's lines, then calls redraw_cell (or hard_rerun when re-enabling a plain-text layer that needs its full UI rebuilt).
On-plot buttons are drawn by place_add_method_buttons_on_plot (hover-regions/hover_regions.ts): it groups state.methods_with_code by each method's show_on artist id, skips any method already called its max_calls times, and overlays a button on each matching hover region — a single button (e.g. ax.set_title) when one method applies, or a dropdown menu (labeled with the artist name) when several do. Placement uses place_over_shape / place_centered_over_shape + reposition_to_avoid_overlap.
How show_on finds the right shape — it's matched by Python id(). When Python renders the hover-region SVG, region2_to_svg_g (snp.py) stamps each region <g> with data-artist-id="{id(artist)}" (plus data-artist-name). When it builds the method list in _repr_html_, it walks the hard-coded method_associations(artist) tables (axes_method_associations, fig_method_associations, …); each entry is (children_paths, method_name, max_calls, docstring_first_line), and Python sets show_on = [id(eval("artist" + path)) for path in children_paths] — e.g. an Axes-wide method like bar uses path '.patch' so it shows on ax.patch, while set_title uses '.title' → ax.title. max_calls is the per-method cap from those same tables (1 for set_title, float('inf') for bar). The frontend then groups state.methods_with_code by method.method_info.show_on.at(-1) (the leaf id of the path), drops any whose state.calls.count(...) >= max_calls, and places the button(s) on every region whose data-artist-id equals that id. So the id flows Python id(artist) → data-artist-id and show_on, matched by plain equality in the browser; that's also why a hard_rerun (which re-id()s objects) is required after adding a call, not a fast redraw.
Clicking → add_method_call(method, state) (sidebar/methods/method.ts):
add_line_of_code(method.code, state)inserts the generated call (e.g.ax.set_title("")) into the cell immediately aboveplt.show(), matching its indentation.- Records the new call's id in the
new_callsper-cell store so it gets auto-selected/focused after the rebuild. hard_rerun(state).
The method.code string was precomputed by method_info_to_method_with_args: from the mypy callee type it emits the required positional + keyword args (with default code) and prefixes name = when the return type isn't None (naming ax / fig / etc. via name_for_ret_type). The same method list also populates the "+ Add Layer" menu in the Layers panel.
Argument rows are built by create_arg_view (sidebar/arg/arg.ts) and live in the Properties panel for the selected call. The value control is always a dropdown (make_widget_for_code_and_type, widgets/widget.ts) wrapping the type-derived widget(s).
- (a) Toggle on/off. Clicking the arg name (
prefixElmousedown) callsdisable_arg_view(addssnp-arg-disabled); clicking an already-disabled row callsenable_arg_view. Effect on code:call_to_code(call/call.ts) skips disabled args (view.disabled ? null : arg_view_to_code(...)), so a disabled optional kwarg simply drops out of the call. The watcher sees the changedcall_to_code()and redraws. (Required args can't be disabled.) - (b) Dropdown of suggestions. Clicking the selected value toggles the drawer open (
.expanded) (widgets/dropdown/dropdown.ts). Items come frommake_widget_for_code_and_type: widgets derived from the mypy type, the parameter's default, andtype_compatible_code_snippets(user variables/expressions whose type matches this arg — computed in Python asuser_typed_snippets/user_iterables), plus an arbitrary-code fallback. Hovering an item setsdropdown.previewing_code, whichto_code()returns, so the liveredraw_cellshows a preview of that choice; clicking it callsselect_dropdown_item, which clones the chosen widget into the selected-item slot (keeping the previous value available in the drawer). - (c) Editing the arg code. The selected widget is usually
arbitrary_code(acontentEditablebox,widgets/arbitrary_code/arbitrary_code.ts) orcode_and_control(widgets/code_and_control/code_and_control.ts), which pairs that box with a GUI control (slider / toggle / color) kept in sync by aMutationObserver— editing the text updates the control (or drops it if the text no longer matches the type), and moving the control rewrites the text. Either way the newto_code()flows through the same call watcher →sync_code_range→redraw_cell.
How the (b) suggestions are computed (all in Python). The user-variable items come from two structures Python builds in SNP.__init__ and serializes to the frontend:
user_typed_snippets— a{ code_string: mypy_type }map of expressions that could fill an argument. It starts from the user's variables in scope (names that actually appear in the user's code viaget_user_nameset, minus trivial/callable ones), each typed by mypy (tree.names[name].type, or, inside a function where mypy doesn't expose it, a builtins fallback inferred fromtype(value).__name__). It then goes one level into concrete containers using the live values:d[key],d.keys()/.values()/.items()for dicts;xs[i]per element andnp.arange(len(xs))for lists/tuples.user_iterables— the subset whose type is an iterable:is_subtype(t, Iterable[Any])and notis_subtype(t, str)(so strings aren't offered to loop over), plus dynamic fallbacks for 1-D numpy arrays and lists. This feeds the for-loop iterable widget and the "+ Add Layer → For-loop over …" menu.
Then, per argument, serialize_type computes type_compatible_code_snippets_by_arg_i = [name for name, t in user_typed_snippets.items() if is_subtype(t, arg_type)] (and the analogous …_by_i for **kwargs TypedDicts). So "type compatible" means a mypy subtype — mypy.subtypes.is_subtype, wrapped in snp.py's is_subtype (line ~1709) to resolve type aliases first and to treat a bare Any snippet as not matching a non-Any parameter (otherwise untyped values would flood every dropdown). It's subtyping, not equality: a list[int] variable is therefore offered for an Iterable / Sequence / ArrayLike parameter, and matplotlib's ArrayLike alias resolves so numpy arrays match wherever it's accepted.
Wiring is in attach_events_to_hover_regions (hover-regions/hover_regions.ts). For each call's hover regions it pulls candidate drag handlers from call/call.ts (perhaps_get_drag_xy_handler, _x_, _y_, and the four edge handlers), then:
- a bare
mousemove(no button down) picks which handler applies from proximity to an edge vs. the center, and sets the cursor (col-resize/row-resize/move/ …); mousedownrecords the start point and the figure's bounding rect;documentmousemovewhile pressed computes the pixel delta, calls the active handler, and hides the overlay during the drag;mouseup: if the mouse didn't move, it's a click →select_layer; if it moved →refresh_hover_regions(regenerate the SVG since shapes moved).
The handlers (drag_handler_for_arg_view in call/call.ts) translate pixels → code: they parse the target arg's current code into stuff + number, convert the pixel delta to axis units using boundses — the data-fig-px-bounds / data-axes-px-bounds / data-axes-unit-bounds / data-region-px-bounds attributes Python wrote onto each SVG region — compute the new number, and call view.widget.set_code(...) (plus enable_arg_view, so dragging turns the arg on if it was off). Special cases: legend loc (an xy fraction of the axes), set_title y (axes-size coords), and bar width / height edges (reversed). Each set_code is picked up by the call watcher → fast redraw_cell (DPI is dropped mid-drag for responsiveness); refresh_hover_regions runs once on release.
The AI panel (ai_panel/ai_panel.ts) is an input box, shown only when an API key is present. Pressing Enter → submit_prompt:
- Disables the input and shows a spinner.
prompt_for_llmbuilds the prompt from every code cell up to and including the current one, delimited with### Cell N ###markers, plus fixed instructions (how to add subplots, axes type annotations), ending with "Return only the new code of Cell N".prompt_llm(utils/llm.ts) calls OpenAI. On success: strip ``` fences and the cell marker,cm.setValue(newCode)to replace the whole cell, `hard_rerun(state)`, then diff old vs. new lines and tag changed lines with `snp-ai-line-changed` (gutter + background). The cursor is moved to the end so a single Cmd/Ctrl-Z undoes the whole AI edit. On error/timeout it surfaces "Oops there was an error." and re-enables the input.
attach_ai_line_highlight_clearing_handlers clears those change highlights the next time the cell is run manually.