Skip to content

Pop a cell's plot out into a floating window - #1152

Draft
SimonHeybrock wants to merge 8 commits into
mainfrom
cell-plot-popout
Draft

Pop a cell's plot out into a floating window#1152
SimonHeybrock wants to merge 8 commits into
mainfrom
cell-plot-popout

Conversation

@SimonHeybrock

@SimonHeybrock SimonHeybrock commented Jul 29, 2026

Copy link
Copy Markdown
Member

What

A tool in the cell titlebar opens that cell's plot in a draggable, resizable floating window. The cell is untouched: it keeps its plot, its toolbars and its place in the grid. The window renders the cell's own composition a second time rather than composing its own, so both views repaint from one layer Pipe and share one autoscale controller — the window is live rather than a snapshot, and turning an autoscale axis off in either view turns it off in both.

Why

A grid cell is often too small to read detail from, and the alternatives (resize the grid, open a modal) either disturb the layout or block the dashboard underneath. A floating window is also what makes a plot watchable while working elsewhere in the dashboard: a popped-out cell stays live when its grid is not the visible tab. That is the capability that justifies the machinery — an expand-in-place or a modal would be simpler but could not do it.

Cost, and why there is no cap on the number of pop-outs

Liveness follows what a window shows, not that it exists — the same rule a hidden tab already obeys. Minimizing or smallifying a pop-out puts its cell back to sleep, so popping out many cells and minimizing the windows costs nothing: what is not rendered is not computed. A cell rebuild (a rename, a job restart) carries the window along with its status, so a parked window stays parked and its cell asleep, rather than every rebuild popping the parked windows open. A cap would punish the case the feature exists for (comparing two detectors side by side) while leaving the actual cost unbounded, since minimized windows would still count against it. Liveness is also per cell rather than per grid, so the rest of a hidden grid still sleeps.

