feat(tao): layered dock sides, per-panel sizes and app-owned dock chrome - #663
Draft
kdroidFilter wants to merge 13 commits into
Draft
feat(tao): layered dock sides, per-panel sizes and app-owned dock chrome#663kdroidFilter wants to merge 13 commits into
kdroidFilter wants to merge 13 commits into
Conversation
A `DockLayout` could only stack the panels of one side at equal shares of a single per-side extent, which is not the pane tree a reader-style app draws: navigation, contents and notes side by side on one edge, each with its own width and its own splitter, and a commentary strip that runs under the text but not under the navigation. That layout is now expressible, and every pane of it is a satellite, so it can also be torn into a window of its own. - `SatellitePlacement.Docked` carries the panel's own `extent` and `weight`; both ride in `SatelliteLayoutSnapshot` and are driven by `setDockedExtent` / `setDockedWeight`. - `DockLayout(sideOrder, layeredSides, splitter, panel)`: sides nest outermost-first, so a side can own the corners; a side is either *split* (panels share its length by weight, thickness by `dockExtent`) or *layered* (each panel a full-length layer of its own extent, with its own splitter). - The `splitter` and `panel` slots hand the chrome to the app — `DockSplitterScope.dockSplitterHandle()` carries the gesture, so a 1 dp line with a wider overflowing grip works — and the default header no longer imposes a height or a background on a docked panel. - Sides are physical: the layout composes LTR internally and gives the caller's direction back to the content, the panels and the slots, so a right-to-left app gets `DockSide.Left` on the left of the screen. - Every panel and the content are `movableContentOf`: no change of the layout — extent, weight, order, side, restore, side order, direction, a resize — rebuilds a subtree, so a docked pane keeps its scroll position and its `remember`s. Extents are fitted proportionally when the window is too small. - Drop feedback is drawn at the rectangle the release produces and hit-tested against that same rectangle: the side's own band, inset behind existing layers, counting the dragged panel's side as already freed, at the width `dock()` will apply. A zone is entered when the dragged satellite's edge reaches it — the palette, not the pointer — and the side a panel already occupies is neither drawn nor droppable. - `examples/reader-dock-demo`: the whole thing as a right-to-left book reader, with the reader's own dividers, hover headers and Islands style. Covered by 435 unit tests (the new classes registered in the GraalVM battery), 10 real-window cases with robot-driven splitter drags and 13 dock-layout monkeys (4 layout profiles x 3 seeds plus a 400-action run).
… one over the others A panel docked again always went to the end of its side — the header button, a drop, a floating palette brought back — and nothing let the user say otherwise: a drop knew the side, not the place. - `dock(id, side, order)` inserts at that rank and keeps a side's ranks contiguous from 0; `undock()` closes the gap. `order = null` returns the satellite to the rank it last held on that side (declared, or the one it left), remembered per side in `SatelliteEntry.dockMemory` with its weight, and appends only when it never sat there. Closed panels keep their rank. - `DockTarget.order`: a side with panels publishes one slot per rank (`DockDropZone.slots`, cut at the neighbours' centres, the dragged panel excluded) and the pointer over the stack picks the rank — its own being no target. Drawn as an insertion bar between the two panels; a new innermost layer and an empty side keep the rectangle they promised. A pointer over a stack beats a strip running across its corner. The Wayland transfer path resolves the same slots. Covered by new unit classes (ranks, slots, bars, hit test, a drag session that reorders), three real-window cases on X11 (return to rank after undock, robot drag of a layer to the first rank, split side reorder with a closed panel in the middle) and one on native Wayland.
An app could not keep a pane off an edge: every satellite was droppable on all four sides, so a reader whose top is its own activity bar had no way to say so. - `Satellite(dockSides = …)`, fixed at declaration like the placement: `dock()` and `restore()` refuse any other side, `hintedSides` and the zone hints neither draw nor publish it, the drag sessions resolve through `dockTargetFor(entry, …)`, the Wayland transfer target filters on it, and the default header drops its Dock action for a floating-only palette (`dockSides = emptySet()`). A declared docked placement must name an allowed side. - `preferredDockSide` starts on an allowed side, so the header's Dock button always has somewhere to go. - `reader-dock-demo` declares its panes for the left, right and bottom only: the top strip never lights up. Covered by `SatelliteDockSidesTest` (5 cases, in the GraalVM battery) and a real-window case: the top is neither hinted nor published, a release there leaves the palette floating, a direct dock is refused, and the left side still takes it.
`dockSides` could keep a satellite off an edge but not keep it in the dock: every docked panel was one drag away from a window of its own, so an app had no way to say "this pane is furniture". - `Satellite(floatable = false)`: `undock()` refuses it, a `restore()` that floats it is ignored (its open state still applies), the docked drag publishes no tear-out ghost and a release clear of every zone leaves the panel where it was, and the default header drops its Float action. The declaration requires a docked `initialPlacement` — a fixed panel with nowhere to live is a mistake, not a runtime surprise. Everything inside the dock still works: hide, resize, and reorder among its neighbours. - `reader-dock-demo`: the book tree and the table of contents are the reader's furniture — `floatable = false` on the right side only. Covered by `SatelliteFixedPanelTest` (6 cases, in the GraalVM battery) and a real-window case where the same gesture that tears out the ordinary neighbour leaves the fixed panel in place, nothing rebuilt.
…ds nowhere A fixed panel could still be reordered — and pushed down by a neighbour dropped in front of it — so the reader's book tree and table of contents would not stay in the order the app declared. - `Satellite(reorderable = false)` pins the rank: `dock()` ignores any order for it and gives it the rank it was declared with, another panel's insertion is pushed past the last pinned one so it can join them but never displace one, its own side is no longer hinted, and a target resolved for it carries no rank — so no preview promises a move that will not happen. Requires a docked `initialPlacement`. - The ranks in front of a pinned panel stay in the published slots as empty rects, so a slot's index is still the rank it stands for and a drop aimed at a pinned panel lands right behind it, where the bar is drawn. - `Modifier.satelliteDragHandle` is inert on a satellite a drag could not move anywhere — pinned, fixed to the side it is on, and alone in the workspace — instead of leaving a gesture that can only end where it began. - `reader-dock-demo`: the book tree and the contents are furniture now — no tear-out, no side change, no reorder, and nothing docks in front of them. Covered by four unit cases (in the GraalVM battery) plus the layered-side geometry, and the real-window case now checks that the neighbour docked at rank 0 lands behind the pinned panel and that the pinned one is offered nothing at all.
Where the compositor places the window, moving it and moving the satellite are two different gestures and cannot share one area. Nucleus already split the title bar for that, but nothing about the split was public: the capability was internal, the reserved strip was a private constant, and a custom header had no way to adapt the way the stock one does. - `TaoWindow.canPlaceOnScreen` is the public capability (the internal `supportsScreenPlacement` is gone). Branch on it rather than on `isNativeWaylandSurface`: it is the question — can the app place this window — not the platform that answers it. - `SatelliteScope.isCompositorPlaced` gives custom chrome the same answer for the window it is composed in; the floating scope reads the satellite's own window, a docked panel reads its host. - `Satellite(floatingCaption = …)` fills the strip the title bar leaves to the compositor's move, `SatelliteCaptionStripWidth` wide, composed only where one is reserved — so an app never guesses that width nor accidentally claims the only area that can move the palette. - `SatelliteWorkspace.dragKind` (`Window` / `Transfer`) says how the drag in flight is carried, which is what tells preview code whether `dragGhost` will ever be published. - `reader-dock-demo` draws a move glyph in that strip. Covered by three unit cases in the GraalVM battery plus both halves of the contract on real windows: the X11 case asserts nothing is reserved and a drag is window-carried, the native-Wayland one asserts the strip is reserved at the published width, the panel and the palette are both told, and the drag is transfer-carried with no ghost.
…o the window The reader had one window and one book. Composing it with the tab archetype needed a slot the tab windows did not have: `windowWrapper` wraps the whole window *including* its strip, so chrome hung there receives the strip inside its own content — the reader's tab strip landed in the middle of the dock and its panes climbed over the title bar. - `TabWindows(windowBodyWrapper = …)`, in tao and in `nucleus-application`: composed inside the window, below the strip, around the selected tab's body. Window-level chrome goes there — a `DockLayout` and its satellites, an activity bar — and it is one call site for every window, so a tab change neither rebuilds it nor moves the body's relocation keys. - `reader-dock-demo` is now both archetypes at once: each sefer is a tab, the strip is the top of the window, and everything under it is the reader. The dock belongs to the window, so a tab change only changes what the panes draw; a tab torn out arrives with a dock of its own, with its own widths and its own chapter. The books pane selects a tab, the contents pane drives the text, and what the reader remembers per book outlives every window. Covered by a real-window case: the chrome is under the strip, at the height it asked for, built exactly once per window, and neither a selection change nor a tear-off rebuilds it. Driven by hand on the demo too — tabs, tear-off, and the per-window panes.
…s Wayland The strip jumped: a reorder swapped tabs in place, a drop was a thin bar, and on a compositor-placed surface the whole gesture was the platform's drag-and-drop session — no motion at all. This ports the state machine of `sh.calvin.reorderable`'s `ReorderableRow`, which is what SeforimApp uses. - **In hand**: tabs are `key`ed on their id, the dragged one is drawn at the pointer's travel since the grab, a neighbour steps a whole tab aside when that tab's *edge* crosses its *centre* (spring), and the release slides it into the slot it was over *before* the order changes — `pendingReorder` + `TabStripMotion.settle`, so nothing is ever seen jumping. Offsets are draw-time layer translations: the slots a drop resolves against never move. - **Two paths, by capability**: where the app places its windows the gesture stays `beginDrag` (ghost, screen hit-test) and the strip animates from the pointer it publishes. Where it cannot, the grip reorders *locally* — window px only — and hands the gesture to the platform's DnD session the moment the pointer leaves the strip (`TransferDragGesture`), which is the only way another window can be told where the pointer is and preview the drop. - `DragGhostWindow(popupFor = …)`: the preview is a popup overlay of the window it came from, so it follows the pointer out of a window the client cannot place. The tab slot carries `noWindowDrag()`, or the title bar's compositor move swallows the gesture. - Tabs open and close by width, the close button plays the tab out before the workspace drops it, and `insertionIndex` is direction-aware — a right-to-left strip used to resolve every drop mirrored. 90 real-window tab cases on X11 and 29 on native Wayland, including the numbers behind the motion, the RTL strip, the in-strip carry and the hand-over.
… it will fill A drag used to answer with four different pictures: a translucent card following the pointer, a solid rectangle on an empty dock side, a 4 dp bar between two panels of a stack, a 3 dp line between two tabs of another window's strip, and dashed strips for the sides merely on offer. Now there is one: the card the panel or tab travels under is also drawn on the very space the release fills, and the neighbours make room for it. - `DragPreviewDefaults.kt`: the shared surface (fill, border, corner) behind `SatelliteGhostCard` and `TabGhostCard`, at `hint` intensity for the sides merely on offer — solid and faint, no dashes. - Dock: `DockLayoutState.dropRectPx(side, dragged, order, extentPx)` replaces `insertionBarPx` and is the space for every case — the edge strip of an empty side, the layer at that rank of a layered side, the share the re-divided weights give it in a split stack, dividers counted. `dock()` and the preview share the weight too (`dockSeedWeight`). - Tabs: the strip another window's tab is carried over opens a slot of that tab's width (`TabStripScope.dropGhost` → `TabDropGhost`, drawn by `TabDropGhostCard`, sized from the source slot by `draggedTabWidth`), and the slot is dropped from the composition the frame the tab lands in it, so the card is seen becoming the tab rather than shutting beside it. Custom strips draw `dropGhost` themselves; `jewel-tabs-demo` inserts a placeholder `TabData.Editor`.
…ides the slot it aims at Wherever the tab was grabbed, the ghost hangs below the pointer: the slot it is aimed at in another window's strip sits where the pointer is, and a card carried by a lower grab point covered it. The torn-off window inherits the same offset and lands where the ghost was. Screen-placement path only; the Wayland drag icon is the compositor's.
… never hides the slot it aims at" This reverts commit b37c9a3.
…not the pointer A tab dragged towards another window's strip only previewed the drop once the pointer itself was inside it, so the card hung over the strip — hiding the very slot it was aiming at — before anything lit up. The dock zones already resolve from the dragged satellite's own edge; tabs now do the same. `TabWorkspace.dropTargetAt(draggedScreenRectPx, pointerScreenPx, …)` takes the card as well as the pointer: a strip the pointer is in wins, else the first strip the card has reached. The tear-off session hands it the ghost rect, and a single-tab window's drag hands its own strip band — the window is what moves there, so a merge reads as soon as the two strips meet. The excluded group is dropped from the search rather than ending it, which is what a single-tab window needs: its own strip travels with the pointer and covers whatever it is over.
…drag no longer swallows **The card.** A tab strip can now preview the tab the pointer rests on, the way a browser does, and the app draws it: `TabHoverPreview` takes the whole composable, with `TabHoverPreviewCard` as a stock one to build on or replace. A strip written from scratch composes `TabHoverPreviewPopup` and needs nothing else — the anchor is the slot `Modifier.tabSlot` already publishes, which also publishes the hovered tab itself (`TabStripScope.hoveredTab`). The card is withheld in every case where it would be in the way, and the rule lives on `hoveredTab` so custom chrome inherits it: the selected tab, whose body is on screen already; a drag in flight, which passes the carried tab over every neighbour without pointing at any; a press, until the pointer has moved on; and the card itself, since reaching it means having left the tab. That last one has to be said explicitly — a popup surface takes the pointer off the window beneath it, so the tab never hears it leave. **The picture.** `TabWorkspace(captureThumbnails = true)` keeps a reduced snapshot of the body each tab last showed, for a card to draw (`TabEntry.thumbnail`, refreshed on demand with `captureThumbnail`). Off by default: it records the body into a layer and reads it back. A native embed draws outside the scene and is missing from the picture, which is documented. **The swallowed click.** The whole tab is a drag grip and it claims the press before the tab's own click gesture, so a click whose pointer drifts past the touch slop became a drag instead — and a drag that ended where it began left the strip exactly as it was, the click lost and the tab having wobbled for nothing. Lifting a tab now selects it, as a browser does on the press, so the click always lands and the tab being carried is always the one on screen. `examples/tabs-demo` shows the file path under the title, `jewel-tabs-demo` draws a card entirely in Jewel's own colours, and `reader-dock-demo` hangs a right-to-left card off the right edge of its seforim.
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
A
DockLayoutcould only stack the panels of one side at equal shares of one per-side extent. That is not the pane tree a reader-style app draws — navigation, contents and notes side by side on one edge, each with its own width and splitter, and a commentary strip under the text but not under the navigation. That layout is now expressible, and because every pane is a satellite it can still be torn into a window of its own.SatellitePlacement.Dockedcarries its ownextentandweight; both ride inSatelliteLayoutSnapshotand are driven bySatelliteWorkspace.setDockedExtent/setDockedWeight.DockLayout(sideOrder, layeredSides, …): sides nest outermost-first so a side can own the corners; a side is either split (panels share its length byweight, its thickness bydockExtent) or layered (each panel a full-length layer of its ownextent, with its own splitter) — the shape of a nested split-pane tree, without the tree.splitterandpanelslots hand the drawing to the caller;DockSplitterScope.dockSplitterHandle()carries the gesture, so a 1 dp divider with a wider overflowing grip works.DefaultSatelliteHeaderno longer imposes a height or a background on a docked panel.DockSide.Leftis the left of the screen in an RTL app and the splitters drag the right way.movableContentOf: no change of the layout (extent, weight, order, side,restore, side order, direction, a window resize) rebuilds a subtree, so a docked pane keeps its scroll position and itsremembers. Extents are fitted proportionally when the window is too small; the stored values come back with the room.dock()will apply — and the drop is hit-tested against that same rectangle. A zone is entered when the dragged satellite's own edge reaches it (the palette, not the pointer; the pointer stays a second trigger and the tie-break), and the side a panel already occupies is neither drawn nor droppable.examples/reader-dock-demo. The whole thing as a right-to-left book reader: layered right side with per-pane widths,sideOrderputting the right side outside the bottom one, the reader's own 1 dp + 5 dp-grip splitters and 32 dp hover headers, Classic / Islands styles, save / restore / reset of the layout.Public API changed on purpose (
apiDumpcommitted):Dockedgained two properties,DockLayoutfour parameters.Test plan
:decorated-window-tao:test— 435 unit tests, including newSatelliteDockedGeometryTest,DockLandingRectTest,DockZoneHintSidesTest,DockTargetFromDraggedRectTest; all new classes registered inTaoSceneTestBattery(GraalVM has no reflection):decorated-window-tao:taoHeadfulTest -Dnucleus.tao.headful.filter="dock layout"— 10 real-window cases (layered geometry, robot-driven splitter and weight drags, side order, RTL, no-rebuild, overflowing grip, drop on a layered side, lift-off, palette-edge targeting) + 13 monkeys (4 layout profiles × 3 seeds, plus a 400-action run on the reader profile)satellite,workspace,tab workspace,tab satellites(24 cases)ktlintCheck,detekt,apiCheck, and every dependent module and demo compiles