Skip to content

feat(2.0): pane handles — named panes, whole-render state, pane-scoped events & export [D145]–[D150] - #6

Merged
jawjay merged 7 commits into
mainfrom
feat/pane-handles
Aug 5, 2026
Merged

feat(2.0): pane handles — named panes, whole-render state, pane-scoped events & export [D145]–[D150]#6
jawjay merged 7 commits into
mainfrom
feat/pane-handles

Conversation

@jawjay

@jawjay jawjay commented Aug 5, 2026

Copy link
Copy Markdown
Owner

What this is

The downstream-use half of the plot-organization arc: every surface of a render (grid cell, tab, splitter pane — or the whole plot) becomes an addressable pane with a stable label, and that label carries through state capture/restore, event scoping, the native escape hatch, and export. Design docs: design/layout-panes-plan.md (review + [D145]–[D151]) and design/pane-handles.md (detailed design; §5 status). One commit per step S1–S5.

lay = qv.Layout.grid({"price": price_plot, "volume": volume_plot})
view = qv.View(lay)

view.pane("price").set_range(x=(0, 10))          # programmatic pan/zoom
view.on(qv.RangeEvent, on_zoom, pane="price")     # pane-scoped events
view.pane("price").export("png", "price.png")     # per-pane export
view.set_root(view.root.with_pane("volume", new)) # declarative pane swap

Fixes (S1, bug tier)

  • Multi-pane view state was silently lost — every backend captured only pane 0 (_plots[0] / _surfaces[0]); composites captured nothing. Zoom pane 2, switch theme/backend → snap back to autorange. Now capture_state() returns a LayoutState covering every pane, restored by label.
  • Nested homogeneous grids crashed (AttributeError: 'Layout' has no attribute 'lower') — now routed through the LayoutHost.

Architecture

  • State ownership inverted: backends implement a _panes() factory (one PaneHandle facade per surface); whole-render capture/restore is written once in core on top of it. ViewState unchanged as the per-surface record; LayoutState adds first-pane passthroughs so single-surface reads keep working.
  • PaneHandle — the "Axes of qtviz" ([D147]): interaction-side verbs only (set_range/autorange/select/capture/restore/.native/.elements/.export). Describe-side config stays on the node (.opts(), with_pane + set_root) — one-way data flow preserved. Facades are owned by their render: a pane kept across a rebuild goes dead (alive) and raises the new qtviz.errors.DisposedError.
  • Pane identity is a core concept ([D145]): Layout.labels (value-hashed) + flat_pane_labels() as the single source of truth — shared by backends, the composite host, state keys, and event stamping.
  • Event stamping layer ([D149]): Event.pane (kw-only), a per-surface PaneBus proxy in pg/mpl (element emit sites inherit pane identity with zero signature changes), and a local→flat label shim on the composite bus — third-party single-surface backends get pane-correct events with no edits ([D125] holds).
  • Host routing: a grid containing a Layout child now hosts per-pane; can_host("grid") means a flat grid (all the cell renderers ever supported).

Creation-side sugar ([D145]/[D148])

Mapping children (Layout.grid({"price": p, ...}), tabs/splitter too), retained mosaic labels + the subplot_mosaic list form with multi-char labels, explicit cells= with mosaic-grade overlap validation, layout[label], Layout.with_pane.

