Skip to content

feat(tao): layered dock sides, per-panel sizes and app-owned dock chrome - #663

Draft
kdroidFilter wants to merge 13 commits into
nucleus-2.6from
feat/2.6-dock-layered-sides
Draft

feat(tao): layered dock sides, per-panel sizes and app-owned dock chrome#663
kdroidFilter wants to merge 13 commits into
nucleus-2.6from
feat/2.6-dock-layered-sides

Conversation

@kdroidFilter

Copy link
Copy Markdown
Collaborator

Summary

A DockLayout could 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.

  • Per-panel geometry. SatellitePlacement.Docked carries its own extent and weight; both ride in SatelliteLayoutSnapshot and are driven by SatelliteWorkspace.setDockedExtent / setDockedWeight.
  • Two arrangements per side. DockLayout(sideOrder, layeredSides, …): sides nest outermost-first so a side can own the corners; a side is either split (panels share its length by weight, its thickness by dockExtent) or layered (each panel a full-length layer of its own extent, with its own splitter) — the shape of a nested split-pane tree, without the tree.
  • App-owned chrome. The new splitter and panel slots hand the drawing to the caller; DockSplitterScope.dockSplitterHandle() carries the gesture, so a 1 dp divider with a wider overflowing grip works. DefaultSatelliteHeader no longer imposes a height or a background on a docked panel.
  • Right-to-left. Sides are physical: the layout composes LTR internally and restores the caller's direction for the content, the panels and the slots, so DockSide.Left is the left of the screen in an RTL app and the splitters drag the right way.
  • Nothing is rebuilt. Every panel and the content are 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 its remembers. Extents are fitted proportionally when the window is too small; the stored values come back with the room.
  • Coherent drop feedback. The highlight is drawn at the rectangle the release actually produces — the side's own band, inset behind existing layers, counting the dragged panel's side as already freed, at the width 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, sideOrder putting 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 (apiDump committed): Docked gained two properties, DockLayout four parameters.

Test plan

  • :decorated-window-tao:test — 435 unit tests, including new SatelliteDockedGeometryTest, DockLandingRectTest, DockZoneHintSidesTest, DockTargetFromDraggedRectTest; all new classes registered in TaoSceneTestBattery (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)
  • Neighbouring families green: satellite, workspace, tab workspace, tab satellites (24 cases)
  • Run under Xvfb + openbox, the CI Linux shape, as well as the desktop session
  • ktlintCheck, detekt, apiCheck, and every dependent module and demo compiles
  • macOS and Windows headful legs (CI)

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.
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