Model explorer

A model explorer for Jupyter: tree navigator beside a diagram pane.

explore() builds the widget:

import longeron
from longeron.widgets import explore

model = longeron.load("examples/deepscout")
explore(model)

The LEFT pane is ModelTree – a small self-contained anywidget tree over the model’s OWNING structure (packages, definitions, usages, nested members; node ids are qualified names). Rows carry a kind badge (part def / state / requirement …) colored by family, a dim : Type suffix for typed usages, disclosure triangles with lazy child rendering (only expanded rows reach the DOM, so the biggest shipped example stays snappy), roving-focus keyboard navigation (arrows move focus, Enter/Space selects), and the toolbar search idiom from longeron.toolbar: a live substring filter over names and qualified names with a matches/total count that prunes the tree to matches plus their ancestors. RELATIONSHIPS are first-class rows too: connections, bindings, flows, satisfies, allocations, interfaces, dependencies, imports, exposes, element filters and aliases appear under the element that OWNS them (kind='relationship' at the tree seam) with a dim italic label in the satisfy idiom (satisfy X, connect a to b, expose P::**) and a dashed kind chip; a compact toggle button beside the search box shows/hides them (the show_relationships trait, default ON; the matches/total counts respect it). Documentation and comments stay out of the tree.

The RIGHT pane shows the selected element through the diagram kind picked by a compact toggle switcher that only offers the APPLICABLE kinds: structure for everything ( structure_diagram() scoped to the nearest owning package, so the element appears among its siblings and relationship edges), state / action for state and action definitions/usages, and requirements – the structure view re-scoped to the containing package’s requirement definitions/usages, satisfy usages and their satisfying elements (see requirements_view()). Switching kinds preserves the selection; every diagram keeps its own toolbar (fit / center / routing / search).

Selection links BOTH ways and is echo-free by idempotence: selecting a tree row renders (or reuses – diagrams are cached per scope and kind) the applicable diagram and highlights the element through the diagram’s selection tool; clicking a diagram node ( on_select()) selects and reveals the element in the tree – ancestors expand, the row scrolls into view – WITHOUT rebuilding the diagram that was clicked. Every hop writes a trait only when the value actually changes, so a selection echo dies at its first fixpoint instead of ping-ponging. Relationships join the loop where they are DRAWN: a relationship rendered as a diagram EDGE (an anonymous connect / bind / flow / satisfy / allocate, a dependency, an alias) is selected through its edge’s synthetic transport id – the widget’s _lgn_rel_edges seam (longeron.diagrams.structure_diagram()) maps edge ids back to model elements, so a tree click highlights the edge and an edge click reveals the tree row. A relationship with no drawn edge (expose, import, filter; unresolvable ends) still selects in the tree, shows its OWNER’s diagram, and highlights the nearest drawn ancestor.

Composed strictly from public surfaces: the diagram constructors and on_select from longeron.diagrams, the model vocabulary, and the resolver. Needs the diagram toolchain (the vendored ipyelk) plus anywidget, like longeron.widgets.replay.

The tree pane sits behind a SMALL EXPLICIT SEAM – the TreeView protocol (set_nodes / selected / on_select / reveal / filter) over TreeNode dicts – so the engine can be swapped (say, for a react-arborist-based tree widget) without touching any explorer logic: pass any conforming engine as Explorer(model, tree=...). ModelTree is the built-in engine.

Layout is a second small seam: explore(model, layout=...) composes the SAME panes either inline (a plain HBox, works everywhere) or into a resizable JupyterLab split panel via ipylab (lab; the explorer extra); auto picks lab only when ipylab is installed and a Lab frontend is detected, else falls back inline.