Behavior changes (CHANGELOG'd)

  • capture_state() returns LayoutState (was ViewState); restore_state accepts both (bare ViewState = first-pane shorthand).
  • RangeEvent/TapEvent.source_id is the pane label (was a per-render uuid — never exposed, nothing could depend on it).

FROZEN_2_0 is untouched — PaneHandle/LayoutState follow the [D135] return-type convention; docs get a Panes section in api.md.

Testing

+53 tests across test_pane_state.py / test_pane_labels.py / test_pane_handle.py / test_pane_events.py (tier-1 core + tier-2 parametrized over pyqtgraph/matplotlib, composite/splitter/nested cases; webengine display-gated as usual). Full suite 1037 passed; ruff/mypy/mkdocs --strict clean. Both export approaches (pg ImageExporter(plotItem), mpl tightbbox crop) were spiked before landing.

Not in this PR

The sharing track — [D146] link_x="col"/"row" + mixed-pane honor-or-warn, then the [D151] cross-backend linking gate (event-loop/throttle risk; own go/no-go).

🤖 Generated with Claude Code

jawjay added 7 commits August 5, 2026 12:28
…ne protocol

capture_state now covers every pane (was: pane 0 only on pg/mpl, nothing on
composite) and restore matches by pane label. RenderHandle grows panes() —
one PaneHandle facade per surface (PgPane / MplPane / _WebPane; composite
flattens children) — and the base class derives whole-render capture/restore
from it, written once in core. ViewState stays the per-surface record;
restore_state(ViewState) remains a first-pane shorthand.

Also fixes the nested-grid crash: a grid containing a Layout child now
routes to the LayoutHost (backend cell renderers take Element/Overlay only).

Design: design/pane-handles.md (S1) + design/layout-panes-plan.md.
…aic, grid(mapping), with_pane

Layout children can be named: mapping children (grid/tabs/splitter; tab
captions default to pane labels), retained mosaic labels incl. the
subplot_mosaic list form with multi-char labels, or explicit labels=.
layout[label] looks up (nested too); with_pane swaps immutably;
Layout.grid(cells=) places panes programmatically with mosaic-grade
overlap/span validation. flat_pane_labels() is the one source of pane
identity — backends and the composite host now key panes (and therefore
LayoutState capture/restore) by the given labels, so state survives
root swaps that reorder panes.

Design: design/pane-handles.md (S2).
…tviz'

Public downstream surface for individual panes: set_range/autorange/select/
capture/restore per surface, plus .native (the [D53] escape valve per pane)
and .elements. Facades wrap the current render and go dead with it —
pane.alive guards, DisposedError (new, qtviz.errors) on use-after-rebuild
instead of touching freed widgets. view.root pairs with Layout.with_pane
for the declarative per-pane update. Docs: api.md pane section (PaneHandle/
LayoutState as [D135] return types, not __all__).

Design: design/pane-handles.md (S3).
…), label shim

Every event carries pane (kw-only, None-defaulted) — stamped by a per-surface
PaneBus proxy in pg/mpl (so every element emit site inherits it with no
signature changes), at the handle in webengine, and mapped local→flat by the
composite host's delivery shim for hosted/third-party children.
RangeEvent/TapEvent source_id becomes the pane label (was a per-render uuid —
never exposed, nothing could depend on it). view.on grows pane=, composable
with source=.

Design: design/pane-handles.md (S4).
pyqtgraph: ImageExporter on the PlotItem subtree (png, one cell of a
shared scene — spiked). matplotlib: figure cropped to the axes' tight bbox
(png/svg/pdf, dpi/transparent honored; geometric-crop caveat documented).
webengine: delegates to the per-figure export. Dead panes raise
DisposedError; formats follow each backend's export capabilities.

Design: design/pane-handles.md (S5).
…ow + cross-backend linking

[D146]: link_x/link_y widen to bool | 'col' | 'row'. Groups come from the
same cells that decide grid shape (core link_groups, union-find over spans —
a spanning pane merges every column/row it covers, the subplot_mosaic rule).
pyqtgraph links natively per group (setXLink to the group leader);
matplotlib shares axes per group (sharex/sharey at add_subplot). col/row on
a non-grid kind raises at construction.

[D151]: linking now crosses host panes (mixed backends, splitter/tabs,
nested grids) — previously warned-and-ignored. A _LinkController on the
composite handle propagates RangeEvents within link groups via
pane.set_range, echo-guarded by a reentrancy flag (sync emits) and a value
guard (async webengine round-trips converge instead of ping-ponging).
Nested-layout panes are excluded from cross-pane groups with a warning; the
host honors link_x/link_y (_HOST_LAYOUT_HONORED).

Design: design/pane-handles.md status note; layout-panes-plan.md [D146]/[D151].
@jawjay

jawjay commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Added the sharing track (34af96d) — the arc's final piece:

  • [D146] link_x/link_y now accept "col"/"row" alongside bools. Groups derive from the grid cells (spanning panes merge groups transitively — the subplot_mosaic rule), applied natively per group: pg setXLink to the group leader, mpl sharex/sharey. "col"/"row" on a non-grid kind raises at construction.
  • [D151] Linking now crosses host panes (mixed backends, splitter/tabs, nested grids) — previously warned-and-ignored. A _LinkController on the composite handle propagates RangeEvents within groups via pane.set_range, echo-guarded by a reentrancy flag (synchronous emits) plus a value guard (async webengine round-trips converge instead of ping-ponging). Nested-layout panes are excluded from cross-pane groups with a warning; the host honors link_x/link_y.

+12 tests (test_pane_linking.py + a flipped host-honesty contract test). Suite at 1048 green; ruff/mypy/mkdocs clean. This completes [D145]–[D151].

@jawjay
jawjay merged commit b4bf83a into main Aug 5, 2026
2 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant