Pop a cell's plot out into a floating window - #1152
Draft
SimonHeybrock wants to merge 8 commits into
Draft
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Pipeand 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:
SessionView.live_cell_ids, sodesired_cellskeeps 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._PassStampsfield. 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.cleanupdrops 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:
CellWidgetowns every pane rendering its plot — the grid cell's and any pop-out's — and severs them together on dispose.PlotGridTabs._close_popoutrebuilds 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.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
FloatPaneldoes 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 onjspanelresizestop, which a drag fires but maximize and normalize do not. And an invisibleReactiveHTMLhelper 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:
mainhad 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/PolyDrawfrom 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:
cell_plan_test.pythat a hidden cell with a showing pop-out is materialized and that its neighbours on the same hidden grid are not;Manual checks for the tester:
🤖 Generated with Claude Code