Docking is a WELL-BEHAVED Lab citizen, twice over. First, the panel docks with mode="tab-after" by default – its own full-width main-area tab (not activated), so running a notebook never reshapes it; pass mode="split-right" (or any Lab dock mode) to opt into a split. Either way the panes FILL the panel: the header keeps its natural height and the diagram box flex-grows to every remaining pixel (the inline layout instead stays bounded at the height parameter – it lives in a notebook cell). Because a background tab renders hidden, the panel’s FIRST reveal triggers one diagram re-fit (the initial auto-fit aimed at a zero-sized viewport). Everything else about keeping the framing honest is the DIAGRAM WIDGET’S OWN job now: every widget built by longeron.diagrams carries a hidden fit sentinel inside its own DOM (see longeron.toolbar.AutoFitTool), so a newly built diagram is fitted the moment its view actually renders (the first-layout auto-fit can be dropped mid view construction: wide diagrams used to come up unfitted, overflowing the pane), and a pane resize re-fits the visible diagram unless the user has panned or zoomed since the last auto-fit (their viewport is theirs). The explorer adds only what the sentinel cannot know: a cached diagram re-entering the box on a kind switch is re-fitted against its current rendered size (built widgets stay in the box as persistent, display-toggled children, so their live views hear the fit). Second, docking is IDEMPOTENT per model: re-running the cell – or restarting the kernel and running all cells – REPLACES the model’s panel instead of stacking a new one. Two mechanisms cooperate, keyed by _dock_key() (a slug of the model’s display name, so explorers over different models coexist):

  • same kernel: a module-level registry (_DOCKED_PANELS) closes the previous ipylab panel before adding the new one;

  • fresh kernel (the restart case, where the old panel’s kernel is dead and no Python-side handle can reach it): every docked panel’s tab carries its identity as data-lgxkey / data-lgxstamp attributes (lumino renders title.dataset onto the tab via setAttribute, which lowercases keys – hence the flat spelling; ipylab syncs panel.title.dataset), and a tiny hidden anywidget (_DockSweeper) rides inside each panel. When the NEW panel’s sweeper renders, it finds any main-area tab with the same key and an OLDER stamp and closes it through lumino’s own close path (synthetic pointer events on the tab’s close icon – exactly what a user click does), so the dock layout stays consistent; a MutationObserver re-sweeps whenever the dock’s tabs change, so panels that finish attaching late are still reconciled. ipylab 1.1 itself exposes no dispose-by-id surface (its Shell model only supports add/expandLeft/expandRight), which is why the orphan case is handled browser-side.

longeron.widgets.explorer.DIAGRAM_KINDS: tuple[Literal['structure', 'state', 'action', 'requirements'], ...] = ('structure', 'state', 'action', 'requirements')

every diagram kind the switcher can offer, in display order (the same vocabulary a view persists: longeron.views.ViewKind; derived from it, so the two cannot drift)

class longeron.widgets.explorer.Explorer(model, *, tree=None, layout='auto', mode='tab-after', structure_scope='package', height='600px')[source]

Bases: HBox

Tree navigator (left) + applicable-kind diagram pane (right).

Build one with explore(). The public knobs:

  • tree – the tree engine, any TreeView (default ModelTree; its selected / query traits are the headless automation surface);

  • kind_switcher – the toggle buttons offering the applicable diagram kinds for the current selection;

  • diagram – the currently displayed diagram widget;

  • select() – programmatic selection by qualified name or element;

  • save_view() (and the header’s save_button) – save the current diagram as a SysML v2 view usage plus sidecar entry (longeron.views);

  • element / kind – the current selection and view;

  • layout_strategy – the resolved layout ("inline" or "lab"; see explore());

  • dock_mode – how the lab layout docks into the shell (default "tab-after"; see explore()).

The panes are built ONCE; the layout strategy only composes them: inline puts them side by side in this HBox (28%/72%), lab docks them as a resizable JupyterLab split panel (lab_panel) and leaves a small placeholder in the cell output.

Diagrams are cached per (scope, kind): re-selecting inside the same package reuses the SAME widget (the browser keeps its layout), so a selection change costs one trait write, not a diagram rebuild.

property element: Element | None

The currently selected model element (None before a selection).

property kind: Literal['structure', 'state', 'action', 'requirements']

The active diagram kind (one of DIAGRAM_KINDS).

property diagram: Any

The diagram widget currently SHOWN in the right pane.

Built widgets stay in the diagram box as persistent children – their browser views must survive re-shows (see _show()) – so the shown one is the child whose display is not none.