How it sits in the session reconciler (#1227)

Rebased onto the declarative reconciler. Pop-outs add no gate of their own to the session pass; they ride three seams that already exist, and nothing else in the pass knows they are there:

  • Policy. A cell behind a showing window joins SessionView.live_cell_ids, so desired_cells keeps a built widget for it and tracks its build inputs exactly as for a visible cell. Deciding this in the pure policy function is what keeps the rule reviewable and unit-testable with plain data; a minimized window contributes no cell, by the same rule that lets it sleep.
  • Tokens. Such a cell takes a viewer token like a visible one, so its layers keep computing while its tab is away.
  • Wake stamps. Frame generations become per grid — the visible one unioned with the grids behind live pop-outs — carried as one _PassStamps field. Every term used to be scoped to the visible grid, so a session parked on another tab would evaluate the predicate, find no work, and never run the pass feeding the window. Predicate and pass read one _live_generations, so they cannot disagree about what counts as live.

Flushes are per grid for the same reason the stamps are. A presenter holds pending data as soon as the ingestion thread builds its layer, before the grid's frame commits, so a single session-wide flush flag would let a frame for the pop-out's grid push the visible grid's half-built burst (and vice versa), staggering one burst's layers across repaints — the artifact the frame clock exists to prevent. Each grid's pipes flush only when that grid's own generation moved or it just became visible.

Working a window's controls moves no shared state, only what this session shows, so no predicate can see it. The manager reports those changes and the tab widget requests a full tick, exactly as it already does for tab switches and modal closes — otherwise a restored window sits frozen at whatever it showed when it was minimized. Opening needs no such call: the pop-out button is in a cell titlebar, so the cell is on the visible tab and already live.

A window must not outlive what it shows: removing the cell closes it, and disabling its grid closes it too, since the grid's layers lose their viewers and the window would float on frozen.

The pop-out tests drive the gated helper rather than the pass directly, so a predicate that misses a pop-out fails there instead of passing while the window freezes in the browser.

A cell's views are torn down together

Merging #1226 (sever pipe subscriptions on dispose) surfaced a real bug in this branch, and it is the sharpest thing to review here.

Plot.cleanup drops every weakly-held plot-refresh subscriber on the streams it touches, not only its own — the owner filter is defeated upstream (holoviews#6988). Panel runs that cleanup on any child removed from an ordinary layout. So closing a pop-out unsubscribed the grid cell's plot too: the cell went on showing data that looked current and never updated again, for the rest of the session. Silent, and not something a user would attribute to having closed a window.

There is no finer-grained sever available, so teardown is all-or-nothing per cell:

  • A CellWidget owns every pane rendering its plot — the grid cell's and any pop-out's — and severs them together on dispose.
  • PlotGridTabs._close_popout rebuilds the cell behind a window it closes, which is what puts a live plot back in the grid. Opening a window needs no such repair: an extra subscriber costs the existing ones nothing.
  • A rebuild closes the window, disposes the old widget, and only then reopens the window on the new one — the reopened pane must subscribe after the disposal has cleared the pipes.

Both halves are pinned by tests that fail if the rebuild is removed, and the invariant is written up in the widget rules rather than left in a commit message.

Notes for review

Three findings are worth the reviewer's attention because they cost real time and are now recorded in the automation rules. Panel's FloatPanel does not carry a window's size down to its content: the wrappers between jsPanel's content element and ours have no height, which both collapses a stretching child to a sliver and prevents a fixed-height child from ever following the window. Panel re-lays out only on jspanelresizestop, which a drag fires but maximize and normalize do not. And an invisible ReactiveHTML helper needs a public class name — a leading underscore makes Bokeh fail to resolve the model and takes the whole session down with it.

Browser-test viewports default to 1000 px tall, which hid a geometry bug that only appears below roughly 820 px; the geometry test parametrizes over 700 px for that reason.

Autoscale needed less than it did before: main had independently made tool installation per figure, so only the per-figure Fit bookkeeping remains here — sibling figures render from one pipe push, and a single pending flag would be consumed by whichever ran first, leaving the other unfitted.

Known gaps

A live window shows the plot alone: the freshness/lag pill stays in the cell titlebar, which a hidden tab does not render, so a stalled stream freezes a pop-out with no staleness cue. Recorded in the module docstring; a window-side freshness cue is future work.

ROI-request layers share one edit stream per presenter, so popping out an ROI cell drives a single BoxEdit/PolyDraw from two figures. This is untested and likely needs handling before the feature is relied on for ROI work.

Test plan

Automated coverage added with the feature:

  • unit tests for the window lifecycle, the shared composition and autoscale controller, cross-tab liveness, sleeping when minimized, and the wake gate (a frame for a popped-out grid wakes a session on another tab; one for a hidden grid alone, or behind a minimized window, does not);
  • unit tests for per-grid flushing (a frame for the pop-out's grid leaves the visible grid's mid-burst data pending, and each grid's frame flushes its own), a rebuild preserving a minimized window and its cell's sleep, closing the window when its grid is disabled, and cascade slots not being reused after a close;
  • unit tests that closing a window leaves the cell's plot subscribed and rebuilds the cell, and that a windowless cell is left alone (both fail without the sever repair);
  • policy tests in cell_plan_test.py that a hidden cell with a showing pop-out is materialized and that its neighbours on the same hidden grid are not;
  • browser tests that the window opens inside the viewport at both 700 px and 1000 px heights, stays live while another tab is shown, sleeps when minimized and wakes on restore, and that the plot tracks the window across maximize and normalize.

Manual checks for the tester:

  • Pop out several cells at once; each window's controls stay reachable as they cascade.
  • Drag and resize a window; the plot follows without leaving whitespace.
  • Open a pop-out on a laptop-height screen; the title bar and its buttons are reachable.
  • The popped-out toolbar's hover and save tools behave as they do in the cell.
  • Rename a cell that has a pop-out open; the window follows the rebuilt cell.
  • Minimize a pop-out, then rename its cell; the rebuilt window arrives minimized in the taskbar strip, not reopened.
  • Close a pop-out while its grid is visible; the cell behind it keeps updating.

🤖 Generated with Claude Code

SimonHeybrock and others added 8 commits July 29, 2026 10:20
A grid cell is often too small to read detail off a live plot, and the only
ways to get a bigger view today are to rearrange the grid or make the cell
span more of it -- both persistent edits to a layout shared with other
sessions, for what is a momentary need.

The new titlebar tool opens the cell's plot in a draggable, resizable
jsPanel window instead. The cell is left exactly as it was: the window is a
second view composed from the same layer DynamicMaps, so both repaint from
one Pipe and the pop-out is live rather than a snapshot. Windows are
per-session and non-persistent, and several can be open at once, so two
detectors can be compared side by side.

A FloatPanel rather than a pn.Modal: a modal would block the grid
underneath, allow only one at a time, and -- routed through the existing
modal container -- would suppress the poll loop's data flush.

Two consequences worth calling out:

- Composition now clones before applying opts. HoloViews `.opts()` mutates
  in place, so composing a second view of a single-layer cell would have
  overwritten the first view's hooks, silently handing the cell's autoscale
  toggles to the pop-out's controller.

- A cell with an open pop-out counts as live even when its grid is not the
  visible tab, so the window keeps updating while the user works elsewhere.
  With no pop-outs open the flush gate reduces to exactly its previous
  behaviour.
A pop-out is a view of a cell, so its autoscale toggles should be the
cell's: turning X-autoscale off to inspect a feature in the big window and
finding the small one still tracking the data is a surprise nobody asked
for. The window now renders the cell's own composition a second time rather
than composing its own, and the two views share one controller and one set
of Bokeh tool models -- a click on either toolbar moves both buttons.

Nothing about the plot needed disconnecting; what blocked sharing was that
the controller assumed a single figure in two places:

- Tool installation was guarded per controller, so only the first figure to
  render got buttons and the other's toolbar came up bare. Now guarded per
  figure, as the hover-suspend hook already does.

- A pending Fit was one flag, cleared by the first render that honoured it.
  Sibling figures repaint from a single pipe push, so the second would
  silently keep its stale range. Now tracked per figure.

Sharing the composition also removes the reason to clone before applying
opts, and the pop-out no longer owns a controller to dispose: it borrows a
view the cell widget owns.

The window's content gets an explicit height. jsPanel moves the window's DOM
out of the Bokeh tree, leaving a stretching child nothing to measure against,
which collapsed free-aspect plots to a sliver.
A pop-out keeps its cell computing while the user works in another tab,
which is the point of popping it out. Tying that to the window merely
existing let a user pin arbitrarily many cells live, across every grid,
by popping out plots and minimizing the windows -- paying for renders
nobody can see.

Liveness now follows what a window shows, the same rule a hidden tab
already obeys: minimized and smallified windows render no plot, so their
cells sleep. Restoring wakes them on the next pass. Existence still
governs everything the user would notice a window losing -- a minimized
pop-out survives a rebuild of the cell behind it.

This is why there is no cap on pop-out count: a cap would punish the case
the feature exists for (comparing detectors side by side) while leaving
the actual cost unbounded, whereas gating on what renders bounds it by
the screen it has to fit on.

The guard is end-to-end, not just unit-level: it rests on jsPanel
round-tripping the user's click back as a status change, so the browser
test minimizes and restores a real window. Reverting the gate fails both
that test and the unit tests.
The window was a fixed 820px tall, centred vertically. On any viewport
shorter than that -- most laptops -- the centred top goes negative and
the title bar lands above the fold, taking the close, minimize and
maximize buttons with it. The window then cannot be dismissed at all.

It now hangs from the top of the viewport with a small margin, so the
title bar is on screen whatever the height, and its height is a fraction
of the viewport rather than a pixel count, so the bottom edge and its
resize handle stay reachable too. Viewport units suit the content
wrapper for the same reason a pixel height did: they are explicit, so
the detached DOM jsPanel relocates the window into has nothing to
measure against and nothing to collapse. An aspect-locked plot can still
exceed the window and still scrolls.

jsPanel's maxSize looks like the natural fit but applies only to
interactive resizing, not to the size a panel opens at; contentSize via
``config`` takes precedence over what Panel derives from width/height.

Verifying this turned up a latent flake in the pop-out browser test.
A rebuild racing a click detaches the button between locating and
pressing it, and Playwright then reports a successful click that did
nothing -- roughly one run in four on a freshly loaded tab. ``click_until``
retries until the effect is observable, which a wait after a single
click cannot do.

The geometry test parametrizes over a 700px viewport because the default
test viewport is 1000px, above the threshold: at 1000px the old code
passes.
The plot did not track the window it lives in. Maximizing grew the
window around a plot that kept its old size, leaving whitespace, and
whether the plot filled the window when it opened at all was a coin
toss. Dragging the window's edge fixed it, which was the clue.

Two independent gaps in Panel's FloatPanel, both of which had to close:

The height chain from jsPanel's content element down to ours runs
through Panel's own template wrappers, and those have no height, so
nothing carries the window's size inward. A stretching child collapses
to a 66px sliver -- the sliver worked around earlier with a fixed height
was always this, seen from below -- while a fixed-height child survives
but can never follow the window. The earlier viewport-unit height was a
second instance: sized against the viewport, it could not track a window
that is not the viewport, which is exactly what maximize exposes.
``stylesheets=`` on the FloatPanel does not reach these wrappers, as
they are light DOM, so the rule has to be document-level.

Panel re-lays out on ``jspanelresizestop`` alone. A drag fires it;
maximize, normalize and smallify do not. Re-dispatching that event on
any status change hands the work to Panel's own handler instead of
reimplementing it. Dispatching a window resize does not work -- Bokeh
does not recompute the canvas from it.

Both are installed once per session by an invisible ReactiveHTML widget,
following ModalEscapeCloser. Its class name is public on purpose: a
leading underscore makes Bokeh fail to resolve the model and takes the
whole session down with it.
Resolves the conflict between this branch's per-grid flush gate and #1105's
event-driven wake ticks, and closes the follow-up the PR description flagged.

The pop-out's cross-tab liveness lived in the poll pass body, which #1105 now
runs only when `_has_pending_work` fires. Every term of that predicate was
scoped to the visible grid, so a session parked on another tab would evaluate
it, find nothing, and never reach the code feeding the window: the pop-out
would freeze while the tests kept passing. The gate now tracks frame
generations per grid -- the visible one unioned with the grids behind live
pop-outs -- and predicate and pass share one `_live_generations` so they cannot
disagree.

Working a window's controls changes what the session shows without moving any
shared state, so it is invisible to any predicate. The manager reports those
changes and `PlotGridTabs` requests a full tick, as it already does for tab
switches and modal closes; a restored window would otherwise sit frozen at
whatever it showed when minimized. Opening needs no such call: the pop-out
button is in a cell titlebar, so the cell is visible and already live.

Pop-out tests move to the gated helper, so a predicate that misses a pop-out
fails there instead of silently lagging in the browser.

Also ports main's aspect-derived pane sizing into the hoisted
`compose_cell_plot`, which replaced the `_build_plot_content` main edited.
- Flush plot data per grid rather than via one session-wide flag: a
  presenter holds pending data as soon as the ingestion thread builds
  its layer, before the grid's frame commits, so a frame for a
  pop-out's grid could push the visible grid's half-built burst
  (staggering one burst's layers across repaints, the artifact the
  frame clock exists to prevent), and vice versa.
- Carry a window's jsPanel status across a cell rebuild so a minimized
  pop-out stays minimized, and its cell asleep, instead of popping open
  on e.g. a job restart.
- Close a pop-out when its grid is disabled: the grid's layers lose
  their viewers, so the window would float on frozen with no cue.
- Allocate cascade slots from a monotone counter; counting open windows
  re-issued an occupied slot after a close, landing a new window
  exactly on an open one and hiding its close button.
- Document the pop-out staleness gap (no freshness pill in the window)
  and the per-grid wake amplification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rebuild the pop-out feature on the declarative session reconciler (#1227
phase 2), which landed on main after this branch was written.

Pop-out liveness now rides on the reconciler's three existing seams rather
than on gates threaded through the poll pass: a cell behind a showing window
enters SessionView.live_cell_ids (policy, in cell_plan), takes a viewer token
like a visible cell, and adds its grid to the per-grid frame generations the
wake stamps carry. The per-cell flush bookkeeping the old version needed is
gone -- the plan already says which cells this session renders.

Fixes a latent bug the merge exposed: closing a pop-out runs Panel's pane
cleanup, and holoviews#6988 makes that sever every plot on the cell's layer
pipes, the grid cell's included. The cell would have gone on showing data
that looked current but never updated again. A CellWidget now owns every pane
rendering its plot and severs them together, and PlotGridTabs rebuilds the
cell behind a window it closes. Tests pin both halves.

Main had meanwhile made autoscale tool installation per figure, so only the
per-figure Fit bookkeeping remains from this branch's cell_autoscale changes.
ComposedPlot is gone too: CellWidget.build_plot_pane covers the second view.
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