Add gui.add_panel() for floating side-by-side control panels#711
Open
ArthurAllshire wants to merge 48 commits into
Open
Add gui.add_panel() for floating side-by-side control panels#711ArthurAllshire wants to merge 48 commits into
ArthurAllshire wants to merge 48 commits into
Conversation
Adds viser.GuiApi.add_panel(), which opens a draggable, resizable floating window as a sibling of the main control panel. Useful when the default single-panel layout gets too tall or too narrow — panels can be placed side-by-side and sized independently. Features: - Drag title bar to move, right edge to resize. - Position and width persist to localStorage per-panel uuid. - initial_position=(x, y); negative values anchor to right/bottom edge. - layout="row" renders children side-by-side with equal flex share. Example: examples/02_gui/11_panels.py.
test_docs_coverage.py was failing because GuiPanelHandle wasn't listed in docs/source/api/handles/gui_handles.rst. Fixed by re-running `python docs/generate_handle_docs.py`.
Refactor standalone panels to a dedicated GuiPanelMessage entity (not a GuiTabGroupMessage under "root"), eliminating the isStandalonePanel inline-render filters. Plus a batch of placement/sizing fixes and structural hardening of the dock layout module. API / behavior: - add_panel is its own top-level entity (like add_modal); add_tab via a shared _TabContainerMixin. expand_by_default replaces minimize()/expand(). - float() is viewport/canvas-relative; negative coords are gaps from the far edge (x=-15 = 15px from the right), re-resolved as the canvas changes. - visible=False hides a panel (panes removed) without destroying it. - main_panel.float() un-docks; configure_theme(control_layout=...) deprecation. - Disconnect freezes + dims the GUI instead of wiping it; reconnect replays. Fixes: - Float canvas-relative + push/clamp within canvas on dock/resize changes. - Auto-height windows: content-aware floor + revert-to-auto (no pin-trap); cappedWindowHeight never inflates a small window past its pinned height. - Resize handle sits between a minimized strip and the expanded panel. - Re-placing a panel re-gathers tabs dragged out into other groups (no dupes). - Narrow "Connected" header truncates cleanly; min region width 96. Correct-by-construction (dock layout): - Extract invariantViolations into layoutInvariants.ts; applyOp asserts it on every commit in dev; the fuzz suite imports the same checker. - movePaneInPlace primitive (detach-then-insert) so a pane can't live in two groups; ensurePanelGroup's gather routes through it. - Remaining union refactors tracked in design/dock-correct-by-construction.md.
Tests built FloatingWindow objects as raw {id,x,y,width,stack} literals in ~100
places (and makeLayout/floatingLayout re-declared the shape inline), coupling the
model to test literals -- which is what made every window-model change expensive.
Add floatingWindow() to testUtils as the ONE place a FloatingWindow is
constructed; route makeLayout and floatingLayout through it; migrate every raw
literal to the factory. Behavior-preserving (403 vitest, 39 e2e unchanged). A
future field change (e.g. a tagged-union for height/position) now touches the
factory, not the literals.
Replace FloatingWindow.height?: number -- which overloaded "auto-size"
(undefined) and "pinned px" (number) -- with a tagged union
{ mode: "auto" } | { mode: "pinned"; px: number }.
Makes the height pin-trap and the sentinel-undefined ambiguity unrepresentable:
"auto vs pinned" is an explicit total state, and reverting to auto is a real
named transition instead of `delete win.height`. Production reads branch on
`.mode`; the testUtils floatingWindow() factory translates the terse numeric test
opt, so call sites stay terse. The dev invariant assert validates px finiteness.
Verified: 403 vitest (incl. fuzz), 29 e2e, tsc/eslint clean; pin-trap fix
re-confirmed end-to-end (button panel resizes and reverts to content height).
Replace the FloatingWindow.requestedX?/requestedY? PAIR with one optional
`anchor: { x; y }`. Presence is the ownership tag: an anchored window re-resolves
its canvas-relative anchor (negative = gap-from-far-edge) as the canvas/size
change; absence means user-owned at its absolute x/y.
Collapsing the two coords into one object makes "half-set ownership"
unrepresentable -- the hazard where a gesture set one coord but not the other.
markWindowUserOwned is now a single `delete win.anchor`. Stored absolute x/y are
kept (hit-test/drag/render read them unchanged), so this is the lighter form of
the placement union: it captures the correctness win without rewriting the
delicate grab-offset/grip-resize code (see design/dock-correct-by-construction.md).
The Python window() helper (dock_helpers.py) is the one e2e seam that builds
floating-window layouts; updated it for the WindowHeight union too.
Verified: 403 vitest, 297 e2e (0 failed), tsc/eslint/ruff clean, 28 Python.
… aliasing Two issues found auditing the just-landed union refactors: - The dev invariant assert (and the shared fuzz checker) flagged any empty-paneIds group, but an area-backing group is LEGITIMATELY empty -- it persists as a "drop a panel here" affordance after its last tab is removed or torn out. This fired console.error on real dev flows (an empty inline tab group, or removing the last area tab). Rule 5 now exempts area groups from the empty check (a non-empty area group is still validated). The fuzz suite never exercises areas, so it missed this. Added regression tests (empty area via ensureArea + add/remove, plus a sanity check that a non-area empty group is still flagged). - snapToWindowStack adopted the source window's height object by reference from the ORIGINAL layout, sharing it with the committed draft. Harmless today (height is always replaced wholesale, never mutated in place) but a footgun the union introduced; copy it instead. Also added an op-level regression test for re-gathering panes the user scattered into separate windows (movePaneInPlace gather). Verified: 405 vitest (incl. fuzz), 37 e2e, tsc/eslint clean.
…anvas Docking enough panels that the left + right regions' summed reserved width exceeds the viewport (e.g. 3 left + 3 right at the 300px default on a 1280px screen = 1800px) made the two regions overlap and fully cover the 3D canvas -- hiding the scene and trapping the panels' own controls underneath. Add a render-time guard in DockManager: when the regions' summed reserved width would leave less than MIN_CANVAS_PX of scene, scale BOTH regions down proportionally so a usable canvas strip always remains. This is render-only -- the MODEL region widths (regionWidth) are untouched, so widths restore when the viewport grows back. The capped insets flow consistently to hit-testing, the rendered region divs, and float clamping (all read the same reservedWidth). Found by an adversarial visual hunt; added an e2e regression (test_many_docked_panels_do_not_occlude_canvas) asserting the viewport center resolves to the CANVAS, not a docked region. Verified: 405 vitest, 298 e2e (0 failed), tsc/eslint/ruff clean.
From an audit pass focused on remaining "valid-only-by-convention" spots and avoidable complexity. All behavior-preserving. - WindowHeight: add windowHeight(px?) / pinnedPxOf(h) helpers and route the 7 open-coded construct/access sites (across types/layoutOps/DockManager/ FloatingWindowView/testUtils) through them. The union's "no sentinel ambiguity" win was being undercut by `.mode === "pinned" ? .px : <fallback>` scattered with three different fallbacks; now there's one constructor and one accessor. - Python tab tuples: _tab_labels / _tab_icons_html / _tab_container_ids were kept in lockstep by hand (separate appends, three splices on remove, .index(self) arithmetic in the icon setter -- the most fragile spot). Make _tab_handles the single source and rederive all three via one _rebuild_tab_props(). Desync in length/order is now unrepresentable. - DockManager: extract a single commit(next) that does the dev invariant check + state update, and route BOTH applyOp and restoreLayout through it -- so "every committed layout is structurally checked in dev" is literally true, not "true except restoreLayout." - Rename releaseRequestedCoords -> releaseAnchor and the resolve params requestedX/Y -> anchorX/Y, matching the `anchor` field vocabulary (the old requested* names were leftover drift). Verified: 405 vitest, 298 e2e (0 failed), tsc/eslint/ruff clean, msgs in sync, 28 panel tests; tab-tuple sync re-confirmed (add/remove-middle/icon-change).
Re-placing a panel that's already floating solo (e.g. a later set_height / set_width, which re-runs applyPanelPlacement with the coalesced placement) went through floatGroup unconditionally: detach + mint a NEW window every time. That churned the window id, reset its z-order to front, and re-resolved position from the stored server anchor -- so set_height on a panel the USER had dragged yanked it back to the anchor position. floatAtRequested now reuses the existing window when the group is its sole occupant: it updates width/height in place, keeps the window id + z-order, and PRESERVES the user's position when the user owns it (anchor cleared by a drag) -- only (re)anchoring + resolving for a fresh float or a still-anchored window. A docked->float or multi-group-stack re-placement still makes a fresh window. Found while polish-hunting the floating-panel height behavior. Added op-level regressions: set_height keeps the same window id + pins the height; a size-only re-placement does not move a user-dragged panel. Verified end-to-end (drag to (380,413) then set_height(420): height applies, position preserved). Verified: 407 vitest (incl. fuzz), 298 e2e (0 failed), tsc/eslint clean.
A size-only re-placement on a panel already docked on the target edge (e.g. `panel.dock_right(); panel.set_width(520)`, two statements -- exactly the add_panel docstring example) silently did nothing: the region stayed at its default width. Cause: applyPanelPlacement re-ran `dockToEdge` on the already-docked group, which detaches + recreates the leaf with a NEW node id. The width reconciler then sees a changed column set, treats it as a new column, and resets the width to the carried-over/default value -- discarding the requested width. (The position-changing case worked only because the width rode that placement.) Fix: skip the re-dock when the group is already docked on the target edge, so the leaf's node id stays stable and the size branch + reconciler's same-columns path apply the new width. Mirrors the floating window-reuse fix from the prior commit. Added an op-level regression (set_width changes the docked region width; node id unchanged). Verified: 408 vitest (incl. fuzz + width reconciliation), 298 e2e (0 failed), docked + floating set_width confirmed end-to-end (300->520->150 / 240->420->300).
A tab label (or single-tab panel header) longer than the tab's max width was cut off mid-character with no "...". The tab/header Box is `display:flex` with the title as a raw text child, and `text-overflow:ellipsis` is ignored on a flex container -- so it clipped with no ellipsis. Move the overflow/text-overflow/white-space onto an inner <span> (a non-flex block) and give it minWidth:0 so it can shrink within the flex row; the icon box gets flexShrink:0 so only the label truncates. Applies to both the tab strip and the single-tab header's plain-title branch. Verified end-to-end: a long label now renders "ThisIsAnExtremelyLon..." with a proper ellipsis. tsc/eslint clean, 408 vitest, 298 e2e.
… offset)
Three related minimized-panel fixes:
- Minimized DOCKED multi-tab strip: was "{active icon} Alpha / Beta / Gamma"
(one arbitrary icon + all labels joined, rotated) -- confusing and per-tab
clicks were impossible. Now render ONE ROW PER TAB (upright icon + rotated
label, active row highlighted). Each row is its own click target.
- Clicking a tab expands the panel: added DockContext.expandToTab (setActiveTab
+ expandGroup, composed in one op). The minimized strip rows AND the no-motion
tab-click in startTabDrag both route through it, so clicking a tab to read it
reveals its content instead of switching a hidden active tab. Keyboard tab
nav still uses plain activateTab (arrow keys shouldn't expand).
- Tear-out grab offset: dragging a minimized panel out by grabbing the BOTTOM of
its region-tall strip left a large cursor-to-ghost gap (grab was computed
against the tall source, but the floated window is short). Clamp the grab
offset into the floated window's actual rendered box so the cursor stays on it.
Verified end-to-end: 3-tab strip renders 3 clickable rows; clicking Beta expands
to Beta; bottom-grab tear-out keeps the cursor on the window. 408 vitest, e2e
regression added (test_minimized_multitab_strip_rows_expand_to_tab).
The press-to-window grab offset was open-coded in four drag starters, and only the group-undock site clamped the offset into the floated window's box. The column-undock site had the same latent gap bug (a region-tall column floats into a height-capped window, so a low grab left a cursor-to-ghost gap) but no clamp. Extract grabOffset(e, originX, originY, winId?): clamps into the floated window's rendered box when winId is given (tear-outs whose source is bigger than the result), else returns the raw offset (when the source IS the window). All four sites route through it; the column-undock site now gets the same clamp. Verified: 408 vitest, 299 e2e (0 failed); strip bottom-grab still lands on the window through the refactored path.
The playground tests' vite dev server recursively watched the whole client dir, including the huge .nodeenv and node_modules trees. Across many test runs this exhausts the OS inotify watcher limit and crashes the server on startup (ENOSPC), making the playground tests error at setup. The tests never edit source mid-run, so ignore those trees in server.watch -- lighter servers, no watcher exhaustion.
…eError PanelHandle.__enter__ raises a clear TypeError to catch the `with add_panel():` mistake, but __exit__ had been removed on false reasoning. CPython looks up BOTH dunders on the type BEFORE calling __enter__, so omitting __exit__ made `with panel:` fail with a bare AttributeError and the helpful message never ran. Restore __exit__; the regression test now uses a real `with panel:` (it had masked the bug by calling __enter__() directly).
The per-tab rows in a minimized docked strip carried role=tab/aria-selected but had no tabIndex, onKeyDown, or focus ring, and weren't wrapped in a role=tablist -- claiming to be tabs while being mouse-only (a role-without-keyboard-support a11y break, and role=tab outside a tablist). Wrap the rows in a role=tablist (aria-orientation vertical), make each row tabIndex=0 + focusRing + onKeyDown (Up/Down move focus, Enter/Space expand-to-that-tab via expandToTab), mirroring the expanded tab strip. Verified: rows focusable, Enter expands.
dock -> minimize -> drag out -> expand produced a ~96px-wide window: floatRectFor measured the collapsed strip's narrow DOM width and used it as the floated window's width, persisting after expand. When tearing out a MINIMIZED docked group, float it at the region's preserved expanded width (regionWidth survives minimization for restore) instead of the measured strip width. Added an e2e regression (dock->minimize->undock->expand stays wide).
Dropping a dragged panel as the leftmost tab (insert index 0) of a docked panel failed in some configs: a region-edge band spans the whole region, so a tab strip flush at the region's top/left edge sits inside the band, and section-2 region edges (checked before per-panel zones) won -- docking a region span instead of inserting at the tab. This hit EVERY panel in a stacked column (leftmost tab at x=0, inside the 40px left band) and topmost panels in a multi-column row (strip inside the 8px top band). Skip the region-edge bands when the pointer is over an INSERTABLE tab strip (a mergeable docked/floating group where tabInsertion resolves, drag itself mergeable). A strip drop is a more specific intent than a region span. Outermost region-edge docking is unaffected -- content area, grip bar, and screen edges aren't strips. Added hitTest regression tests (column + topmost-row configs, plus draggingUnmergeable still gets the region span). Verified live: drop on a stacked panel's leftmost tab now inserts at index 0.
A minimized vertical strip is wayfinding chrome (a label/affordance), not content, so emphasizing the active tab with the primary color + bold there just distracts. Render all rows uniformly dimmed. aria-selected still marks the logical active tab for assistive tech; only the visual emphasis is removed.
Dragging across the seam between two vertically stacked docked panels [A above B] flickered: A's content bottom band drew the hint line at A.bottom, B's grip bar drew it at B.top (7px apart, across the divider), and the divider gap itself hit no target -> a NONE frame. Three slivers that all mean 'insert between A and B', read as a jumpy/blinking target. Hint-only fix (drop OUTCOMES unchanged): snap both bands' line to the divider gap CENTER when the split lands on a seam shared with an adjacent stacked sibling (dockedSeamSibling: same edge, horizontally overlapping, gap <= divider+slack), and map the divider gap itself to that same seam split so there's no dead frame. Single-panel / region-edge splits (no sibling) keep the panel-boundary line + on-screen clamp. Added hitTest seam tests (bands + divider + 1px sweep: one stable line, no NONE; single-leaf still anchors to its own edge). Verified live: seam sweep went from 403->NONE->410 to a stable 406, 0 dead frames; drop still inserts between A and B.
The main panel's header toggled minimize on a no-motion tap, but ordinary panel grip bars deliberately did not -- minimize was only on the small +/- button -- an inconsistency between the main panel and every other panel. Make a motionless tap ANYWHERE on the grip handle toggle minimize/expand too, matching the header. The +/- button stays as an explicit, redundant cue. Safe because armPress already splits click-vs-drag at a 3px motion threshold: a real drag (move the panel) exceeds it and never fires the tap toggle. Verified live: tap grip -> minimize, tap strip -> expand, drag grip -> moves (no accidental minimize). Updated the e2e (test_handle_tap_does_not_minimize -> test_handle_tap_toggles_minimize) to assert the new behavior.
A docked region's max width was derived from MAX_PANEL_WIDTH_PX (600) via maxRegionWidth, which SUMS across a row but takes the MAX across a column. So a single panel capped at 600 alone but ~1207 when stacked above a two-panel row -- structure-dependent and inconsistent. Remove the absolute cap entirely. Region width and floating-window width are now bounded only by the container: the render-time MIN_CANVAS_PX guard keeps a canvas sliver visible for docked regions, and float resize is bounded by container width minus a canvas sliver. The width reconciler's clamps now enforce only the grab-min floor (no ceiling). maxRegionWidth is kept solely as a relative structural-extent comparator for widthColumns (renamed leaf unit to LEAF_WIDTH_UNIT_PX; it caps nothing). Dropped MAX_PANEL_WIDTH_PX. Updated width tests (no upper bound; over-wide set_width is now preserved). Verified live: set_width(850) gives 850 (was clamped to 600); set_width(5000) is held to 1280 by the canvas guard, leaving a 120px canvas sliver.
Cleanups from a /simplify pass over the recent dock work (behavior-preserving): - Finish the max-width removal. maxRegionWidth still fed resizeRegionColumns as a per-column cap, so interactive region-edge DRAG was still capped at 600 while SplitView/reconciler/set_width were not -- an inconsistency the cap-removal commit missed. Replace the vestigial pixel-based maxRegionWidth (and LEAF_WIDTH_UNIT_PX) with a pure ordinal columnExtent (leaf=1, row=sum, col=max) used only by widthColumns; region-edge resize now passes Infinity for maxs. The misleading 'max width' concept is gone, not renamed. - widthReconciliation: clamp(x, min, Infinity) -> Math.max(x, min) at all sites (a floor, not a two-sided clamp); drop the now-unused clamp import and trim the gravestone comment describing removed behavior. - Extract tabListKeyDown (gestures.ts) for the roving-focus tablist keyboard pattern; TabGroupFrame's tabKeyDown and VerticalMinimizedColumn's per-row handler were near-duplicates (arrow-rove + Enter/Space activate). - hitTest overInsertableStrip: reuse the existing inside() helper for the strip-bounds check instead of an open-coded 4-way compare. Removed the obsolete maxRegionWidth test block (covered by widthColumns tests). Verified: 412 vitest, 46 e2e (width/keyboard/dropzones/panels), tsc/eslint clean. NOT done (noted, higher-risk redesigns of the just-tuned hit-test, left for a deliberate change): canonicalizing the seam DropResult by tree nodeId instead of geometric sibling detection; replacing the leftmost-tab skip-guard with an explicit 3-tier zone precedence.
resizeWindow (the op behind a floating panel's server set_width) wrote the width unclamped, so set_width(0)/negative produced a zero- or ungrabbable-width window (and the interactive drag path's floor didn't apply to server-driven sizing). Floor at MIN_REGION_GRAB_PX, matching the drag path and docked regions. No max -- float width stays otherwise uncapped, consistent with the max-width removal. Pre-existing gap (resizeWindow never clamped); surfaced by the adversarial sweep of this session's changes. + op regression test.
set_width/set_height re-send the whole COALESCED placement, so a size-only change still carries the prior position (e.g. dock_right's edge). applyPanelPlacement's edge/split branches then unconditionally re-docked -- so a panel the user had locally torn out to a float got yanked back to the server's edge on any set_width. (The float branch already guarded this via the window's anchor flag; edge/split had no equivalent.) With two clients this is a contract violation: one set_width correctly resizes the docked client but re-docks the client that had locally floated the shared panel. Fix: thread the last-applied position (prevPosition) into applyPanelPlacement; the edge/split branches relocate only on a genuine position CHANGE. A size-only re-placement (position unchanged) leaves the panel where it is and just resizes it. A real dock_left-after-float still relocates (positions differ). Verified end-to-end + multi-client: tear out on A -> set_width keeps A floating while B resizes docked; dock_left then moves both. + op regression tests (size-only keeps a floated panel floating; position change relocates). Found by the multi-client adversarial loop.
Removing all of a DOCKED panel's tabs (down to zero) then adding a new tab left the panel permanently invisible (and orphaned its group -- the dev invariant checker fired). When tabIds went empty, ready flipped false so the membership effect bailed and the hide effect (gated on !visible) didn't run, so the dead group lingered in the layout; on revive the placement effect was deduped (appliedPlacementKey unchanged) and the membership effect created a NEW group, orphaning the stale one. Floating panels were immune (different path). Add an effect for the emptied-while-visible transition: remove the panel's last-known panes from the layout (collapsing the dead group/leaf) and clear the applied-placement refs so a revive re-places cleanly. Verified across docked, dock_below (stacked), and floating; 0 invariant violations. + e2e regression. Found by the general correctness loop.
A minimized vertical strip with many tabs / long titles in a short viewport clipped its lower rows unreachably: the Paper was overflow:hidden, cells/rows flex-shrank and squashed, and a vertical-rl label has no main-axis bound so a long title stretched the whole strip. - Cap each rotated label at maxHeight:14em so it ellipsizes into a tidy fixed spine (instead of growing the row to the title's length). - Cells + rows are flexShrink:0 (keep content height); the Paper is overflowY:auto -- so when rows don't all fit, the strip SCROLLS and every tab stays reachable, rather than clipping. flexGrow:1 still balances a few cells. Verified: 8-tab strip in a 360px viewport is scrollable (all reachable); a long title ellipsizes. 368 vitest.
dock_above/dock_below split against a DOCKED neighbor; if the anchor is floating or unplaced they fall back to a right-edge dock (with a warning). The main_panel docstring advertised it as a dock anchor 'from any scope' without noting the control panel floats by default, so a split against it falls back unless it's docked first. Clarified all three docstrings.
Two pure-column render paths (expanded SplitNode vs fully-minimized VerticalMinimizedColumn) duplicated the same chrome: a data-dock-column Box + the float-the-column handle + a flex-grow body wrapper. Extract a ColumnShell component taking the body as children. Behavior-preserving render refactor; verified the stacked-column path still renders (column + handle + both leaves).
From a deep robustness review (no live bug; these make it bulletproof): - Move the lastTabIds bookkeeping out of the render body into an effect (keyed on orderKey, declared before the empty effect). A ref write during render is a concurrent-mode footgun; only-update-when-non-empty preserves the panes the empty effect needs. Documented the B/D declaration-order coupling. - Add four structural invariants to layoutInvariants.ts (so the dev assert + the fuzzer now catch them): floating stackWeights must be finite/positive and keyed only by groups in the window's stack; collapsedByParent must be boolean; each area must be keyed by its own id. These harden the persistence/restore boundary (only reachable from a corrupt serialized layout, never from an op).
Added an area-containing fuzz fixture (the fuzzer previously NEVER exercised dockable areas -- a standing blind spot, since the area-aware invariants are load-bearing). It immediately caught a real conservation bug: tearOutPane wrapped its paneId in a fresh floating group UNCONDITIONALLY, so tearing out a pane the group doesn't hold (e.g. an emptied area group -> undefined paneId) CONJURED a phantom panel (a [null] group + window), violating panel conservation. Fix at the chokepoint: tearOutPane returns the layout unchanged (windowId/ floatingGroupId null) when the pane isn't actually in the group -- there's nothing to tear out. Return type widened; the DockManager caller bails on the null window id. Regression tests in area.test.ts pin the exact shape. Verified: fuzz suite green (area fixture across 500x80 + randomized start), 371 dock vitest, 37 e2e.
floatColumn (float a whole pure column as one stacked window) is the only conservation-preserving op that produces stackWeights, and it wasn't fuzzed. Add it to the op driver so the new stackWeights invariant (finite/positive, keyed only by in-stack groups) is verified across random sequences, not just by unit tests. (removePane deliberately NOT added -- it destroys a pane, which would break the fuzzer's panel-conservation check; its removal logic is covered by op tests + the empty-revive e2e.) Fuzz suite green across all 7 fixtures.
In a docked column [Top, Mid, minimized Bottom], dragging the Top/Mid divider rewrites the expanded cells' weights to a PX scale, but the minimized Bottom's weight (its restore size) was left at its old flex-unit value. On unminimize it then rendered flexGrow:tiny next to px-magnitude siblings and collapsed to ~1px -- off the bottom of the viewport. Fix at the resize chokepoint: when committing resized weights, also rescale each collapsed cell's preserved weight onto the same px basis (keeping its proportion), so all of a column's weights stay in one consistent unit and an expand always restores a proportional height. + e2e regression.
The scene visibly lagged the panel divider on a fast docked-width drag: R3F sizes its renderer from a ResizeObserver on the canvas wrapper, which fires async AFTER layout, so the panel edge (CSS inset) moved this frame but the GL backbuffer trailed by one drag-step every frame (measured -6px at 6px/step, -30px at 30px/step; snapped on release). Drive the GL resize on the same tick as the commit instead: - ViewerMutable.syncCanvasSize: a nullable hook that calls R3F setSize to the canvas's current CSS box (updates drawing buffer + camera aspect/projection; no-ops if unchanged). Registered by SceneContextSetter once the renderer exists. - DockManager: a host-supplied onRegionResizeFrame callback (dock stays Three-agnostic); the per-frame width commit is wrapped in flushSync so the new inset is in the DOM, then the callback fires. flushSync is safe here -- the commit runs in the gesture's own rAF, not in render. - ControlPanelDock wires the two together. R3F's own observer fires moments later and finds the size already current, so the two never fight. Verified: 0px backbuffer lag at full drag speed (dpr 1 + 2); window resize after a drag still follows (setSize doesn't pin CSS px); 55 e2e + 371 dock unit tests pass.
…n's path Two fixes to how a docked-region width drag behaves: 1. Canvas no longer trails the divider. R3F sizes its renderer from an async ResizeObserver, so during a width drag the panel edge moved this frame but the GL backbuffer (and rendered scene) trailed. The resize handler now hands the 3D canvas its authoritative new size (containerWidth minus the freshly committed insets) and the renderer resizes + repaints synchronously on the same tick (ViewerMutable.syncCanvasSize). Computed, not re-measured from canvas.clientWidth, which isn't reliably reflowed mid-drag. 2. Floating panels no longer get shoved around by a resize. Previously every inset change re-clamped user floats inward (so you couldn't resize a docked panel under a float without it jumping). Now a float is pushed ONLY when the growing region's edge actually sweeps past it AND it was fully on the canvas; it's kept flush with the seam (stays fully on canvas) until it can't, then the region slides over it. A float already overlapping, or a shrinking region, moves nothing. The decision is local to the drag frame (before/after seam) -- no history. Server-anchored floats still re-resolve against the new bounds. Verified: scene tracks the divider with 0 lag; float pushed flush / left alone per the rule. 39 panel+float e2e + 371 dock unit tests pass. Replaces the discrete-state float_clamp playground tests (the behavior is now drag-only) with a real-server push test.
The unmergeable panel header (e.g. the main control panel's connection-status header) drew a 1px bottom rule via the headerRule class unconditionally. That rule separates the header from the content below -- but a minimized panel is header-only, so the rule read as a stray gray border on its bottom edge. Apply headerRule only when expanded (!collapsed).
- Remove the orphaned comment block that still described the old resolveFloatingPositions/clampDragged behavior (since replaced by pushFloatsAheadOfSeam + reanchorFloats); it referenced a parameter that no longer exists. - Drop containerHeightRef: a width drag never changes the container height, so the resize closure's captured containerHeight is already current. Keep containerWidthRef (matches the reservedWidthRef convention; width is the axis being dragged). No behavior change; 371 dock unit tests pass.
Behavior-preserving cleanup of the panel/dock client logic: - DockManager: merge two byte-identical reanchorFloats effects into one (combined deps -- React fires on any dep change either way); inline restoreLayout (a one-line useCallback wrapper around commit used at a single drag-cancel site), moving its rationale comment to the call site. - layoutOps: drop the unused PanelPlacementPosition type export. - ControlPanelDock: fold a duplicate DockContext import line. Net ~-13 lines of source. The load-bearing machines (StandalonePanelPlacement's effect/ref set, the gesture stable-wrapper) were reviewed and deliberately kept: no consolidation is provably behavior-preserving across their transitions (per-client/shared contract, set_width-no-redock, empty-then-revive). tsc + eslint clean; 372 dock unit tests pass.
The dropOnDockedLeaf seam-insert equivalence (splitting right-of-b == left-of-a in a side-by-side region both yield column order [b,c,a]) was pinned only by an e2e test that opened TWO full browser sessions to read rendered geometry. It's a pure layout-algebra property -- add it as a deterministic vitest test against dropOnDockedLeaf (runs in ms). The e2e test can be retired once the pytest harness is available for a coverage-parity run. Part of a test-efficiency pass: the dock suite is already well-factored (algebra exhaustively unit-tested; e2e deliberately covers wiring/render). 372 dock unit tests pass.
Regression from 4c3facf: it replaced the inset-change effect (which re-clamped unanchored floats inward when a docked region's width changed) with reanchorFloats (server-anchored floats only). The control panel is UNANCHORED, so after dock_right then minimize, it was never pulled back -- its right edge and its zIndex-12 resize handle overhung the revealed minimized strip and intercepted the strip's pointerdown, so tearing out a minimized panel did nothing. Restore the clamp for unanchored floats, but ONLY on DISCRETE inset changes (dock/minimize/undock); a region-resize DRAG is excluded via regionResizeDraggingRef (the drag moves floats itself via pushFloatsAheadOfSeam). Canvas-resize-lag fix and float-collision-push untouched. Verified: full dock e2e 111 passed (incl. the 2 previously-failing tear-out tests); tsc/eslint clean; 372 dock unit tests pass.
When the control panel was narrow, TWO stacked horizontal scrollbars appeared: one for the scene tree table (its own overflowX:auto ScrollArea) and one for the whole panel body (dockBodyScroll). The scene tree's wide fixed-width rows overflowed inside their own scroller, on top of the panel's. Render the scene tree as a plain Box (drop its ScrollArea) and remove tableWrapper's overflowX, so its wide rows overflow into the panel body's single scrollbar instead of stacking a second one. The scene tree still renders and its controls still work; the panel now shows exactly one horizontal bar when narrow.
These asserted pure layout-algebra (collapsed-state / column-order) through synthetic clicks -- no real geometry or rendering -- and pytest.skip'd when their gesture-built setup didn't land (flaky), so they cost a browser session for zero unique coverage: - test_stack_minimize_all_and_expand_all - test_stack_minimize_restores_previous_mix (both subsumed by layoutOps.test.ts "minimizeStack / expandStack", 5 cases) - test_right_of_A_and_left_of_B_are_the_same_seam_insert (opened TWO browser sessions; ported to layoutOps.test.ts "dropOnDockedLeaf seam equivalence") Also removed the now-dead _make_stack helper + unused pytest/collapsed imports. Verified: vitest equivalents pass; trimmed handles+dropzones e2e pass (8 tests).
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.
Summary
Screen.Recording.2026-05-04.at.8.10.40.PM_compressed.mp4
Adds
viser.GuiApi.add_panel(), which opens a draggable, resizable floating window as a sibling of the main control panel. Useful when the default single-panel layout gets too tall or too narrow — panels can be placed side-by-side and sized independently.GuiPanelHandlesubclassesGuiFolderHandle, so it works as a context manager exactly like folders do — the only difference is that the panel renders as its own floating window instead of inline in the main panel.Features
localStorage(keyed by the panel's UUID).initial_position=(x, y)acceptsint | \"center\"for either axis. Negative integers anchor to the right/bottom edge (e.g.(-20, 20)is 20px from the top-right corner).layout=\"row\": renders children side-by-side with equal flex share, non-wrapping — useful for rows of camera/video feeds.resizingRefsuppresses the ResizeObserver's reposition pass during an active drag so width updates don't fight it.Backward-compatible: all changes are additive.
SidebarPanel,BottomPanel, and existingFloatingPanelcall sites are untouched.Demo
examples/02_gui/11_panels.py— standalone demo with animated "video" feeds. Shows the main panel alongside three user panels (top-left, centered, bottom-right), with working Zoom slider and Reset button. Run withpython examples/02_gui/11_panels.pyand open http://localhost:8080.Stats
13 files changed, 528 insertions(+), 6 deletions(-). Largest changes are in
UserPanels.tsx(new, 121 lines — the renderer) andFloatingPanel.tsx(+101, adds the resize handle andinitialPosition/onGeometryChangeprops).Test plan
examples/02_gui/11_panels.pyruns; all three panels visible and animate.\"center\"x keeps the Cameras panel horizontally centered; negative-y keeps Status anchored to the bottom.add_folder,configure_theme, sidebar layout, and bottom (mobile) layout are unchanged — verified via02_gui/00_basic_controls.pyand02_gui/05_theming.py.Open questions / happy to bikeshed
API shape wasn't obvious — would love maintainer input on any of these before you merge:
initial_positionconvention. I usedint | \"center\"with negative = right/bottom anchor. Clean in the common case, but the negative-sign thing is a little clever. An alternative would be an explicitanchor: Literal[\"top-left\", \"top-right\", \"top-center\", \"bottom-left\", \"bottom-right\"] = \"top-left\"field with all-positive offsets. Same expressiveness, more verbose.layout=\"row\". Currently only exists on panels. If this feels generally useful, it could move toadd_folder/ a newadd_rowcontainer. Kept it panel-only here to minimize surface area.UserPanels.tsxand persists by panel UUID. If uuids aren't stable across Python restarts (they aren't —uuid.uuid4()), persistence only survives browser reloads, not server restarts. Happy to key ontitleinstead if that's preferable, or make persistence opt-in via a prop.UserPanels.tsx, or leave it to the user.Also: I ran ruff (passes),
npm run typecheck(passes), and smoke-tested existing GUI examples (00_basic_controls.py,01_callbacks.py,05_theming.py). I couldn't runpyrightlocally due to an unrelated environment issue — CI should catch any type problems.🤖 Generated with Claude Code