select(target)[source]

Select by tree node id / qualified name, or by element.

Return type:

None

save_view(name=None, *, sidecar=None)[source]

Save the current pane’s diagram as a SysML v2 view usage.

The chrome affordance behind the header’s save button. The currently shown diagram widget carries its own root and kind (the longeron.views seam), so this is a thin capture: longeron.views.save_view() appends the typed view usage – with a recursive expose of the shown scope and the standard render reference – to the scope’s owning package, and the live presentation (direction, routing, collapse state) lands in the sidecar entry. sidecar is a path, None to auto-discover the workspace sidecar next to the model’s sources (skipped silently for in-memory models), or False to skip the sidecar write. The model text itself is NOT rewritten here: export the model (longeron.save() / to_sysml) or push it over the API to materialize the change. Returns the view usage element; the tree refreshes so the saved view appears.

Return type:

Element

refresh()[source]

Re-read the model after an edit: rebuild the tree, re-render.

The app calls this on its launched explorers after a longeron.edit rename or value edit (the tree’s labels, qualified-name node ids, and the diagrams’ drawn labels may all be stale). The tree payload rebuilds wholesale (_tree_data() + set_nodes – the cheapest correct hook); the resolver is rebuilt too, because renames invalidate its resolution caches. Every CACHED diagram is dropped and the current selection re-rendered through the normal _show path; the previously built widgets stay in the diagram box as hidden children – removing them would zombie their live browser views (see _show()) – but the cache drop guarantees a stale diagram is never shown again. Selection is preserved by element IDENTITY (its qualified name may have changed); an element that left the tree falls back to the root.

Return type:

None

longeron.widgets.explorer.LayoutChoice

the layout strategies explore() (and the app’s open) accept: detect the frontend, force the side-by-side HBox, or force the JupyterLab dock (which requires ipylab)

alias of Literal[‘auto’, ‘inline’, ‘lab’]

class longeron.widgets.explorer.ModelTree(nodes=(), **kwargs)[source]

Bases: AnyWidget

The built-in TreeView engine (a self-contained anywidget).

Disclosure rows, kind badges, filter, keyboard navigation. Pure presentation over TreeNode dicts – it holds no model references, only ids (qualified names). selected is the two-way selection trait (at most one id); setting it from Python reveals the node in the browser (ancestors expand, the row scrolls into view). query live-filters the tree exactly like the diagram toolbar’s search (case-insensitive substring over label and qualified name); match_count / total_count mirror its matches/total counter and are computed kernel-side too, so headless tests see the same numbers the browser shows. show_relationships (default True) is the tree-toolbar toggle’s trait: False hides every kind='relationship' row – they drop out of the rendered tree AND out of both counts – so kernels and notebooks can drive the toggle programmatically.

selected

selected node ids; [] = no selection

query

live filter text; empty shows the whole tree

show_relationships

whether relationship rows are shown (and counted)

match_count

how many visible nodes match the query

total_count

how many nodes the tree shows

set_nodes(nodes)[source]

Replace the tree’s contents (the browser re-indexes and re-renders).

Return type:

None

on_select(callback)[source]

Call callback with the selected ids on every selection change.

Return type:

None

reveal(node_id)[source]

Expand the node’s ancestors and scroll it into view (browser-side).

Return type:

None

filter(text)[source]

Live-filter the tree; returns the kernel-side match count.

Return type:

int

longeron.widgets.explorer.ResolvedLayout

what "auto" resolves to: the concrete strategy a built widget runs (Explorer.layout_strategy; the inspector accepts the same pair)

alias of Literal[‘inline’, ‘lab’]

longeron.widgets.explorer.StructureScope

what the structure view scopes to: the selection’s owning package, or the selected element itself

alias of Literal[‘package’, ‘element’]

class longeron.widgets.explorer.TreeNode[source]

Bases: _TreeNodeBase

One tree node, as plain data.

children nests the owned members (engines that lazy-load may key off has_children instead of materializing them up front); suffix is an optional dim tail (: Type for typed usages). kind value relationship marks relationship rows (the built-in engine dims them and offers a show/hide toggle; an engine that does not style them may treat the value like any other family). Engines must ignore keys they do not understand.

class longeron.widgets.explorer.TreeView(*args, **kwargs)[source]

Bases: Protocol

The seam between the explorer and its tree pane.

The explorer drives the tree ONLY through this surface, so the engine can be swapped – e.g. for a react-arborist-based widget – without touching any explorer logic. An engine that is also an ipywidget joins the explorer’s layout; a headless engine (like the test suite’s stub) simply is not displayed.

selected holds the selected node ids (at most one for a single-selection engine) and must be plain-assignable; assignment from the explorer MAY re-enter on_select() callbacks – the explorer is idempotent against that echo.

set_nodes(nodes)[source]

Replace the tree’s contents with nodes (nested).

Return type:

None

on_select(callback)[source]

Call callback with the selected ids on every selection.

Return type:

None

reveal(node_id)[source]

Expand the node’s ancestors and scroll it into view.

Return type:

None

filter(text)[source]

Live-filter the tree; returns how many nodes match.

Return type:

int

longeron.widgets.explorer.applicable_kinds(element)[source]

Which DIAGRAM_KINDS apply to element.

structure always applies; state / action apply to state and action definitions/usages (usages typed by a definition expand its submachine, exactly like state_diagram()); requirements applies when the nearest owning package (or the model root) contains requirement definitions/usages or satisfies.

Return type:

tuple[Literal['structure', 'state', 'action', 'requirements'], ...]

longeron.widgets.explorer.explore(model, *, tree=None, layout='auto', mode='tab-after', structure_scope='package', height='600px')[source]

Explore model: a tree navigator beside a diagram pane.

Keyword arguments reach Explorer (spelled out here so the vocabularies typecheck at the call site):

  • layout"auto" (the default: dock into JupyterLab when ipylab is installed and a Lab frontend is detected, else render inline), "inline" (a plain side-by-side HBox; works everywhere – nbclient, VS Code, docs), or "lab" (require the ipylab docking; raises MissingExtraError unless the explorer extra is installed);

  • mode – how the lab layout docks into the shell, passed straight through to JupyterLab ("tab-after", "tab-before", "split-right", "split-left", "split-top", "split-bottom", …). The default "tab-after" opens the explorer as its own full-width main-area tab WITHOUT stealing focus or width from the notebook; choose a split-* mode (or drag the tab) to see both at once. Ignored by the inline layout. Re-running the cell – or restarting the kernel and running all cells – REPLACES the model’s docked panel instead of accumulating copies (see the module docstring);

  • tree – a custom TreeView engine (default ModelTree);

  • structure_scope"package" (the default) scopes the structure view to the selection’s owning package so relationship edges to siblings stay visible; "element" scopes it to the selected namespace itself;

  • height – the explorer’s inline CSS height: the tree pane and the diagram pane both honor it (default "600px"; the diagram area takes the pane minus the kind-switcher header). The lab layout ignores it – the docked panel fills its tab, and the dock’s split handles own the sizing.

Return type:

Explorer

longeron.widgets.explorer.requirements_view(scope, *, resolver=None, **kwargs)[source]

The requirements landscape of scope as a structure diagram.

Collects the requirement definitions and usages under scope, the satisfy usages, and each satisfy’s satisfying element (its by target and satisfied requirements, resolved against the real model), then renders them through structure_diagram() under a synthetic requirements package. The collected elements are listed in the synthetic package WITHOUT re-parenting (their owner chains – and therefore their qualified names, the diagram node ids – stay exactly those of the real model), so the view is a pure read-only projection: satisfy keyword edges, reference subsetting into «requirement» boxes, and typing edges all draw from the same public structure view the explorer uses everywhere else.

Elements whose ancestor is already collected are skipped – they are drawn nested inside that ancestor’s box, and a second top-level node would duplicate their qualified-name id.

Keyword arguments pass through to structure_diagram() (direction, routing, membership, …) – the seam view restoration uses to re-apply persisted presentation.

Return type:

Any