diff --git a/CLAUDE.md b/CLAUDE.md index 5da10110e..a9dd523d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,13 +37,13 @@ Published releases are `2.5.x` (latest tag `v2.5.0`). Do not treat `IDEAL_API.md - `graalvm-runtime` - GraalVM native-image bootstrap - `decorated-window-core` - Shared types, layout, styling (design-system agnostic) - `decorated-window-tao` - **The only window backend** — no-AWT window shell over the Rust `tao` crate via JNI (Metal on macOS, EGL on Linux, ANGLE/GLES on Windows), single native event-loop thread as `Dispatchers.Main` -- `decorated-window-tao` internals: `window/tao/workspace/` is the shared, `internal` core behind the multi-window archetypes — `WindowGroup` (membership, focus recency, pinning), `RelocatedContentHost` + `RelocatingSaveableStateRegistry` (`rememberSaveable` state that follows content between windows), `HostGeometry` (drop targets in physical screen px), `CrossWindowDrag` (one live drag, screen-space drag handle), `DragGhostWindow`, `ScreenPlacement` (`supportsScreenPlacement`: the native-Wayland gate — GDK reports every toplevel at `(0, 0)` and ignores moves, so anything that treats `outerBoundsPx()`'s origin as a screen coordinate must check it; the size half stays valid there), `TransferDrag` (the native-Wayland path of every cross-window gesture: the grip starts a platform **drag-and-drop** session carrying an in-process token (`TaoPrivateTransfer`, `SAME_APP` only), the window under the pointer resolves the drop in its *own* coordinates and records it on the session, and the source acts on that record when the session ends — inverted roles versus `ScreenDrag`, because the source is told nothing about where the pointer is; the drag icon is a reduced snapshot of the dragged palette or panel, taken through `TaoWindow.contentSnapshot`). `SatelliteWorkspace` (docking) and `TabWorkspace` (Chrome-like tabs) are both built on it — put new cross-window gestures there rather than duplicating the geometry or the drag bookkeeping. +- `decorated-window-tao` internals: `window/tao/workspace/` is the shared, `internal` core behind the multi-window archetypes — `WindowGroup` (membership, focus recency, pinning), `RelocatedContentHost` + `RelocatingSaveableStateRegistry` (`rememberSaveable` state that follows content between windows), `HostGeometry` (drop targets in physical screen px), `CrossWindowDrag` (one live drag, screen-space drag handle), `DragGhostWindow`, `ScreenPlacement` (the public capability is `TaoWindow.canPlaceOnScreen` — the native-Wayland gate — GDK reports every toplevel at `(0, 0)` and ignores moves, so anything that treats `outerBoundsPx()`'s origin as a screen coordinate must check it; the size half stays valid there; `warnScreenPlacementUnsupported` logs the gap once per process), `TransferDrag` (the native-Wayland path of every cross-window gesture: the grip starts a platform **drag-and-drop** session carrying an in-process token (`TaoPrivateTransfer`, `SAME_APP` only), the window under the pointer resolves the drop in its *own* coordinates and records it on the session, and the source acts on that record when the session ends — inverted roles versus `ScreenDrag`, because the source is told nothing about where the pointer is; the drag icon is a reduced snapshot of the dragged palette or panel, taken through `TaoWindow.contentSnapshot`). **Tab drag, two paths.** Where the app places its windows the gesture is `screenDragHandle` → `TabWorkspace.beginDrag` (ghost window, screen hit-test, tear-off; the drop resolves through `dropTargetAt(draggedScreenRectPx, pointerScreenPx, …)` — a strip the **card** has reached counts as entered, the pointer's own strip still winning, the same rule as the dock zones, and a single-tab window's drag hands its own strip band as the card), and the strip animates the reorder from `dragPointerScreenPx`. Where it cannot (native Wayland), the grip is `tabStripLocalDragHandle`: a **local** reorder driven by the pointer's travel in window px and resolved by `reorderTarget` (edge-crossing, RTL inferred from the slots), and the moment the pointer leaves the strip the gesture is handed to the platform's drag-and-drop session — `transferDragHandle(gesture = …)` takes a `TransferDragGesture` whose `onDrag` returns `true` to start it mid-gesture, from the *press* position (Compose refuses a point outside the source node). That handover is what gives every *other* window the pointer in its own coordinates, so their strips can preview the drop; nothing else can, since a client hears nothing about a pointer another window holds. `DragGhostWindow(popupFor = source)` is the preview that follows the pointer out of a compositor-placed window (`wl_subsurface`, parent-relative positions). The tab slot carries `noWindowDrag()`: the title bar's move is a compositor grab that swallows the gesture. **`TabStrip` motion** (`TabStripAnimation.kt`, a port of `sh.calvin.reorderable`'s `ReorderableRow` state machine): items are `key`ed on the tab id; a tab dragged along its **own** strip publishes no ghost (`TabTearOffDragSession` clears it while `dropPreview.group === entry.group`) and the strip draws it at the pointer's travel since the grab (`TabWorkspace.dragGrabScreenPx` / `dragPointerScreenPx`), a neighbour slides one tab-width aside (spring `StiffnessMediumLow`) when the carried tab's *edge* crosses its *centre*, and on release the session sets `pendingReorder` instead of reordering — the strip's `TabStripMotion.settle` slides the tab into the target slot, then `reorder()` + `rest()` in the same frame, so nothing jumps. The own-strip drop index is `reorderTarget` (edge-crossing rule, RTL inferred from the slots, same rule as the motion) and `insertionIndex` is direction-aware too (a right-to-left strip used to resolve every drop mirrored). Offsets are draw-time `graphicsLayer` translations, so `tabSlot` geometry is always the settled layout. Tabs open/close by width (`AnimatedVisibility`, 200 ms, `clip = false` so the carried card can leave its slot) and the stock close button delays `workspace.close` by the exit duration; `TabEntry.isEntering` marks a tab the strip has not shown yet. `TabWindows` has two app slots: `windowWrapper` wraps the whole window *including* its strip (per-window locals, background), `windowBodyWrapper` wraps only what is under the strip and is where window-level chrome goes (a `DockLayout`, activity bars) — composed at one call site for every window, so a tab change neither rebuilds it nor moves the body's relocation keys. `SatelliteWorkspace` (docking) and `TabWorkspace` (Chrome-like tabs) are both built on it — put new cross-window gestures there rather than duplicating the geometry or the drag bookkeeping. `DockLayout` (`window/tao/DockLayout.kt` + `DockSplitter.kt` + `DockTransferTarget.kt`) is the dock: sides nest in `sideOrder` (outermost first, default `DefaultDockSideOrder` = top, bottom, left, right — **not** `DockSide.entries`, whose declaration order is left, right, top, bottom), a side is either *split* (panels share its length by `Docked.weight` and its thickness by `dockExtent(side)`) or *layered* (`layeredSides`: each panel a full-length layer of its own `Docked.extent`, the way a nested split-pane tree looks), `splitter` / `panel` slots carry the app's own chrome (`DockSplitterScope.dockSplitterHandle()` is the gesture; an overflowing `requiredWidth` grip on a 1 dp line works), sides are physical and the layout forces LTR internally then restores the caller's direction for content/panels/slots, and every panel and the content are `movableContentOf` so no layout change (extent, weight, order, side, restore, side order, direction) rebuilds a subtree — the layout's inputs live in `DockLayoutState` as snapshot state because the bands are separate composables that strong skipping would otherwise skip. Extents are fitted proportionally when the window is too small (`fit`). Drop feedback lives in `DockZoneHints.kt` and **the rectangles it draws are the target**: it publishes them to `HostGeometry.zoneBoundsInWindowPx`, and `dockTargetAt(draggedScreenRectPx, pointerScreenPx)` → `dockSideEntered` resolves a drop against those, not against the window's edges — on a layered side the strip is inset behind the existing layers, and the window's own edge behind them is nothing. A zone is entered when the dragged **satellite's** edge (its window, or the tear-out ghost) is within one zone thickness of the zone's outer edge and overlaps it across the other axis — edge alignment, not overlap, or a full-height panel could never be torn out; the pointer inside a zone is a second trigger and the tie-break, else the smallest gap wins. The rects come from `DockLayoutState.landingRectPx`: the side's measured band, inside existing layers, counting the dragged panel's own side as already freed; `hintedSides` drops the side the panel is alone on in that window, so it is neither drawn nor droppable. **`dockSides`**: `Satellite(dockSides = …)` (default all four, empty = floating-only) is fixed at declaration and enforced everywhere — `dock()` and `restore()` refuse another side, `hintedSides` and `DockZoneHints` neither draw nor publish it, the drag sessions resolve through `dockTargetFor(entry, …)` and the Wayland target filters on `drag.entry.dockSides`, and the default header hides its Dock action for a floating-only palette. **`floatable = false`** is the opposite knob — a fixed panel: `undock()` refuses it, a `restore()` that floats it is ignored, the docked drag publishes no tear-out ghost and a release off every zone leaves it in place, the default header drops its Float action, and the declaration requires a docked `initialPlacement`. **`reorderable = false`** pins the rank: `dock(order)` is ignored for it (it takes the declared rank back), `insertInStack` pushes any other panel past the last pinned one (`pinnedFloor`), `dropSlotsPx` returns nothing for a pinned dragged panel and keeps the forbidden ranks as **empty** slots so a slot's index is still its rank, `hintedSides` drops its own side, `targetFor` strips the rank off a target, and `satelliteDragHandle` is inert when `canBeDragged` says a drag could not end anywhere. **Telling the two gestures apart** (what an app adapts its UI to, #663 review): `TaoWindow.canPlaceOnScreen` is the public capability (branch on it, not on `isNativeWaylandSurface`), `SatelliteScope.isCompositorPlaced` is the same answer for the window the chrome is composed in (the floating scope reads the satellite's own window through a lambda since the scope outlives it; the docked scope reads `entry.dockHost`), `SatelliteCaptionStripWidth` + the `floatingCaption` slot of `Satellite` are the strip the title bar leaves to the compositor's move — reserved and composed **only** where `isCompositorPlaced`, so an app never has to guess a width or accidentally claim the only area that can move the palette — and `SatelliteWorkspace.dragKind` (`Window` / `Transfer`) says how a drag in flight is carried, which is what tells preview code whether `dragGhost` will ever be published. `reader-dock-demo`: the book tree and the contents are `floatable = false` + `reorderable = false` + `dockSides = setOf(Right)` — furniture, and no pane can be dropped in front of them. **Ranks**: `Docked.order` is kept contiguous from 0 per (host, side) by `dock()` / `undock()` (`dock(order)` inserts at that index, `null` = the rank the entry last held on that side, remembered in `SatelliteEntry.dockMemory`, else the end), and a side with panels publishes `DockDropZone.slots` — one rect per rank, cut at the neighbours' centres, the dragged panel excluded — so `DockTarget.order` is the rank under the pointer (`dockSlotAt`), the own rank (`ownTarget`) being no target; a pointer over a stack beats a strip across its corner. `dropAt` converts a shown-rank into the full rank (closed panels keep theirs). The Wayland DnD path (`DockTransferTarget`) hit-tests the same published zones. A hand-driven `beginDrag` session must wait for the zones to be published before its first sample, or it resolves against the bare edges. `dock()` and the preview share one width (`dockSeedExtent`) and one weight (`dockSeedWeight`), so what lights up is what the release produces. **One drop preview everywhere** (`DragPreviewDefaults.kt`): the card that follows the pointer (`SatelliteGhostCard` / `TabGhostCard` on `DragPreviewSurface`) is also drawn on the space the release fills — the dock draws it at `DockLayoutState.dropRectPx(side, dragged, order, extentPx)` (empty side: the edge strip; layered: the layer at that rank; split: the share the re-divided weights give it, dividers counted), the tab strip opens a slot of the dragged tab's width (`TabStripScope.dropGhost` → `TabDropGhost`, `TabDropGhostCard`; `TabWorkspace.draggedTabWidth` reads the source slot) — and the sides merely on offer are the same surface at `hint` intensity. No insertion bars, no drop-indicator lines; a custom strip draws `dropGhost` itself, as `jewel-tabs-demo` does with a placeholder `TabData.Editor`. Headful coverage: `DockLayoutHeadfulCases` (robot splitter drags) + `DockLayoutMonkeyHeadfulCases` (profiles × seeds, `-Dnucleus.tao.headful.filter="dock layout"`). - `decorated-window-jewel` - Jewel (IntelliJ theme) integration - `decorated-window-material2` - Material 2 color mapping - `decorated-window-material3` - Material 3 color mapping - `plugin-build/plugin` - Gradle plugin for packaging & distribution - `buildSrc` - Build-only convention plugins (`nucleus.native-module`: the shared `buildNative*` wiring for every JNI module) -- `examples/` - Demo & sample applications: `nucleus-demo` (flagship), `compose-demo`, `tao-demo`, `swing-tao-demo`, `jewel-demo`, `cmp-demo` (KMP), `window-scaffold-demo`, `satellite-demo` (satellite workspace: floating palettes following the focused document, docking into a `DockLayout`, drag-to-dock, layout snapshots), `tabs-demo` (Chrome-like tabs: tear-off, merge, reorder, state following a tab between windows, layout snapshots), `jewel-tabs-demo` (the same tab workspace wearing Jewel's `TabStrip` / `TabData.Editor` chrome), `tab-satellites-demo` (the two archetypes composed: one `SatelliteWorkspace` per tab window, palettes drawing the window's selected tab), `zstd-demo`, `scheduler-demo`, `service-management-demo`, `system-info-demo`, `fs-watcher-smoke`, `orphan-reflect-smoke`, `extra-launcher-demo`, `tao-native-test` (GraalVM + SLF4J fixture), `benchmark-demo` (JIT-vs-GraalVM-O3, ports under `ports/`), `gstreamer-demo` / `mediafoundation-demo` / `avfoundation-demo` (platform video into a `TextureView`), plus `shared` (Compose helper used by the tao demos). `native-proxy` and `spellcheck` directories on disk are **not** on `main` — ignore them unless the matching feature branch is checked out. +- `examples/` - Demo & sample applications: `nucleus-demo` (flagship), `compose-demo`, `tao-demo`, `swing-tao-demo`, `jewel-demo`, `cmp-demo` (KMP), `window-scaffold-demo`, `satellite-demo` (satellite workspace: floating palettes following the focused document, docking into a `DockLayout`, drag-to-dock, layout snapshots), `tabs-demo` (Chrome-like tabs: tear-off, merge, reorder, state following a tab between windows, layout snapshots), `jewel-tabs-demo` (the same tab workspace wearing Jewel's `TabStrip` / `TabData.Editor` chrome), `tab-satellites-demo` (the two archetypes composed: one `SatelliteWorkspace` per tab window, palettes drawing the window's selected tab), `reader-dock-demo` (a right-to-left book reader composing **both** archetypes: its seforim are tabs and its every pane is a satellite — 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 hover headers, Classic/Islands styles, one dock per tab window hung on `TabWindows(windowBodyWrapper)` so the strip stays the top of the window and a tab change touches no panel — the target layout of SeforimApp), `zstd-demo`, `scheduler-demo`, `service-management-demo`, `system-info-demo`, `fs-watcher-smoke`, `orphan-reflect-smoke`, `extra-launcher-demo`, `tao-native-test` (GraalVM + SLF4J fixture), `benchmark-demo` (JIT-vs-GraalVM-O3, ports under `ports/`), `gstreamer-demo` / `mediafoundation-demo` / `avfoundation-demo` (platform video into a `TextureView`), plus `shared` (Compose helper used by the tao demos). `native-proxy` and `spellcheck` directories on disk are **not** on `main` — ignore them unless the matching feature branch is checked out. ## Build & Run diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index 02103417b..6c2322dac 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -176,6 +176,27 @@ public abstract interface class dev/nucleusframework/window/tao/ApplicationScope public abstract fun getTaoApplication ()Ldev/nucleusframework/window/tao/TaoApplication; } +public final class dev/nucleusframework/window/tao/ComposableSingletons$DockLayoutKt { + public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$DockLayoutKt; + public fun ()V + public final fun getLambda$-1338913852$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-2018802953$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-795381038$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1525993791$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; +} + +public final class dev/nucleusframework/window/tao/ComposableSingletons$DockZoneHintsKt { + public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$DockZoneHintsKt; + public fun ()V + public final fun getLambda$-928659135$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function2; +} + +public final class dev/nucleusframework/window/tao/ComposableSingletons$DragPreviewDefaultsKt { + public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$DragPreviewDefaultsKt; + public fun ()V + public final fun getLambda$2034763238$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; +} + public final class dev/nucleusframework/window/tao/ComposableSingletons$NativeViewKt { public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$NativeViewKt; public fun ()V @@ -185,21 +206,29 @@ public final class dev/nucleusframework/window/tao/ComposableSingletons$NativeVi public final class dev/nucleusframework/window/tao/ComposableSingletons$SatelliteKt { public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$SatelliteKt; public fun ()V - public final fun getLambda$1206832291$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$660144339$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-381801716$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-608241131$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$1877818949$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; +} + +public final class dev/nucleusframework/window/tao/ComposableSingletons$TabHoverPreviewKt { + public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$TabHoverPreviewKt; + public fun ()V + public final fun getLambda$-1555734992$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; } public final class dev/nucleusframework/window/tao/ComposableSingletons$TabStripKt { public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$TabStripKt; public fun ()V - public final fun getLambda$577364127$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$737531015$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; } public final class dev/nucleusframework/window/tao/ComposableSingletons$TabWindowsKt { public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$TabWindowsKt; public fun ()V - public final fun getLambda$1323285147$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$328928826$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-1651313828$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$560415099$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$761178795$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; } public final class dev/nucleusframework/window/tao/D3D11TestTextureProducer : java/lang/AutoCloseable { @@ -259,7 +288,8 @@ public final class dev/nucleusframework/window/tao/DmaBufTestTextureProducer$Com } public final class dev/nucleusframework/window/tao/DockLayoutKt { - public static final fun DockLayout (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/TaoWindow;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun DockLayout (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/TaoWindow;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun getDefaultDockSideOrder ()Ljava/util/List; public static final fun getDockPanelHeaderHeight ()F } @@ -269,20 +299,36 @@ public final class dev/nucleusframework/window/tao/DockSide : java/lang/Enum { public static final field Right Ldev/nucleusframework/window/tao/DockSide; public static final field Top Ldev/nucleusframework/window/tao/DockSide; public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getOpposite ()Ldev/nucleusframework/window/tao/DockSide; public final fun isVertical ()Z public static fun valueOf (Ljava/lang/String;)Ldev/nucleusframework/window/tao/DockSide; public static fun values ()[Ldev/nucleusframework/window/tao/DockSide; } +public final class dev/nucleusframework/window/tao/DockSplitterKt { + public static final fun DefaultDockSplitter (Ldev/nucleusframework/window/tao/DockSplitterScope;Landroidx/compose/runtime/Composer;I)V + public static final fun getDockSplitterThickness ()F +} + +public abstract interface class dev/nucleusframework/window/tao/DockSplitterScope { + public abstract fun dockSplitterHandle (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; + public abstract fun getOrientation ()Landroidx/compose/foundation/gestures/Orientation; + public abstract fun getPanel ()Ldev/nucleusframework/window/tao/SatelliteEntry; + public abstract fun getSide ()Ldev/nucleusframework/window/tao/DockSide; +} + public final class dev/nucleusframework/window/tao/DockTarget { public static final field $stable I - public fun (Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/DockSide;)V + public fun (Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;)V + public synthetic fun (Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ldev/nucleusframework/window/tao/TaoWindow; public final fun component2 ()Ldev/nucleusframework/window/tao/DockSide; - public final fun copy (Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/DockSide;)Ldev/nucleusframework/window/tao/DockTarget; - public static synthetic fun copy$default (Ldev/nucleusframework/window/tao/DockTarget;Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/DockSide;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/DockTarget; + public final fun component3 ()Ljava/lang/Integer; + public final fun copy (Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;)Ldev/nucleusframework/window/tao/DockTarget; + public static synthetic fun copy$default (Ldev/nucleusframework/window/tao/DockTarget;Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/DockTarget; public fun equals (Ljava/lang/Object;)Z public final fun getHost ()Ldev/nucleusframework/window/tao/TaoWindow; + public final fun getOrder ()Ljava/lang/Integer; public final fun getSide ()Ldev/nucleusframework/window/tao/DockSide; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -454,6 +500,14 @@ public final class dev/nucleusframework/window/tao/OverlayInteractionModifierKt public static synthetic fun consumeOverlayPointerEvents$default (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/input/pointer/PointerIcon;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; } +public final class dev/nucleusframework/window/tao/SatelliteDragKind : java/lang/Enum { + public static final field Transfer Ldev/nucleusframework/window/tao/SatelliteDragKind; + public static final field Window Ldev/nucleusframework/window/tao/SatelliteDragKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/nucleusframework/window/tao/SatelliteDragKind; + public static fun values ()[Ldev/nucleusframework/window/tao/SatelliteDragKind; +} + public abstract interface class dev/nucleusframework/window/tao/SatelliteDragOrigin { } @@ -478,18 +532,22 @@ public abstract interface class dev/nucleusframework/window/tao/SatelliteDragSes public final class dev/nucleusframework/window/tao/SatelliteEntry { public static final field $stable I public final fun getDockHost ()Ldev/nucleusframework/window/tao/TaoWindow; + public final fun getDockSides ()Ljava/util/Set; public final fun getId ()Ljava/lang/String; public final fun getPlacement ()Ldev/nucleusframework/window/tao/SatellitePlacement; public final fun getPreferredDockSide ()Ldev/nucleusframework/window/tao/DockSide; public final fun getTitle ()Ljava/lang/String; public final fun getWindowState ()Ldev/nucleusframework/window/tao/SatelliteWindowState; public final fun isDocked ()Z + public final fun isFloatable ()Z public final fun isOpen ()Z + public final fun isReorderable ()Z } public final class dev/nucleusframework/window/tao/SatelliteKt { public static final fun DefaultSatelliteHeader (Ldev/nucleusframework/window/tao/SatelliteScope;Landroidx/compose/runtime/Composer;I)V - public static final fun Satellite (Ldev/nucleusframework/window/tao/ApplicationScope;Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement;ZZZLandroidx/compose/runtime/CompositionLocalContext;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun Satellite (Ldev/nucleusframework/window/tao/ApplicationScope;Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement;ZLjava/util/Set;ZZZZLandroidx/compose/runtime/CompositionLocalContext;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun getSatelliteCaptionStripWidth ()F public static final fun satelliteDragHandle (Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/SatelliteScope;)Landroidx/compose/ui/Modifier; } @@ -512,15 +570,19 @@ public abstract interface class dev/nucleusframework/window/tao/SatellitePlaceme public final class dev/nucleusframework/window/tao/SatellitePlacement$Docked : dev/nucleusframework/window/tao/SatellitePlacement { public static final field $stable I - public fun (Ldev/nucleusframework/window/tao/DockSide;I)V - public synthetic fun (Ldev/nucleusframework/window/tao/DockSide;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ldev/nucleusframework/window/tao/DockSide;ILandroidx/compose/ui/unit/Dp;FILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ldev/nucleusframework/window/tao/DockSide;ILandroidx/compose/ui/unit/Dp;FLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ldev/nucleusframework/window/tao/DockSide; public final fun component2 ()I - public final fun copy (Ldev/nucleusframework/window/tao/DockSide;I)Ldev/nucleusframework/window/tao/SatellitePlacement$Docked; - public static synthetic fun copy$default (Ldev/nucleusframework/window/tao/SatellitePlacement$Docked;Ldev/nucleusframework/window/tao/DockSide;IILjava/lang/Object;)Ldev/nucleusframework/window/tao/SatellitePlacement$Docked; + public final fun component3-lTKBWiU ()Landroidx/compose/ui/unit/Dp; + public final fun component4 ()F + public final fun copy-37wYfng (Ldev/nucleusframework/window/tao/DockSide;ILandroidx/compose/ui/unit/Dp;F)Ldev/nucleusframework/window/tao/SatellitePlacement$Docked; + public static synthetic fun copy-37wYfng$default (Ldev/nucleusframework/window/tao/SatellitePlacement$Docked;Ldev/nucleusframework/window/tao/DockSide;ILandroidx/compose/ui/unit/Dp;FILjava/lang/Object;)Ldev/nucleusframework/window/tao/SatellitePlacement$Docked; public fun equals (Ljava/lang/Object;)Z + public final fun getExtent-lTKBWiU ()Landroidx/compose/ui/unit/Dp; public final fun getOrder ()I public final fun getSide ()Ldev/nucleusframework/window/tao/DockSide; + public final fun getWeight ()F public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -554,6 +616,7 @@ public abstract interface class dev/nucleusframework/window/tao/SatelliteScope { public static synthetic fun dock$default (Ldev/nucleusframework/window/tao/SatelliteScope;Ldev/nucleusframework/window/tao/DockSide;ILjava/lang/Object;)V public abstract fun getSatellite ()Ldev/nucleusframework/window/tao/SatelliteEntry; public abstract fun getWorkspace ()Ldev/nucleusframework/window/tao/SatelliteWorkspace; + public abstract fun isCompositorPlaced ()Z public abstract fun isDocked ()Z public fun undock ()V } @@ -614,9 +677,11 @@ public final class dev/nucleusframework/window/tao/SatelliteWorkspace { public final fun dock (Ljava/lang/String;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;Ldev/nucleusframework/window/tao/TaoWindow;)V public static synthetic fun dock$default (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;Ldev/nucleusframework/window/tao/TaoWindow;ILjava/lang/Object;)V public final fun dockExtent-u2uoSUM (Ldev/nucleusframework/window/tao/DockSide;)F + public final fun dockTargetAt-Uv8p0NA (Landroidx/compose/ui/geometry/Rect;J)Ldev/nucleusframework/window/tao/DockTarget; public final fun dockTargetAt-k-4lQ0M (J)Ldev/nucleusframework/window/tao/DockTarget; public final fun getDockPreview ()Ldev/nucleusframework/window/tao/DockTarget; public final fun getDragGhost ()Ldev/nucleusframework/window/tao/DragGhost; + public final fun getDragKind ()Ldev/nucleusframework/window/tao/SatelliteDragKind; public final fun getDraggedSatellite ()Ldev/nucleusframework/window/tao/SatelliteEntry; public final fun getFollowFocus ()Z public final fun getMembers ()Ljava/util/List; @@ -632,6 +697,8 @@ public final class dev/nucleusframework/window/tao/SatelliteWorkspace { public final fun restore (Ldev/nucleusframework/window/tao/SatelliteLayoutSnapshot;)V public final fun satellite (Ljava/lang/String;)Ldev/nucleusframework/window/tao/SatelliteEntry; public final fun setDockExtent-3ABfNKs (Ldev/nucleusframework/window/tao/DockSide;F)V + public final fun setDockedExtent-3ABfNKs (Ljava/lang/String;F)V + public final fun setDockedWeight (Ljava/lang/String;F)V public final fun setVisible (Z)V public final fun snapshot ()Ldev/nucleusframework/window/tao/SatelliteLayoutSnapshot; public final fun toggle (Ljava/lang/String;)V @@ -681,6 +748,22 @@ public abstract interface class dev/nucleusframework/window/tao/TabDragSession { public abstract fun update-k-4lQ0M (J)V } +public final class dev/nucleusframework/window/tao/TabDropGhost { + public static final field $stable I + public synthetic fun (IFLjava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2-D9Ej5fM ()F + public final fun component3 ()Ljava/lang/String; + public final fun copy-lG28NQ4 (IFLjava/lang/String;)Ldev/nucleusframework/window/tao/TabDropGhost; + public static synthetic fun copy-lG28NQ4$default (Ldev/nucleusframework/window/tao/TabDropGhost;IFLjava/lang/String;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/TabDropGhost; + public fun equals (Ljava/lang/Object;)Z + public final fun getIndex ()I + public final fun getTitle ()Ljava/lang/String; + public final fun getWidth-D9Ej5fM ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/nucleusframework/window/tao/TabDropTarget { public static final field $stable I public fun (Ldev/nucleusframework/window/tao/TabWindowGroup;I)V @@ -699,6 +782,7 @@ public final class dev/nucleusframework/window/tao/TabEntry { public static final field $stable I public final fun getGroup ()Ldev/nucleusframework/window/tao/TabWindowGroup; public final fun getId ()Ljava/lang/String; + public final fun getThumbnail ()Landroidx/compose/ui/graphics/ImageBitmap; public final fun getTitle ()Ljava/lang/String; public final fun isSelected ()Z } @@ -723,6 +807,38 @@ public final class dev/nucleusframework/window/tao/TabGroupSnapshot { public fun toString ()Ljava/lang/String; } +public final class dev/nucleusframework/window/tao/TabHoverPreview { + public static final field $stable I + public static final field Companion Ldev/nucleusframework/window/tao/TabHoverPreview$Companion; + public synthetic fun (JJZLkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JJZLkotlin/jvm/functions/Function3;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getContent ()Lkotlin/jvm/functions/Function3; + public final fun getDelay-UwyO8pc ()J + public final fun getNativeLayer ()Z + public final fun getOffset-RKDOV3M ()J +} + +public final class dev/nucleusframework/window/tao/TabHoverPreview$Companion { + public final fun getDefault ()Ldev/nucleusframework/window/tao/TabHoverPreview; +} + +public final class dev/nucleusframework/window/tao/TabHoverPreviewKt { + public static final fun TabHoverPreviewCard (Ldev/nucleusframework/window/tao/TabHoverPreviewScope;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun TabHoverPreviewPopup (Ldev/nucleusframework/window/tao/TabStripScope;Ldev/nucleusframework/window/tao/TabHoverPreview;Landroidx/compose/runtime/Composer;II)V + public static final fun getHoveredTab (Ldev/nucleusframework/window/tao/TabStripScope;)Ldev/nucleusframework/window/tao/TabEntry; +} + +public abstract interface class dev/nucleusframework/window/tao/TabHoverPreviewScope { + public abstract fun getGroup ()Ldev/nucleusframework/window/tao/TabWindowGroup; + public abstract fun getTab ()Ldev/nucleusframework/window/tao/TabEntry; + public fun getThumbnail ()Landroidx/compose/ui/graphics/ImageBitmap; + public abstract fun getWorkspace ()Ldev/nucleusframework/window/tao/TabWorkspace; +} + +public final class dev/nucleusframework/window/tao/TabHoverPreviewScope$DefaultImpls { + public static fun getThumbnail (Ldev/nucleusframework/window/tao/TabHoverPreviewScope;)Landroidx/compose/ui/graphics/ImageBitmap; +} + public final class dev/nucleusframework/window/tao/TabLayoutSnapshot { public static final field $stable I public fun (Ljava/util/List;)V @@ -747,9 +863,18 @@ public final class dev/nucleusframework/window/tao/TabScope$DefaultImpls { public static fun select (Ldev/nucleusframework/window/tao/TabScope;)V } -public final class dev/nucleusframework/window/tao/TabStripKt { - public static final fun TabStrip (Ldev/nucleusframework/window/tao/TabStripScope;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +public final class dev/nucleusframework/window/tao/TabStripAnimationKt { + public static final fun getTabReorderAnimation ()Landroidx/compose/animation/core/AnimationSpec; +} + +public final class dev/nucleusframework/window/tao/TabStripDragKt { public static final fun tabDragHandle (Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/TabWorkspace;Ldev/nucleusframework/window/tao/TabEntry;)Landroidx/compose/ui/Modifier; +} + +public final class dev/nucleusframework/window/tao/TabStripKt { + public static final fun TabDropGhostCard (Ldev/nucleusframework/window/tao/TabDropGhost;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun TabStrip (Ldev/nucleusframework/window/tao/TabStripScope;Landroidx/compose/ui/Modifier;Landroidx/compose/animation/core/AnimationSpec;Ldev/nucleusframework/window/tao/TabHoverPreview;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun getDropGhost (Ldev/nucleusframework/window/tao/TabStripScope;)Ldev/nucleusframework/window/tao/TabDropGhost; public static final fun tabSlot (Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/TabWindowGroup;I)Landroidx/compose/ui/Modifier; public static final fun tabStripGeometry (Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/TabWorkspace;Ldev/nucleusframework/window/tao/TabWindowGroup;)Landroidx/compose/ui/Modifier; } @@ -776,19 +901,23 @@ public final class dev/nucleusframework/window/tao/TabWindowGroup { public final class dev/nucleusframework/window/tao/TabWindowsKt { public static final fun Tab (Ldev/nucleusframework/window/tao/ApplicationScope;Ldev/nucleusframework/window/tao/TabWorkspace;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V - public static final fun TabWindows (Ldev/nucleusframework/window/tao/ApplicationScope;Ldev/nucleusframework/window/tao/TabWorkspace;Landroidx/compose/runtime/CompositionLocalContext;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final fun TabWindows (Ldev/nucleusframework/window/tao/ApplicationScope;Ldev/nucleusframework/window/tao/TabWorkspace;Landroidx/compose/runtime/CompositionLocalContext;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V } public final class dev/nucleusframework/window/tao/TabWorkspace { public static final field $stable I public static final field Companion Ldev/nucleusframework/window/tao/TabWorkspace$Companion; - public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JZLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun beginDrag-0AR0LA0 (Ljava/lang/String;Ldev/nucleusframework/window/tao/TabDragOrigin;J)Ldev/nucleusframework/window/tao/TabDragSession; + public final fun captureThumbnail (Ljava/lang/String;)V public final fun close (Ljava/lang/String;)V public final fun dropTargetAt-9KIMszo (JLdev/nucleusframework/window/tao/TabEntry;Ldev/nucleusframework/window/tao/TabWindowGroup;)Ldev/nucleusframework/window/tao/TabDropTarget; public static synthetic fun dropTargetAt-9KIMszo$default (Ldev/nucleusframework/window/tao/TabWorkspace;JLdev/nucleusframework/window/tao/TabEntry;Ldev/nucleusframework/window/tao/TabWindowGroup;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/TabDropTarget; + public final fun dropTargetAt-ubNVwUQ (Landroidx/compose/ui/geometry/Rect;JLdev/nucleusframework/window/tao/TabEntry;Ldev/nucleusframework/window/tao/TabWindowGroup;)Ldev/nucleusframework/window/tao/TabDropTarget; + public static synthetic fun dropTargetAt-ubNVwUQ$default (Ldev/nucleusframework/window/tao/TabWorkspace;Landroidx/compose/ui/geometry/Rect;JLdev/nucleusframework/window/tao/TabEntry;Ldev/nucleusframework/window/tao/TabWindowGroup;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/TabDropTarget; public final fun getActiveGroup ()Ldev/nucleusframework/window/tao/TabWindowGroup; + public final fun getCaptureThumbnails ()Z public final fun getDefaultWindowSize-MYxV2XQ ()J public final fun getDragGhost ()Ldev/nucleusframework/window/tao/TabDragGhost; public final fun getDraggedTab ()Ldev/nucleusframework/window/tao/TabEntry; @@ -814,7 +943,7 @@ public final class dev/nucleusframework/window/tao/TabWorkspace$Companion { } public final class dev/nucleusframework/window/tao/TabWorkspaceKt { - public static final fun rememberTabWorkspace-UBP6k7g (JLandroidx/compose/runtime/Composer;II)Ldev/nucleusframework/window/tao/TabWorkspace; + public static final fun rememberTabWorkspace-IbIYxLY (JZLandroidx/compose/runtime/Composer;II)Ldev/nucleusframework/window/tao/TabWorkspace; } public final class dev/nucleusframework/window/tao/TaoA11yAction { @@ -1204,6 +1333,7 @@ public final class dev/nucleusframework/window/tao/TaoWindow { public final fun exportXdgForeignHandle (J)Ldev/nucleusframework/window/tao/XdgForeignExport; public static synthetic fun exportXdgForeignHandle$default (Ldev/nucleusframework/window/tao/TaoWindow;JILjava/lang/Object;)Ldev/nucleusframework/window/tao/XdgForeignExport; public final fun focus ()V + public final fun getCanPlaceOnScreen ()Z public final fun getHandle ()J public final fun getNativeHandle ()J public final fun getNsWindowHandle ()Ljava/lang/Long; diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockLayout.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockLayout.kt index 1703dab9c..1d4f75bff 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockLayout.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockLayout.kt @@ -1,12 +1,7 @@ package dev.nucleusframework.window.tao -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.draganddrop.dragAndDropTarget -import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -16,49 +11,69 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draganddrop.DragAndDropEvent -import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.tao.workspace.HostGeometry import dev.nucleusframework.window.tao.workspace.RelocatedContentHost -import dev.nucleusframework.window.tao.workspace.positionInWindowPx import dev.nucleusframework.window.tao.workspace.publishHostGeometry import dev.nucleusframework.window.tao.workspace.rememberHostGeometry /** * Lays [content] out with the satellites docked into this window around it. * - * Panels attach to the four edges of the layout ([DockSide]); the ones on a - * side share it equally, in [SatellitePlacement.Docked.order], and a splitter - * between the side and the content drags that side's - * [SatelliteWorkspace.dockExtent]. With nothing docked — or while the - * workspace is not [SatelliteWorkspace.visible] — the layout is just - * [content]. + * Panels attach to the four edges of the layout ([DockSide]). The sides nest + * in [sideOrder], outermost first: the first side runs the full length of the + * layout and owns its corners, the next one runs the length that is left, and + * so on down to [content]. The default ([DefaultDockSideOrder] — top, bottom, + * left, right) is the classic border layout; a reader that wants its navigation on the right at + * full height and its commentary strip under the text *and* the left panel + * says `listOf(Right, Bottom, Left, Top)`. + * + * The panels on one side share it in one of two ways: + * + * - **Split** (the default): they divide the side's length in proportion to + * their [SatellitePlacement.Docked.weight], one above the other on a + * vertical side, side by side on a horizontal one, and share the side's + * thickness, [SatelliteWorkspace.dockExtent]. A splitter between the side + * and the content drags that thickness; a divider between two panels moves + * their weights. + * - **Layered** ([layeredSides]): each panel is a full-length layer of its + * own [SatellitePlacement.Docked.extent], laid from the edge towards the + * content — three panels docked on a layered right side are three columns + * next to each other, each with its own splitter and width. This is the + * arrangement of a nested split-pane tree, without the tree. + * + * With nothing docked — or while the workspace is not + * [SatelliteWorkspace.visible] — the layout is just [content]. When the window + * is too small for what the extents ask, the panels along that axis are drawn + * proportionally smaller so the content keeps a minimum and nothing overflows; + * the extents themselves are kept and come back with the room. + * + * Sides are physical: the layout lays itself out left-to-right whatever the + * `LayoutDirection` in force, so [DockSide.Left] is the left edge of the + * screen in a right-to-left app too. The direction is restored for the + * content, the panels and the slots, which see the one the layout was + * composed in. * * Compose it inside a window that joined the workspace, typically as the body * of a `WindowScaffold`. The window it is composed in ([host], resolved from @@ -67,19 +82,49 @@ import dev.nucleusframework.window.tao.workspace.rememberHostGeometry * The layout is also the drop target for satellite drags * ([Modifier.satelliteDragHandle]): a strip of [SatelliteWorkspace.DockZoneWidth] * inside each edge lights up while a dragged satellite hovers it, and a panel - * dragged out of its dock is outlined under the pointer until released. + * dragged out of its dock is previewed under the pointer until released. The + * preview of the drop is the same card, drawn on the very space the release + * fills ([DockLayoutState.dropRectPx]). Over a side that already has panels, + * the pointer's place along the stack picks the rank the drop takes — the card + * is then the share it gets between the two panels it lands between — so the + * panels of a side are reordered by dragging one over the others; the rank it + * holds is no target. A panel docked again without a drag + * (`SatelliteScope.dock()`, [SatelliteWorkspace.dock] with no order) comes + * back to the rank it left. * * Each panel is the satellite's `header` above its `content`, composed here * in the host window's scene under the satellite's own saveable-state - * registry — see [Satellite]. + * registry — see [Satellite]. A panel keeps its composition — its `remember`s + * included — through every change of this layout: a splitter drag, a + * reorder, a move to another side, a [SatelliteWorkspace.restore], a new + * [sideOrder]. Only leaving the host (undocking, docking elsewhere, closing) + * disposes it. The same holds for [content]. + * + * @param sideOrder the four sides from the outermost in; every side exactly once. + * @param layeredSides the sides whose panels are layers rather than a split. + * @param splitter the drag handle drawn between a side and the content, and + * between two panels; [DefaultDockSplitter] is a plain bar in the window + * style's border colour. Apply [DockSplitterScope.dockSplitterHandle] to + * whatever the user is meant to grab. + * @param panel composed around each docked panel — its header over its + * content, handed in as the lambda's argument — to give it a frame, a card, + * a padding. Must invoke the lambda it is given. */ +@Suppress("LongParameterList") @Composable public fun DockLayout( workspace: SatelliteWorkspace, modifier: Modifier = Modifier, host: TaoWindow? = LocalTaoWindow.current, + sideOrder: List = DefaultDockSideOrder, + layeredSides: Set = emptySet(), + splitter: @Composable DockSplitterScope.() -> Unit = { DefaultDockSplitter() }, + panel: @Composable SatelliteScope.(panel: @Composable () -> Unit) -> Unit = { it() }, content: @Composable () -> Unit, ) { + require(sideOrder.size == DockSide.entries.size && sideOrder.toSet().size == DockSide.entries.size) { + "sideOrder must name each of the four sides exactly once, was $sideOrder" + } val containerSize = LocalWindowInfo.current.containerSize // Published so drags can be hit-tested against this layout on screen and // undocked windows placed over their panel. @@ -92,261 +137,575 @@ public fun DockLayout( entry.isOpen && entry.content != null && entry.dockHost === host && entry.isDocked } } - Box( - modifier - .publishHostGeometry(geometry, containerSize) - .dockTransferTarget(workspace, host, geometry), - ) { - DockScaffold(workspace, docked, containerSize, content) - if (host != null) DockZoneHints(workspace, host) + val direction = LocalLayoutDirection.current + val state = remember(workspace) { DockLayoutState(workspace) } + state.docked = docked + state.layeredSides = layeredSides + state.containerSize = containerSize + state.direction = direction + state.splitter = splitter + state.panel = panel + + // The content and every panel are movable, so a change of the layout's + // shape — a side that gains its first panel, a panel that changes side, a + // new side order — moves their subtrees instead of rebuilding them. + val latestContent by rememberUpdatedState(content) + val movableContent = + remember { + movableContentOf { + CompositionLocalProvider(LocalLayoutDirection provides state.direction) { latestContent() } + } + } + state.pruneMovables(docked) + + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Box( + modifier + .publishHostGeometry(geometry, containerSize) + .dockTransferTarget(workspace, host, geometry) + .onSizeChanged { state.layoutSize = it } + .onGloballyPositioned { state.layoutBoundsInWindowPx = it.boundsInWindow() }, + ) { + DockBand(state, sideOrder, 0, movableContent) + if (host != null) DockZoneHints(workspace, host, state) + } } } /** - * Makes the layout the drop target of a [SatelliteWorkspace.transferDrag]: - * the drag that rides the platform's DnD session where windows cannot be - * hit-tested from the source (native Wayland). The events arrive in this - * window's own coordinates, which is exactly what the source lacks, so the - * zone under the pointer is resolved here — previewed while hovering, recorded - * on the session at the drop for the source to act on when the session ends. + * What the bands, the panels and the splitters read: the layout's inputs as + * snapshot state, so the subtree that reads one recomposes when it changes — + * the bands are separate composables and would otherwise be skipped — and the + * gesture handlers, which run outside composition, read the current values. */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun Modifier.dockTransferTarget( - workspace: SatelliteWorkspace, - host: TaoWindow?, - geometry: HostGeometry?, -): Modifier { - if (host == null || geometry == null) return this - val target = remember(workspace, host, geometry) { DockTransferTarget(workspace, host, geometry) } - return dragAndDropTarget( - shouldStartDragAndDrop = { workspace.transferDrag != null }, - target = target, - ) -} +internal class DockLayoutState( + val workspace: SatelliteWorkspace, +) { + var docked: List by mutableStateOf(emptyList()) + var layeredSides: Set by mutableStateOf(emptySet()) + var containerSize: IntSize by mutableStateOf(IntSize.Zero) + var direction: LayoutDirection by mutableStateOf(LayoutDirection.Ltr) + var splitter: @Composable DockSplitterScope.() -> Unit by mutableStateOf({}) + var panel: @Composable SatelliteScope.(panel: @Composable () -> Unit) -> Unit by mutableStateOf({ it() }) + var layoutSize: IntSize by mutableStateOf(IntSize.Zero) + val stackLengthsPx = HashMap() -private class DockTransferTarget( - private val workspace: SatelliteWorkspace, - private val host: TaoWindow, - private val geometry: HostGeometry, -) : DragAndDropTarget { - override fun onEntered(event: DragAndDropEvent) = preview(event) - - override fun onMoved(event: DragAndDropEvent) = preview(event) - - override fun onExited(event: DragAndDropEvent) = clearPreview() - - override fun onEnded(event: DragAndDropEvent) = clearPreview() - - override fun onDrop(event: DragAndDropEvent): Boolean { - val drag = workspace.transferDrag ?: return false - val position = event.positionInWindowPx() - val zone = zoneAt(position) - val outcome = - when { - zone != null && zone != drag.own -> TransferDrop.Dock(zone) - // Back onto its own side, or onto the very panel it came from: - // the gesture was abandoned, not a tear-out. - zone != null || drag.isOwnPanel(position) -> TransferDrop.Stay - else -> return false + /** The layout's own rect and each side's band — the side plus everything inside it — in host window px. */ + var layoutBoundsInWindowPx: Rect by mutableStateOf(Rect.Zero) + val bandBoundsInWindowPx = mutableStateMapOf() + + /** + * Where the satellite [dragged] would land if dropped on [side], in the + * layout's own px: a strip of [thicknessPx] along the side's edge of its + * band — not of the whole layout, since an outer side owns the corners — + * pushed inwards past the layers already there on a layered side, where a + * new panel is a new innermost layer. On a split side that already has a + * stack the panel joins the stack, so the stack itself is the answer. + * + * A [dragged] panel that is the only one on *another* side of this same + * layout is counted as already gone: it frees its side, and the band it + * leaves behind is where the drop will actually be. Without that the + * preview would promise the layout as it stands mid-drag rather than the + * one the release produces. + */ + fun landingRectPx( + side: DockSide, + thicknessPx: Float, + joinsStack: Boolean, + dragged: SatelliteEntry? = null, + ): Rect { + val origin = layoutBoundsInWindowPx.topLeft + val band = bandPx(side, dragged) + val stack = + panelsOn(side) + .mapNotNull { it.dockedBoundsInWindowPx } + .takeIf { it.isNotEmpty() } + ?.reduce { acc, rect -> unionOf(acc, rect) } + ?.translate(-origin) + if (stack != null && joinsStack && !isLayered(side)) return stack + val inset = if (stack != null && isLayered(side)) stack else null + return when (side) { + DockSide.Left -> { + val left = inset?.right ?: band.left + Rect(left, band.top, left + thicknessPx, band.bottom) + } + DockSide.Right -> { + val right = inset?.left ?: band.right + Rect(right - thicknessPx, band.top, right, band.bottom) + } + DockSide.Top -> { + val top = inset?.bottom ?: band.top + Rect(band.left, top, band.right, top + thicknessPx) + } + DockSide.Bottom -> { + val bottom = inset?.top ?: band.bottom + Rect(band.left, bottom - thicknessPx, band.right, bottom) } - drag.drop = outcome - clearPreview() - return true + } } - private fun zoneAt(positionInWindowPx: Offset): DockTarget? { - val zonePx = SatelliteWorkspace.DockZoneWidth.value * geometry.scaleOrOne() - return dockSideAt(geometry.layoutBoundsInWindowPx, positionInWindowPx, zonePx)?.let { DockTarget(host, it) } + /** + * The ranks a panel dropped on [side] can take among the panels already + * shown there, as one rect per rank in rank order — in the layout's own + * px, like [landingRectPx]. Together the rects cover the side's stack and + * [stripPx], its drop strip: rank `k` is the region between the centres of + * the panels of ranks `k - 1` and `k`, the first reaching the side's own + * edge (a layered side) or the start of the band (a split side), the last + * running through the strip. The [dragged] panel is not counted — its + * neighbours' centres are the boundaries, so its own region is the rank it + * has now. Empty while no other panel is docked there, one has not been + * placed yet, or [dragged] is pinned to its rank: nothing to order + * against. A rank in front of a pinned panel is not on offer either — it + * is an empty rect, so the index of a slot is still the rank it stands + * for, and the first rank on offer covers the area of the ones dropped. + */ + fun dropSlotsPx( + side: DockSide, + stripPx: Rect, + dragged: SatelliteEntry?, + ): List { + // A pinned panel has one rank and it is not the user's to change. + if (dragged != null && !dragged.isReorderable) return emptyList() + val origin = layoutBoundsInWindowPx.topLeft + val panels = panelsOn(side).filter { it !== dragged } + if (panels.isEmpty()) return emptyList() + val rects = panels.map { (it.dockedBoundsInWindowPx ?: return emptyList()).translate(-origin) } + val band = (bandBoundsInWindowPx[side] ?: layoutBoundsInWindowPx).translate(-origin) + val layered = isLayered(side) + var region = rects.reduce(::unionOf).let { unionOf(it, stripPx) } + if (layered) { + // Out to the side's own edge: a drop past the outermost layer is the first rank. + region = + when (side) { + DockSide.Left -> region.copy(left = band.left) + DockSide.Right -> region.copy(right = band.right) + DockSide.Top -> region.copy(top = band.top) + DockSide.Bottom -> region.copy(bottom = band.bottom) + } + } + val alongX = side.isVertical == layered + val cuts = rects.map { if (alongX) it.center.x else it.center.y }.sorted() + val edges = + listOf(if (alongX) region.left else region.top) + cuts + listOf(if (alongX) region.right else region.bottom) + val ascending = + List(rects.size + 1) { index -> + if (alongX) { + Rect(edges[index], region.top, edges[index + 1], region.bottom) + } else { + Rect(region.left, edges[index], region.right, edges[index + 1]) + } + } + val byRank = if (ranksDescend(side)) ascending.asReversed() else ascending + // The ranks in front of a pinned panel are not on offer: a drop there + // would shift it. They stay in the list — the index of a slot is the + // rank it stands for — as empty rects, and the first rank on offer + // takes their area, so aiming at a pinned panel lands right behind it, + // which is where the drop actually goes. + val floor = workspace.pinnedFloor(panels) + if (floor <= 0) return byRank + return List(floor) { Rect.Zero } + byRank.take(floor + 1).reduce(::unionOf) + byRank.drop(floor + 1) } - private fun preview(event: DragAndDropEvent) { - val drag = workspace.transferDrag ?: return - workspace.dockPreview = zoneAt(event.positionInWindowPx())?.takeIf { it != drag.own } + /** + * The band of [side] in the layout's own px — the side plus everything + * inside it — grown over the space the [dragged] panel frees when it is + * the only one on another side of this layout: that band is where the + * drop actually lands, not the one measured mid-drag. + */ + private fun bandPx( + side: DockSide, + dragged: SatelliteEntry?, + ): Rect { + val origin = layoutBoundsInWindowPx.topLeft + val layout = layoutBoundsInWindowPx.translate(-origin) + val measured = (bandBoundsInWindowPx[side] ?: layoutBoundsInWindowPx).translate(-origin) + val leaving = dragged?.takeIf { it.isDocked && it !in panelsOn(side) && panelsOn(sideOf(it)).size == 1 } + val freed = leaving?.dockedBoundsInWindowPx?.translate(-origin) ?: return measured + return unionOf(measured, freed).intersect(layout) } - private fun clearPreview() { - if (workspace.dockPreview?.host === host) workspace.dockPreview = null + /** + * The space the [dragged] panel occupies once dropped at rank [order] on + * [side], in the layout's own px — what the drop preview is drawn on, so + * that what the user sees lit up is what the release produces. + * + * - A side with no other panel: the strip along its edge, [extentPx] + * thick ([landingRectPx]). + * - A layered side: a full-length layer of [extentPx], laid where rank + * [order] puts it — the layers of lower rank keep their thickness + * between it and the edge, the others move inwards to make room. + * - A split side: its share of the stack's length once the weights are + * re-divided with its own ([SatelliteWorkspace.dockSeedWeight]) among + * the others', at rank [order], dividers counted. + * + * The [dragged] panel is not counted among the others, as in + * [dropSlotsPx]. A `null` [order] is the rank [SatelliteWorkspace.dock] + * gives without one — the rank last held on that side, else the end — + * and a rank in front of a pinned panel is pushed past it, as the drop is. + */ + fun dropRectPx( + side: DockSide, + dragged: SatelliteEntry?, + order: Int?, + extentPx: Float, + ): Rect { + val origin = layoutBoundsInWindowPx.topLeft + val others = panelsOn(side).filter { it !== dragged } + val rects = others.map { it.dockedBoundsInWindowPx?.translate(-origin) }.filterNotNull() + // No other panel, or one not placed yet: the strip along the edge. + if (rects.size != others.size || others.isEmpty()) { + return landingRectPx(side, extentPx, joinsStack = true, dragged = dragged) + } + val floor = if (dragged?.isReorderable == false) 0 else workspace.pinnedFloor(others) + val rank = (order ?: dragged?.dockMemory?.get(side)?.order ?: others.size).coerceIn(floor, others.size) + val band = bandPx(side, dragged) + if (isLayered(side)) { + val alongX = side.isVertical + val thicknesses = rects.map { if (alongX) it.width else it.height }.toMutableList() + thicknesses.add(rank, extentPx) + val before = thicknesses.take(rank).sum() + return when (side) { + DockSide.Left -> Rect(band.left + before, band.top, band.left + before + extentPx, band.bottom) + DockSide.Right -> Rect(band.right - before - extentPx, band.top, band.right - before, band.bottom) + DockSide.Top -> Rect(band.left, band.top + before, band.right, band.top + before + extentPx) + DockSide.Bottom -> Rect(band.left, band.bottom - before - extentPx, band.right, band.bottom - before) + } + } + // A split side: the stack keeps its thickness and its length, and the + // panels — the dragged one among them — divide the length by weight, + // the dividers between them taking what they take today. + val all = panelsOn(side).mapNotNull { it.dockedBoundsInWindowPx?.translate(-origin) } + val stack = all.reduce(::unionOf) + val alongX = !side.isVertical + val length = if (alongX) stack.width else stack.height + val dividerPx = + if (all.size > 1) { + (length - all.sumOf { (if (alongX) it.width else it.height).toDouble() }.toFloat()).coerceAtLeast(0f) / + (all.size - 1) + } else { + 0f + } + val weights = others.map(::weightOf).toMutableList() + weights.add(rank, dragged?.let { workspace.dockSeedWeight(it, side) } ?: 1f) + val total = weights.sum() + val available = length - dividerPx * others.size + val start = weights.take(rank).sum() / total * available + dividerPx * rank + val share = weights[rank] / total * available + return if (alongX) { + Rect(stack.left + start, stack.top, stack.left + start + share, stack.bottom) + } else { + Rect(stack.left, stack.top + start, stack.right, stack.top + start + share) + } } - /** Whether [positionInWindowPx] is on the dragged panel itself, in this host. */ - private fun SatelliteTransferDrag.isOwnPanel(positionInWindowPx: Offset): Boolean = - (origin as? SatelliteDragOrigin.DockedPanel)?.host === host && - entry.dockedBoundsInWindowPx?.contains(positionInWindowPx) == true -} + /** Whether rank `0` sits at the high coordinate: the outer layer of a right or bottom layered side. */ + private fun ranksDescend(side: DockSide): Boolean = + isLayered(side) && (side == DockSide.Right || side == DockSide.Bottom) -/** - * The content with its docked panels around it, one stack per side. - * - * Every slot is composed unconditionally — a side with nothing docked emits an - * empty stack and an empty splitter. Compose identifies children by their - * position, so a conditional slot would move the content's subtree the first - * time a panel appears and destroy it: the document's scroll position, and - * every `remember` under it, would be lost on the first dock. - */ -@Composable -private fun DockScaffold( - workspace: SatelliteWorkspace, - docked: List, - containerSize: IntSize, - content: @Composable () -> Unit, -) { - val bySide = + /** One movable subtree per docked satellite, so a panel changing side keeps its composition. */ + private val movables = HashMap Unit>() + + fun movableOf(entry: SatelliteEntry): @Composable () -> Unit = + movables.getOrPut(entry) { movableContentOf { DockPanel(this, entry) } } + + fun pruneMovables(docked: List) { + movables.keys.retainAll(docked.toSet()) + } + + fun isLayered(side: DockSide): Boolean = side in layeredSides + + /** The side [entry] is docked on. */ + fun sideOf(entry: SatelliteEntry): DockSide = (entry.placement as SatellitePlacement.Docked).side + + fun panelsOn(side: DockSide): List = docked - .groupBy { (it.placement as SatellitePlacement.Docked).side } - .mapValues { (_, entries) -> - entries.sortedWith(compareBy({ (it.placement as SatellitePlacement.Docked).order }, { it.id })) - } - var layoutSize by remember { mutableStateOf(IntSize.Zero) } - - Column(Modifier.fillMaxSize().onSizeChanged { layoutSize = it }) { - DockSideStack(workspace, DockSide.Top, bySide[DockSide.Top].orEmpty(), containerSize) - DockSplitter(workspace, DockSide.Top, layoutSize, bySide[DockSide.Top] != null) - Row(Modifier.weight(1f).fillMaxWidth()) { - DockSideStack(workspace, DockSide.Left, bySide[DockSide.Left].orEmpty(), containerSize) - DockSplitter(workspace, DockSide.Left, layoutSize, bySide[DockSide.Left] != null) - Box(Modifier.weight(1f).fillMaxHeight()) { content() } - DockSplitter(workspace, DockSide.Right, layoutSize, bySide[DockSide.Right] != null) - DockSideStack(workspace, DockSide.Right, bySide[DockSide.Right].orEmpty(), containerSize) + .filter { (it.placement as SatellitePlacement.Docked).side == side } + .sortedWith(compareBy({ (it.placement as SatellitePlacement.Docked).order }, { it.id })) + + /** A layered panel's own thickness, falling back to the side's. */ + fun extentOf(entry: SatelliteEntry): Dp { + val docked = entry.placement as SatellitePlacement.Docked + return docked.extent ?: workspace.dockExtent(docked.side) + } + + /** Thickness taken by every panel on [side], in px. */ + fun sideThicknessPx( + side: DockSide, + density: Density, + ): Float { + val panels = panelsOn(side) + if (panels.isEmpty()) return 0f + val layered = isLayered(side) + return with(density) { + if (!layered) return@with workspace.dockExtent(side).toPx() + panels.sumOf { extentOf(it).toPx().toDouble() }.toFloat() } - DockSplitter(workspace, DockSide.Bottom, layoutSize, bySide[DockSide.Bottom] != null) - DockSideStack(workspace, DockSide.Bottom, bySide[DockSide.Bottom].orEmpty(), containerSize) + } + + /** + * The factor the thicknesses along one axis are drawn at so they fit: `1` + * while the panels leave [MinContentExtent] to the content, less once the + * window has shrunk under what the extents ask for. The stored extents are + * untouched — the layout gives them back as soon as there is room again — + * and the same rule holds for every panel, so a shrunk window shows the + * same proportions as the full one, like a split pane's percentages do. + */ + fun fit( + vertical: Boolean, + density: Density, + ): Float { + val along = if (vertical) layoutSize.width else layoutSize.height + if (along <= 0) return 1f + val sides = if (vertical) listOf(DockSide.Left, DockSide.Right) else listOf(DockSide.Top, DockSide.Bottom) + val total = sides.sumOf { sideThicknessPx(it, density).toDouble() }.toFloat() + val available = (along - with(density) { MinContentExtent.toPx() }).coerceAtLeast(0f) + return if (total > available && total > 0f) available / total else 1f + } + + /** The thickness [side] is drawn at: its own, fitted to the window. */ + @Composable + fun drawnSideExtent(side: DockSide): Dp = workspace.dockExtent(side) * fit(side.isVertical, LocalDensity.current) + + /** The thickness the layer [entry] is drawn at: its own, fitted to the window. */ + @Composable + fun drawnExtent(entry: SatelliteEntry): Dp { + val side = (entry.placement as SatellitePlacement.Docked).side + return extentOf(entry) * fit(side.isVertical, LocalDensity.current) + } + + /** + * Grows a thickness by [towardsContentPx], keeping [MinContentExtent] of + * the layout free along the axis once everything else on it is counted. + */ + fun clampThicknessPx( + side: DockSide, + currentPx: Float, + towardsContentPx: Float, + density: Density, + ): Float { + val along = if (side.isVertical) layoutSize.width else layoutSize.height + val others = sideThicknessPx(side, density) + sideThicknessPx(side.opposite, density) - currentPx + val maxPx = along - with(density) { MinContentExtent.toPx() } - others + var nextPx = currentPx + towardsContentPx + if (along > 0 && maxPx > 0f) nextPx = nextPx.coerceAtMost(maxPx) + return nextPx } } +/** One child of a band, keyed so the band keeps its subtree wherever it lands in the row. */ +private class BandItem( + val key: String, + val content: @Composable () -> Unit, +) + /** - * The four drop zones of this layout, shown while a satellite is being - * dragged anywhere in the workspace. + * The side [sideOrder]`[index]` around whatever is inside it: the next side, + * down to the content. * - * Every side is outlined as soon as the drag starts — that is what tells the - * user the gesture exists — and the one under the pointer fills in solid, at - * the width the panel will actually have once dropped. + * Every child is [key]ed, the content included, because Compose otherwise + * identifies children by their position: a side gaining its first panel would + * shift the content along the row and destroy its subtree — the document's + * scroll position, and every `remember` under it, lost on the first dock. + * With keys the subtrees move and nothing is rebuilt. */ @Composable -private fun BoxScope.DockZoneHints( - workspace: SatelliteWorkspace, - host: TaoWindow, +private fun DockBand( + state: DockLayoutState, + sideOrder: List, + index: Int, + content: @Composable () -> Unit, ) { - val dragged = workspace.draggedSatellite ?: return - val preview = workspace.dockPreview - val accent = LocalTitleBarStyle.current.colors.content - // Keeps the closed-hand cursor over the whole layout for the length of the - // drag: the grip itself is only under the pointer while the satellite - // floats, and a docked panel's header is left behind at the first move. - Box( - Modifier - .matchParentSize() - .pointerHoverIcon(TaoPointerIcons.Grabbing, overrideDescendants = true), - ) - for (side in DockSide.entries) { - val active = preview?.host === host && preview.side == side - // The width the drop will actually produce, which on a side that has - // no extent yet is the satellite's own size, not the default. - val extent = if (active) workspace.plannedDockExtent(dragged, side) else SatelliteWorkspace.DockZoneWidth - val alignment = - when (side) { - DockSide.Left -> Alignment.CenterStart - DockSide.Right -> Alignment.CenterEnd - DockSide.Top -> Alignment.TopCenter - DockSide.Bottom -> Alignment.BottomCenter + if (index == sideOrder.size) { + content() + return + } + val side = sideOrder[index] + val panels = state.panelsOn(side) + val inner: @Composable () -> Unit = { DockBand(state, sideOrder, index + 1, content) } + val layered = state.isLayered(side) + val outerToInner = if (layered) layeredItems(state, side, panels) else splitItems(state, side, panels) + val leading = side == DockSide.Left || side == DockSide.Top + val contentItem = BandItem(CONTENT_KEY, inner) + val children = if (leading) outerToInner + contentItem else listOf(contentItem) + outerToInner.asReversed() + // The band's rect is what a drop preview on this side is drawn against. + val measured = + Modifier.fillMaxSize().onGloballyPositioned { + state.bandBoundsInWindowPx[side] = it.boundsInWindow() + } + if (side.isVertical) { + Row(measured) { + for (item in children) { + key(item.key) { + if (item === contentItem) { + Box(Modifier.weight(1f).fillMaxHeight()) { item.content() } + } else { + item.content() + } + } } - val sizeModifier = - if (side.isVertical) { - Modifier.fillMaxHeight().width(extent) - } else { - Modifier.fillMaxWidth().height(extent) + } + } else { + Column(measured) { + for (item in children) { + key(item.key) { + if (item === contentItem) { + Box(Modifier.weight(1f).fillMaxWidth()) { item.content() } + } else { + item.content() + } + } } - Box( - sizeModifier - .align(alignment) - .background(accent.copy(alpha = if (active) ZONE_ACTIVE_ALPHA else ZONE_HINT_ALPHA)) - .dashedOutline(accent.copy(alpha = if (active) 1f else ZONE_OUTLINE_ALPHA), dashed = !active), - ) + } } } -/** A dashed (or solid) 1 dp outline, drawn rather than composed so it costs no layout. */ -private fun Modifier.dashedOutline( - color: Color, - dashed: Boolean, -): Modifier = - drawBehind { - val stroke = ZoneOutlineWidth.toPx() - drawRect( - color = color, - topLeft = Offset(stroke / 2f, stroke / 2f), - size = Size(size.width - stroke, size.height - stroke), - style = - Stroke( - width = stroke, - pathEffect = - if (dashed) { - PathEffect.dashPathEffect(floatArrayOf(ZoneDashOn.toPx(), ZoneDashOff.toPx())) - } else { - null - }, - ), +/** A layered side: each panel a layer of its own extent, its splitter on its content side. */ +private fun layeredItems( + state: DockLayoutState, + side: DockSide, + panels: List, +): List = + panels.flatMap { entry -> + listOf( + BandItem("panel:${entry.id}") { + val extent = state.drawnExtent(entry) + val sized = + if (side.isVertical) { + Modifier.fillMaxHeight().width( + extent, + ) + } else { + Modifier.fillMaxWidth().height(extent) + } + Box(sized) { state.movableOf(entry)() } + }, + BandItem("splitter:${entry.id}") { + val orientation = if (side.isVertical) Orientation.Horizontal else Orientation.Vertical + val scope = + remember(state, side, entry) { + DockSplitterScopeImpl(side, orientation, entry) { deltaPx, density -> + val currentPx = with(density) { state.extentOf(entry).toPx() } + val nextPx = state.clampThicknessPx(side, currentPx, towardsContent(side, deltaPx), density) + state.workspace.setDockedExtent(entry.id, with(density) { nextPx.toDp() }) + } + } + SplitterSlot(state, scope) + }, ) } +/** A split side: one stack sharing the side's extent, then the splitter that drags it. */ +private fun splitItems( + state: DockLayoutState, + side: DockSide, + panels: List, +): List { + if (panels.isEmpty()) return emptyList() + return listOf( + BandItem("stack:$side") { SplitStack(state, side, panels) }, + BandItem("splitter:$side") { + val orientation = if (side.isVertical) Orientation.Horizontal else Orientation.Vertical + val scope = + remember(state, side) { + DockSplitterScopeImpl(side, orientation, panel = null) { deltaPx, density -> + val currentPx = with(density) { state.workspace.dockExtent(side).toPx() } + val nextPx = state.clampThicknessPx(side, currentPx, towardsContent(side, deltaPx), density) + state.workspace.setDockExtent(side, with(density) { nextPx.toDp() }) + } + } + SplitterSlot(state, scope) + }, + ) +} + /** - * The panels docked on one side, sharing the side equally along its length. - * Empty when none are. + * The panels of a split side, dividing its length by weight, with a divider + * between neighbours that moves weight from one to the other. * * Each panel is [key]ed on its satellite, because Compose otherwise identifies * them by their position on the side: undocking the first of two panels would * dispose the *second* one's subtree and hand the first one's — its * `remember`s, its saveable registry, the content of a satellite that has just - * left — to the panel that survives. The satellite that stays would keep - * composing under the identity of the one that went. + * left — to the panel that survives. */ @Composable -private fun DockSideStack( - workspace: SatelliteWorkspace, +private fun SplitStack( + state: DockLayoutState, side: DockSide, - entries: List, - containerSize: IntSize, + panels: List, ) { - if (entries.isEmpty()) return - val extent = workspace.dockExtent(side) - val divider = LocalDecoratedWindowStyle.current.colors.border + val extent = state.drawnSideExtent(side) + val orientation = if (side.isVertical) Orientation.Vertical else Orientation.Horizontal + val measure = Modifier.onSizeChanged { state.stackLengthsPx[side] = if (side.isVertical) it.height else it.width } + + @Composable + fun WeightDivider( + before: SatelliteEntry, + after: SatelliteEntry, + ) { + val scope = + remember(state, side, before, after) { + DockSplitterScopeImpl(side, orientation, before) { deltaPx, density -> + moveWeight(state, side, before, after, deltaPx, density) + } + } + SplitterSlot(state, scope) + } + if (side.isVertical) { - Column(Modifier.fillMaxHeight().width(extent)) { - entries.forEachIndexed { index, entry -> + Column(Modifier.fillMaxHeight().width(extent).then(measure)) { + panels.forEachIndexed { index, entry -> + if (index > 0) key("divider:${entry.id}") { WeightDivider(panels[index - 1], entry) } key(entry.id) { - if (index > 0) Box(Modifier.fillMaxWidth().height(PanelDividerThickness).background(divider)) - DockPanel(workspace, entry, containerSize, Modifier.fillMaxWidth().weight(1f)) + Box(Modifier.fillMaxWidth().weight(weightOf(entry))) { state.movableOf(entry)() } } } } } else { - Row(Modifier.fillMaxWidth().height(extent)) { - entries.forEachIndexed { index, entry -> + Row(Modifier.fillMaxWidth().height(extent).then(measure)) { + panels.forEachIndexed { index, entry -> + if (index > 0) key("divider:${entry.id}") { WeightDivider(panels[index - 1], entry) } key(entry.id) { - if (index > 0) Box(Modifier.fillMaxHeight().width(PanelDividerThickness).background(divider)) - DockPanel(workspace, entry, containerSize, Modifier.fillMaxHeight().weight(1f)) + Box(Modifier.fillMaxHeight().weight(weightOf(entry))) { state.movableOf(entry)() } } } } } } -/** One docked satellite: its header strip over its content. */ +internal fun weightOf(entry: SatelliteEntry): Float = (entry.placement as SatellitePlacement.Docked).weight + +/** The `splitter` slot, composed in the direction the layout was declared in. */ +@Composable +private fun SplitterSlot( + state: DockLayoutState, + scope: DockSplitterScope, +) { + CompositionLocalProvider(LocalLayoutDirection provides state.direction) { + state.splitter(scope) + } +} + +/** + * One docked satellite: its header strip over its content, inside the + * layout's `panel` slot. Movable — see [DockLayoutState.movableOf]. + */ @Composable private fun DockPanel( - workspace: SatelliteWorkspace, + state: DockLayoutState, entry: SatelliteEntry, - containerSize: IntSize, - modifier: Modifier, ) { if (entry.content == null) return - val header = entry.header - val scope = remember(workspace, entry) { SatelliteScopeImpl(workspace, entry, isDocked = true) } - val headerBackground = LocalTitleBarStyle.current.colors.background + val workspace = state.workspace + // The host answers how it is placed, and a panel moves between hosts, so + // the scope reads it through the entry rather than capturing a window. + val scope = remember(workspace, entry) { SatelliteScopeImpl(workspace, entry, isDocked = true) { entry.dockHost } } // Dimmed while its ghost is being dragged: the panel is on its way out. val leaving = workspace.dragGhost?.satellite === entry - Column( - modifier + val containerSize = state.containerSize + Box( + Modifier + .fillMaxSize() .alpha(if (leaving) LEAVING_PANEL_ALPHA else 1f) .onGloballyPositioned { coordinates -> // Read by SatelliteWorkspace.undock to lift the window off the panel. @@ -354,75 +713,31 @@ private fun DockPanel( entry.dockHostContainerSizePx = containerSize }, ) { - Box( - modifier = Modifier.fillMaxWidth().height(DockPanelHeaderHeight).background(headerBackground), - contentAlignment = Alignment.CenterStart, - ) { - if (header != null) header(scope) else scope.DefaultSatelliteHeader() - } - Box(Modifier.fillMaxWidth().weight(1f)) { - RelocatedContentHost(entry.stateSlot, scope, entry.content) + CompositionLocalProvider(LocalLayoutDirection provides state.direction) { + state.panel(scope) { + Column(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxWidth()) { + val header = entry.header + if (header != null) header(scope) else scope.DefaultSatelliteHeader() + } + Box(Modifier.fillMaxWidth().weight(1f)) { + RelocatedContentHost(entry.stateSlot, scope, entry.content) + } + } + } } } } /** - * Drag handle between a dock side and the content. Dragging towards the - * content grows the side; the extent is kept between - * [SatelliteWorkspace.MinDockExtent] and the layout minus [MinContentExtent]. + * The default [DockLayout] side order: top and bottom run the full width and + * own the corners, left and right sit between them — the classic border layout. */ -@Composable -private fun DockSplitter( - workspace: SatelliteWorkspace, - side: DockSide, - layoutSize: IntSize, - enabled: Boolean, -) { - if (!enabled) return - val density = LocalDensity.current - val color = LocalDecoratedWindowStyle.current.colors.border - val sizeModifier = - if (side.isVertical) { - Modifier.fillMaxHeight().width(SplitterThickness) - } else { - Modifier.fillMaxWidth().height(SplitterThickness) - } - Box( - sizeModifier - .background(color) - .pointerHoverIcon(if (side.isVertical) TaoPointerIcons.ResizeEastWest else TaoPointerIcons.ResizeNorthSouth) - .pointerInput(workspace, side, layoutSize) { - detectDragGestures { change, drag -> - change.consume() - val towardsContent = - when (side) { - DockSide.Left -> drag.x - DockSide.Right -> -drag.x - DockSide.Top -> drag.y - DockSide.Bottom -> -drag.y - } - val currentPx = with(density) { workspace.dockExtent(side).toPx() } - val along = if (side.isVertical) layoutSize.width else layoutSize.height - val maxPx = along - with(density) { MinContentExtent.toPx() } - var nextPx = currentPx + towardsContent - if (along > 0 && maxPx > 0f) nextPx = nextPx.coerceAtMost(maxPx) - workspace.setDockExtent(side, with(density) { nextPx.toDp() }) - } - }.fillMaxSize(), - ) -} +public val DefaultDockSideOrder: List = listOf(DockSide.Top, DockSide.Bottom, DockSide.Left, DockSide.Right) -/** Height of the header strip above a docked panel's content. */ +/** Height of the [DefaultSatelliteHeader] strip above a docked panel's content. */ public val DockPanelHeaderHeight: Dp = 30.dp -private val SplitterThickness: Dp = 6.dp -private val PanelDividerThickness: Dp = 1.dp -private val MinContentExtent: Dp = 120.dp -private val PreviewBorderWidth: Dp = 1.dp -private val ZoneOutlineWidth: Dp = 1.5.dp -private val ZoneDashOn: Dp = 5.dp -private val ZoneDashOff: Dp = 4.dp -private const val ZONE_HINT_ALPHA = 0.10f -private const val ZONE_ACTIVE_ALPHA = 0.28f -private const val ZONE_OUTLINE_ALPHA = 0.55f +private const val CONTENT_KEY = "content" +internal val MinContentExtent: Dp = 120.dp private const val LEAVING_PANEL_ALPHA = 0.35f diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockSplitter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockSplitter.kt new file mode 100644 index 000000000..90d8aba47 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockSplitter.kt @@ -0,0 +1,129 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle + +/** + * What the [DockLayout] `splitter` slot composes in: which side and panel the + * splitter resizes, along which axis, and the modifier that makes an element + * the grip. + */ +public interface DockSplitterScope { + /** The side this splitter belongs to. */ + public val side: DockSide + + /** + * The axis the splitter is dragged along: [Orientation.Horizontal] for a + * bar between things side by side (a vertical line), [Orientation.Vertical] + * for a bar between things stacked. + */ + public val orientation: Orientation + + /** + * The panel this splitter resizes: the layer just outside it on a layered + * side, or the panel just before it on a split side. `null` for the + * splitter between a split side's stack and the content, which drags the + * side's [SatelliteWorkspace.dockExtent]. + */ + public val panel: SatelliteEntry? + + /** + * Attaches the resize gesture and the resize cursor. Apply it to the + * element the user grabs; it may be larger than what is drawn — a 1 dp + * line can carry a wider invisible grip through `Modifier.requiredWidth`. + */ + public fun Modifier.dockSplitterHandle(): Modifier +} + +/** + * The stock splitter: a bar of [DockSplitterThickness] in the window style's + * border colour, the whole of it the grip. + */ +@Composable +public fun DockSplitterScope.DefaultDockSplitter() { + val color = LocalDecoratedWindowStyle.current.colors.border + val sizeModifier = + if (orientation == Orientation.Horizontal) { + Modifier.fillMaxHeight().width(DockSplitterThickness) + } else { + Modifier.fillMaxWidth().height(DockSplitterThickness) + } + Box(sizeModifier.background(color).dockSplitterHandle()) +} + +/** Sign of a pointer delta that grows [side] towards the content. */ +internal fun towardsContent( + side: DockSide, + deltaPx: Float, +): Float = + when (side) { + DockSide.Left, DockSide.Top -> deltaPx + DockSide.Right, DockSide.Bottom -> -deltaPx + } + +internal class DockSplitterScopeImpl( + override val side: DockSide, + override val orientation: Orientation, + override val panel: SatelliteEntry?, + private val onDragPx: (deltaPx: Float, density: Density) -> Unit, +) : DockSplitterScope { + private val horizontal: Boolean get() = orientation == Orientation.Horizontal + + override fun Modifier.dockSplitterHandle(): Modifier = + pointerHoverIcon(if (horizontal) TaoPointerIcons.ResizeEastWest else TaoPointerIcons.ResizeNorthSouth) + .pointerInput(this@DockSplitterScopeImpl) { + detectDragGestures { change, drag -> + change.consume() + onDragPx(if (horizontal) drag.x else drag.y, this) + } + } +} + +/** + * Moves [deltaPx] of the stack's length from [after] to [before]: the + * divider follows the pointer one-to-one, and neither panel drops under + * [SatelliteWorkspace.MinDockExtent]. + */ +internal fun moveWeight( + state: DockLayoutState, + side: DockSide, + before: SatelliteEntry, + after: SatelliteEntry, + deltaPx: Float, + density: Density, +) { + val lengthPx = state.stackLengthsPx[side]?.takeIf { it > 0 } ?: return + val total = state.panelsOn(side).sumOf { weightOf(it).toDouble() }.toFloat() + val pxPerWeight = lengthPx / total + val minWeight = with(density) { SatelliteWorkspace.MinDockExtent.toPx() } / pxPerWeight + val beforeWeight = weightOf(before) + val afterWeight = weightOf(after) + // Both panels already under the minimum — a stack too short for its + // panels — leaves nothing to move. + val low = minWeight - beforeWeight + val high = afterWeight - minWeight + if (low > high) return + val delta = (deltaPx / pxPerWeight).coerceIn(low, high) + if (delta == 0f || delta.isNaN()) return + state.workspace.setDockedWeight(before.id, beforeWeight + delta) + state.workspace.setDockedWeight(after.id, afterWeight - delta) +} + +/** Thickness of the [DefaultDockSplitter] bar. */ +public val DockSplitterThickness: Dp = 6.dp diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockTransferTarget.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockTransferTarget.kt new file mode 100644 index 000000000..8cfe02b6d --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockTransferTarget.kt @@ -0,0 +1,106 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.geometry.Offset +import dev.nucleusframework.window.tao.workspace.HostGeometry +import dev.nucleusframework.window.tao.workspace.positionInWindowPx + +/** + * Makes the layout the drop target of a [SatelliteWorkspace.transferDrag]: + * the drag that rides the platform's DnD session where windows cannot be + * hit-tested from the source (native Wayland). The events arrive in this + * window's own coordinates, which is exactly what the source lacks, so the + * zone under the pointer is resolved here — previewed while hovering, recorded + * on the session at the drop for the source to act on when the session ends. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun Modifier.dockTransferTarget( + workspace: SatelliteWorkspace, + host: TaoWindow?, + geometry: HostGeometry?, +): Modifier { + if (host == null || geometry == null) return this + val target = remember(workspace, host, geometry) { DockTransferTarget(workspace, host, geometry) } + return dragAndDropTarget( + shouldStartDragAndDrop = { workspace.transferDrag != null }, + target = target, + ) +} + +internal class DockTransferTarget( + private val workspace: SatelliteWorkspace, + private val host: TaoWindow, + private val geometry: HostGeometry, +) : DragAndDropTarget { + override fun onEntered(event: DragAndDropEvent) = preview(event) + + override fun onMoved(event: DragAndDropEvent) = preview(event) + + override fun onExited(event: DragAndDropEvent) = clearPreview() + + override fun onEnded(event: DragAndDropEvent) = clearPreview() + + override fun onDrop(event: DragAndDropEvent): Boolean { + val drag = workspace.transferDrag ?: return false + val position = event.positionInWindowPx() + val zone = zoneAt(position)?.let { workspace.targetFor(drag.entry, it) } + val outcome = + when { + zone != null && zone != drag.own -> TransferDrop.Dock(zone) + // Back onto its own side, or onto the very panel it came from: + // the gesture was abandoned, not a tear-out. + zone != null || drag.isOwnPanel(position) -> TransferDrop.Stay + else -> return false + } + drag.drop = outcome + clearPreview() + return true + } + + /** + * The zone [positionInWindowPx] is in, resolved against the rectangles the + * layout draws ([HostGeometry.zoneBoundsInWindowPx]) so a drop lands where + * the highlight promised — inset behind existing layers included, at the + * rank of the stack the pointer is over — and against the layout's edges + * while none are published. + */ + internal fun zoneAt(positionInWindowPx: Offset): DockTarget? { + val zonePx = SatelliteWorkspace.DockZoneWidth.value * geometry.scaleOrOne() + val zones = geometry.zoneBoundsInWindowPx + if (zones.isEmpty()) { + return dockSideAt(geometry.layoutBoundsInWindowPx, positionInWindowPx, zonePx)?.let { DockTarget(host, it) } + } + // A stack the pointer is over wins over a strip running across its corner. + val (side, zone) = + zones.entries.firstOrNull { (_, zone) -> zone.slots.any { it.contains(positionInWindowPx) } } + ?: zones.entries.firstOrNull { (_, zone) -> zone.strip.contains(positionInWindowPx) } + ?: return null + return DockTarget(host, side, zone.slotAt(positionInWindowPx)) + } + + private fun preview(event: DragAndDropEvent) { + val drag = workspace.transferDrag ?: return + workspace.dockPreview = + zoneAt(event.positionInWindowPx()) + ?.let { workspace.targetFor(drag.entry, it) } + ?.takeIf { it != drag.own } + } + + private fun clearPreview() { + if (workspace.dockPreview?.host === host) workspace.dockPreview = null + } + + /** Whether [positionInWindowPx] is on the dragged panel itself, in this host. */ + private fun SatelliteTransferDrag.isOwnPanel(positionInWindowPx: Offset): Boolean = + (origin as? SatelliteDragOrigin.DockedPanel)?.host === host && + entry.dockedBoundsInWindowPx?.contains(positionInWindowPx) == true +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockZoneHints.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockZoneHints.kt new file mode 100644 index 000000000..782a44141 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockZoneHints.kt @@ -0,0 +1,168 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import dev.nucleusframework.window.tao.workspace.DockDropZone +import kotlin.math.roundToInt + +/** + * The four drop zones of this layout, shown while a satellite is being + * dragged anywhere in the workspace. + * + * Every side is outlined faintly as soon as the drag starts — that is what + * tells the user the gesture exists — and on the one the satellite has + * entered the panel's own card ([SatelliteGhostCard], the card that follows + * the pointer) is drawn on the space the release will fill + * ([DockLayoutState.dropRectPx]): the side's own band rather than the whole + * edge, at the width the drop will produce, inside the layers already there + * on a layered side, and on a side with panels at the rank the pointer picks + * — the share of the stack the panel gets between the two it lands between. + * The rank the dragged panel already holds is not a target: a side it is + * alone on is left out altogether, and with neighbours the strip past the + * stack is not lit while the panel is the last of them, since a drop there + * changes nothing. + */ +@Composable +internal fun BoxScope.DockZoneHints( + workspace: SatelliteWorkspace, + host: TaoWindow, + state: DockLayoutState, +) { + val dragged = workspace.draggedSatellite ?: return + val density = LocalDensity.current + val hinted = hintedSides(dragged, host, workspace.satellites) + val zoneWidthPx = with(density) { SatelliteWorkspace.DockZoneWidth.toPx() } + // What a drag is hit-tested against is what is drawn: the idle strips and + // the ranks of each stack, published to the geometry the workspace + // resolves drops on. Cleared when the drag ends, so a stale set can never + // answer for a later one. Recomputed on every recomposition rather than + // remembered: the rects come from the measured bands, which move without + // any of the keys a remember could name (a side order change, a splitter + // drag). Four strips and a handful of slots. + val zones = + hinted.associateWith { side -> + val strip = state.landingRectPx(side, zoneWidthPx, joinsStack = false, dragged = dragged) + DockDropZone(strip, state.dropSlotsPx(side, strip, dragged)) + } + val origin = state.layoutBoundsInWindowPx.topLeft + DisposableEffect(zones, origin) { + val geometry = workspace.dockHostGeometry(host) + geometry?.zoneBoundsInWindowPx = zones.mapValues { (_, zone) -> zone.translate(origin) } + onDispose { geometry?.zoneBoundsInWindowPx = emptyMap() } + } + val own = workspace.ownTarget(dragged, host) + // Keeps the closed-hand cursor over the whole layout for the length of the + // drag: the grip itself is only under the pointer while the satellite + // floats, and a docked panel's header is left behind at the first move. + Box( + Modifier + .matchParentSize() + .pointerHoverIcon(TaoPointerIcons.Grabbing, overrideDescendants = true), + ) + for (side in hinted) { + SideHint(workspace, state, host, side, zones.getValue(side), dragged, own) + } +} + +/** + * One side's feedback: the panel's card on the space it will take when the + * side is the one aimed at, else the faint strip that says it could be. + */ +@Suppress("LongParameterList") // the drag's whole state, read once per side +@Composable +private fun SideHint( + workspace: SatelliteWorkspace, + state: DockLayoutState, + host: TaoWindow, + side: DockSide, + zone: DockDropZone, + dragged: SatelliteEntry, + own: DockTarget?, +) { + val preview = workspace.dockPreview + val active = preview?.host === host && preview.side == side + // Its own side, with itself last: the strip past the stack is the rank it + // holds, so lighting it up would promise a move that does not happen. + if (!active && own?.side == side && own.order == zone.slots.lastIndex) return + if (!active) { + PreviewAt(zone.strip) { DragPreviewSurface(Modifier.fillMaxSize(), hint = true) } + return + } + val density = LocalDensity.current + // The width the drop will actually produce: on a layered side the panel's + // own, elsewhere the side's — which on a side that has no extent yet is + // the satellite's own size, not the default. + val extent = + if (state.isLayered(side)) { + workspace.dockSeedExtent(dragged, side) + } else { + workspace.plannedDockExtent(dragged, side) + } + val order = preview.order?.takeIf { zone.slots.isNotEmpty() } + val rect = state.dropRectPx(side, dragged, order, with(density) { extent.toPx() }) + PreviewAt(rect) { SatelliteGhostCard(dragged.title, Modifier.fillMaxSize()) } +} + +/** [content] laid over [rect], in the layout's own px. */ +@Composable +private fun PreviewAt( + rect: Rect, + content: @Composable () -> Unit, +) { + if (rect.isEmpty) return + val density = LocalDensity.current + Box( + Modifier + .offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) } + .size(with(density) { rect.width.toDp() }, with(density) { rect.height.toDp() }), + ) { + content() + } +} + +/** + * The sides worth hinting while [dragged] is in flight over [host]: every one + * except the side [dragged] is already docked on **in this window** while + * there is no other rank for it there — it is alone, or pinned + * ([SatelliteEntry.isReorderable]) — since dropping it back is a no-op and + * offering it would promise a move that does not happen. With other panels + * on that side it is a target again: the panel can be dropped at another + * rank among them. + * Dragged from another window, or floating, every side is a real target — + * among the sides the satellite was declared for ([SatelliteEntry.dockSides]). + * [satellites] are the workspace's, to tell a lone panel from a stack. + */ +internal fun hintedSides( + dragged: SatelliteEntry, + host: TaoWindow, + satellites: Collection, +): List { + val own = (dragged.placement as? SatellitePlacement.Docked)?.side?.takeIf { dragged.dockHost === host } + val alone = + own != null && + satellites.none { + it !== dragged && + it.isShown && + it.dockHost === host && + (it.placement as? SatellitePlacement.Docked)?.side == own + } + // Its own side is a target only while another rank is on offer there. + val stuck = alone || !dragged.isReorderable + return DockSide.entries.filter { it in dragged.dockSides && !(stuck && it == own) } +} + +/** The smallest rect containing both. */ +internal fun unionOf( + a: Rect, + b: Rect, +): Rect = Rect(minOf(a.left, b.left), minOf(a.top, b.top), maxOf(a.right, b.right), maxOf(a.bottom, b.bottom)) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DragPreviewDefaults.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DragPreviewDefaults.kt new file mode 100644 index 000000000..77c8667d2 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DragPreviewDefaults.kt @@ -0,0 +1,57 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.styling.LocalTitleBarStyle + +/** + * The one look every drop preview of the workspaces has — the card that + * follows the pointer out of a window, the same card drawn on the space a + * release will fill, and the faint outline of a place that could be dropped + * on: a tinted, rounded surface in the title bar's content colour. + * + * One surface rather than one per gesture, so a tab and a panel, a preview in + * hand and a preview on its target, all read as the same thing. + */ +internal object DragPreviewDefaults { + val CornerRadius: Dp = 8.dp + val BorderWidth: Dp = 1.dp + + /** The card: what is being dragged, in hand or on the space it will take. */ + const val FILL_ALPHA = 0.22f + const val BORDER_ALPHA = 0.55f + + /** The hint: a place that could be dropped on, but is not the one aimed at. */ + const val HINT_FILL_ALPHA = 0.06f + const val HINT_BORDER_ALPHA = 0.22f +} + +/** + * The tinted, rounded surface of a drop preview; [hint] draws it at the + * intensity of a place that is merely on offer. + */ +@Composable +internal fun DragPreviewSurface( + modifier: Modifier = Modifier, + hint: Boolean = false, + content: @Composable BoxScope.() -> Unit = {}, +) { + val accent = LocalTitleBarStyle.current.colors.content + val shape = RoundedCornerShape(DragPreviewDefaults.CornerRadius) + val fill = if (hint) DragPreviewDefaults.HINT_FILL_ALPHA else DragPreviewDefaults.FILL_ALPHA + val border = if (hint) DragPreviewDefaults.HINT_BORDER_ALPHA else DragPreviewDefaults.BORDER_ALPHA + Box( + modifier = + modifier + .background(accent.copy(alpha = fill), shape) + .border(DragPreviewDefaults.BorderWidth, accent.copy(alpha = border), shape), + content = content, + ) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/Satellite.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/Satellite.kt index ad2aca3b7..ea5fd862f 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/Satellite.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/Satellite.kt @@ -9,14 +9,13 @@ package dev.nucleusframework.window.tao import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -44,6 +43,7 @@ import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.nucleusframework.window.BasicTitleBar @@ -54,7 +54,6 @@ import dev.nucleusframework.window.tao.workspace.DragGhostWindow import dev.nucleusframework.window.tao.workspace.RelocatedContentHost import dev.nucleusframework.window.tao.workspace.ScreenDrag import dev.nucleusframework.window.tao.workspace.screenDragHandle -import dev.nucleusframework.window.tao.workspace.supportsScreenPlacement /** * What a satellite's `header` and `content` lambdas get to see: the satellite @@ -79,6 +78,26 @@ public interface SatelliteScope { */ public val isDocked: Boolean + /** + * `true` when the window this satellite is composed in is placed by the + * compositor rather than by the app ([TaoWindow.canPlaceOnScreen] `false` + * — a native Wayland surface). + * + * That is the one thing chrome has to adapt to, and it says nothing about + * the platform: where it holds, *moving the window* is the compositor's + * gesture and only the area an app leaves unclaimed can start it, while + * *moving the satellite* — docking it, tearing it out — rides the + * platform's drag-and-drop session from a [Modifier.satelliteDragHandle]. + * The two cannot share one area, so a floating satellite's title bar + * reserves [SatelliteCaptionStripWidth] for the compositor and hands it to + * the `floatingCaption` slot of [Satellite]; everywhere else the whole bar + * drags the satellite and that slot is not composed at all. + * + * `false` until the native window exists, and while the satellite has no + * window at all (a docked panel reads its host's value). + */ + public val isCompositorPlaced: Boolean + /** Docks the satellite on [side] of the workspace owner; defaults to the last side it was docked on. */ public fun dock(side: DockSide = satellite.preferredDockSide) { workspace.dock(satellite.id, side) @@ -99,7 +118,16 @@ internal class SatelliteScopeImpl( override val workspace: SatelliteWorkspace, override val satellite: SatelliteEntry, override val isDocked: Boolean, -) : SatelliteScope + /** + * The window this scope's content is composed in, read on every access: + * the scope outlives the window (a satellite docks, undocks, moves host) + * and a window answers [TaoWindow.canPlaceOnScreen] only once its native + * surface exists. + */ + private val host: () -> TaoWindow? = { null }, +) : SatelliteScope { + override val isCompositorPlaced: Boolean get() = host()?.canPlaceOnScreen == false +} /** * Declares a satellite of [workspace] and hosts it wherever its placement @@ -141,6 +169,21 @@ internal class SatelliteScopeImpl( * @param title shown by the default [header] and as the floating window title. * @param initialPlacement where the satellite starts on first declaration. * @param initiallyOpen whether it is shown on first declaration. + * @param dockSides the sides the satellite may be docked on: the others are + * neither offered while it is dragged nor accepted by + * [SatelliteWorkspace.dock]. Empty makes it a floating-only palette. Fixed + * on first declaration, like the placement; a docked [initialPlacement] + * must name one of them. + * @param floatable whether the satellite can be a window of its own. `false` + * is a fixed panel: no tear-out, [SatelliteWorkspace.undock] refuses it, + * the default header offers no Float action, and a drag can only move it + * inside the dock. Requires a docked [initialPlacement]. + * @param reorderable whether the user may change its rank on its side. + * `false` pins it to the rank it was declared with: its own drag is offered + * none, and another panel can be dropped after it but never in front of it. + * Requires a docked [initialPlacement]. With `floatable = false` and a + * single [dockSides], the panel is furniture and its header is not even a + * drag handle. * @param resizable whether the floating window can be resized by the user. * @param hideWhileOwnerFullscreenOrMaximized hide the floating window while * the owner fills the screen; see [SatelliteWindow]. @@ -152,6 +195,14 @@ internal class SatelliteScopeImpl( * use to provide their per-window locals. Must invoke the lambda it is given. * @param header chrome shown in the floating window's title bar and above the * docked panel; [DefaultSatelliteHeader] draws the title and dock actions. + * @param floatingCaption composed inside the strip of the floating title bar + * that is left to the compositor's window move — the + * [SatelliteCaptionStripWidth] beside the window controls, reserved only + * where the window is placed by the compositor + * ([SatelliteScope.isCompositorPlaced]). It is *not* a + * [Modifier.satelliteDragHandle]: a press in it moves the window, so what + * belongs here is the affordance that says so, not a control. Not composed + * at all on the platforms where the whole bar drags the satellite. * @param content the satellite's body. */ @Suppress("LongParameterList", "FunctionNaming") @@ -163,6 +214,9 @@ public fun ApplicationScope.Satellite( title: String, initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), initiallyOpen: Boolean = true, + dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, + reorderable: Boolean = true, resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, compositionLocalContext: CompositionLocalContext? = null, @@ -170,10 +224,17 @@ public fun ApplicationScope.Satellite( @Composable @UiComposable TaoDecoratedWindowScope.(content: @Composable @UiComposable () -> Unit) -> Unit = { it() }, header: @Composable @UiComposable SatelliteScope.() -> Unit = { DefaultSatelliteHeader() }, + floatingCaption: @Composable @UiComposable SatelliteScope.() -> Unit = {}, content: @Composable @UiComposable SatelliteScope.() -> Unit, ) { - val entry = remember(workspace, id) { workspace.register(id, title, initialPlacement, initiallyOpen) } - val scope = remember(entry) { SatelliteScopeImpl(workspace, entry, isDocked = false) } + val entry = + remember(workspace, id) { + workspace.register(id, title, initialPlacement, initiallyOpen, dockSides, floatable, reorderable) + } + // The satellite's own window, once it has one: the scope is created before + // it and survives it, so it is read through a lambda. + var floatingWindow by remember(entry) { mutableStateOf(null) } + val scope = remember(entry) { SatelliteScopeImpl(workspace, entry, isDocked = false) { floatingWindow } } // Published as snapshot state so the DockLayout hosting the panel picks up // a new lambda without this composable knowing where the panel lives. SideEffect { @@ -194,7 +255,7 @@ public fun ApplicationScope.Satellite( title = ghost.satellite.title, compositionLocalContext = compositionLocalContext, ) { - SatelliteGhostCard(ghost.satellite.title) + SatelliteGhostCard(ghost.satellite.title, Modifier.fillMaxSize()) } } @@ -223,6 +284,10 @@ public fun ApplicationScope.Satellite( compositionLocalContext = compositionLocalContext, ) { val windowScope: TaoDecoratedWindowScope = this + SideEffect { floatingWindow = window } + DisposableEffect(window) { + onDispose { if (floatingWindow === window) floatingWindow = null } + } // Native Wayland: the workspace cannot move the window itself (no // client-side placement), so the bar keeps the compositor's move — // the only way the palette stays draggable there. The header strip @@ -230,7 +295,8 @@ public fun ApplicationScope.Satellite( // caption strip next to the window controls is left to the compositor // move: the split Chrome's tab strip makes between a tab and the empty // strip beside it. - val workspaceDrag = window.supportsScreenPlacement + val workspaceDrag = window.canPlaceOnScreen + val currentCaption by rememberUpdatedState(floatingCaption) floatingContentWrapper { with(windowScope) { WindowScaffold( @@ -265,8 +331,12 @@ public fun ApplicationScope.Satellite( contentAlignment = Alignment.Center, ) { currentHeader(scope) } // Unclaimed on purpose: the bar's compositor - // move is what a press here starts. - Spacer(Modifier.width(WAYLAND_CAPTION_DP.dp).fillMaxHeight()) + // move is what a press here starts, and the + // app's own content for it goes inside. + Box( + modifier = Modifier.width(SatelliteCaptionStripWidth).fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { currentCaption(scope) } } } } @@ -282,20 +352,17 @@ public fun ApplicationScope.Satellite( } /** - * The translucent card a panel torn out of its dock is previewed as: the - * satellite's grip and title on a tinted, rounded surface, filling the ghost - * window. + * The card a panel is previewed as while it is dragged — following the pointer + * out of its dock, and drawn on the space a release will fill: the satellite's + * grip and title on the shared [DragPreviewSurface]. */ @Composable -private fun SatelliteGhostCard(title: String) { +internal fun SatelliteGhostCard( + title: String, + modifier: Modifier = Modifier, +) { val accent = LocalTitleBarStyle.current.colors.content - val ghostShape = RoundedCornerShape(GHOST_CORNER_DP.dp) - Box( - Modifier - .fillMaxSize() - .background(accent.copy(alpha = GHOST_FILL_ALPHA), ghostShape) - .border(GHOST_BORDER_DP.dp, accent.copy(alpha = GHOST_BORDER_ALPHA), ghostShape), - ) { + DragPreviewSurface(modifier) { Row( modifier = Modifier.fillMaxWidth().padding(GHOST_PADDING_DP.dp), verticalAlignment = Alignment.CenterVertically, @@ -353,15 +420,25 @@ private fun SatelliteGhostCard(title: String) { * still be moved. Custom floating chrome gets the same split for free: it is * composed inside that handle. * - * No-op outside a Tao window. Drives [SatelliteWorkspace.beginDrag]. + * No-op outside a Tao window, and on a satellite a drag could not move + * anywhere — fixed to one side, pinned to its rank and alone in the workspace + * — rather than leaving a gesture that can only end where it started. + * + * Drives [SatelliteWorkspace.beginDrag]. */ public fun Modifier.satelliteDragHandle(scope: SatelliteScope): Modifier = - screenDragHandle( - key = scope, - isDragging = { scope.workspace.draggedSatellite === scope.satellite }, - beginTransfer = { window -> scope.workspace.beginTransferDrag(scope.satellite.id, scope.dragOrigin(window)) }, - ) { window, pointerScreenPx -> - scope.workspace.beginDrag(scope.satellite.id, scope.dragOrigin(window), pointerScreenPx)?.asScreenDrag() + if (!scope.workspace.canBeDragged(scope.satellite)) { + this + } else { + screenDragHandle( + key = scope, + isDragging = { scope.workspace.draggedSatellite === scope.satellite }, + beginTransfer = { window -> + scope.workspace.beginTransferDrag(scope.satellite.id, scope.dragOrigin(window)) + }, + ) { window, pointerScreenPx -> + scope.workspace.beginDrag(scope.satellite.id, scope.dragOrigin(window), pointerScreenPx)?.asScreenDrag() + } } private fun SatelliteScope.dragOrigin(window: TaoWindow): SatelliteDragOrigin = @@ -376,6 +453,18 @@ private fun SatelliteDragSession.asScreenDrag(): ScreenDrag = override fun cancel() = this@asScreenDrag.cancel() } +/** + * Width of the strip a floating satellite's title bar leaves to the + * compositor's window move, next to the window controls, on a window the + * compositor places ([SatelliteScope.isCompositorPlaced]). The + * `floatingCaption` slot of [Satellite] is composed inside it. + * + * Wide enough to aim at without looking, narrow enough to leave the header + * the rest of the bar — the same bargain Chrome's tab strip makes with the + * empty strip beside the last tab. + */ +public val SatelliteCaptionStripWidth: Dp = 56.dp + /** * The stock satellite header: the title, then "Dock" while floating or * "Float" and "Close" while docked. Colours come from [LocalTitleBarStyle], so @@ -393,13 +482,14 @@ private fun SatelliteDragSession.asScreenDrag(): ScreenDrag = * see which is which. Chrome's tab strip and GIMP's dock tabs draw the same * distinction for the same reason. */ + @OptIn(ExperimentalComposeUiApi::class) @Composable public fun SatelliteScope.DefaultSatelliteHeader() { val colors = LocalTitleBarStyle.current.colors var hovered by remember { mutableStateOf(false) } val window = LocalTaoWindow.current - val chip = !isDocked && window != null && !window.supportsScreenPlacement + val chip = !isDocked && isCompositorPlaced val shape = if (chip) RoundedCornerShape(CHIP_CORNER_DP.dp) else RectangleShape val background = when { @@ -412,12 +502,19 @@ public fun SatelliteScope.DefaultSatelliteHeader() { modifier = Modifier .fillMaxWidth() - // Full height so the whole header strip is the grip, not just - // the band its content happens to occupy. The chip is inset - // inside that, so it reads as an object sitting in the bar - // while the area a press lands on stays the whole strip. - .fillMaxHeight() - .then(if (chip) Modifier.padding(vertical = CHIP_INSET_DP.dp) else Modifier) + // Docked, the strip sizes itself: the dock frame imposes no + // height, so a custom header can be as tall as it likes. + // Floating, full height so the whole header strip is the grip, + // not just the band its content happens to occupy. The chip is + // inset inside that, so it reads as an object sitting in the + // bar while the area a press lands on stays the whole strip. + .then( + if (isDocked) { + Modifier.height(DockPanelHeaderHeight).background(colors.background) + } else { + Modifier.fillMaxHeight() + }, + ).then(if (chip) Modifier.padding(vertical = CHIP_INSET_DP.dp) else Modifier) .then(if (isDocked) Modifier.satelliteDragHandle(this) else Modifier) .onPointerEvent(PointerEventType.Enter) { hovered = true } .onPointerEvent(PointerEventType.Exit) { hovered = false } @@ -434,10 +531,10 @@ public fun SatelliteScope.DefaultSatelliteHeader() { overflow = TextOverflow.Ellipsis, ) if (isDocked) { - HeaderAction("Float", colors.content) { undock() } + if (satellite.isFloatable) HeaderAction("Float", colors.content) { undock() } HeaderAction("Close", colors.content) { close() } } else { - HeaderAction("Dock", colors.content) { dock() } + if (satellite.dockSides.isNotEmpty()) HeaderAction("Dock", colors.content) { dock() } } } } @@ -481,9 +578,6 @@ private fun HeaderAction( private const val HEADER_PADDING_DP = 8 -/** Title-bar strip left to the compositor move on native Wayland, beside the window controls. */ -private const val WAYLAND_CAPTION_DP = 56 - /** The chip's corner radius, matching the tab strip's own tabs. */ private const val CHIP_CORNER_DP = 8 @@ -499,10 +593,6 @@ private const val GRIP_DOT_COLUMNS = 2 private const val GRIP_DOT_ROWS = 3 private const val GRIP_ALPHA = 0.55f private const val GRIP_HOVER_ALPHA = 0.08f -private const val GHOST_FILL_ALPHA = 0.22f -private const val GHOST_BORDER_ALPHA = 0.55f -private const val GHOST_BORDER_DP = 1 -private const val GHOST_CORNER_DP = 8 private const val GHOST_PADDING_DP = 8 private const val HEADER_ACTION_PADDING_DP = 6 private const val HEADER_ACTION_VERTICAL_PADDING_DP = 2 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteDragSessions.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteDragSessions.kt index 2d6cf7080..fc23ca352 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteDragSessions.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteDragSessions.kt @@ -70,7 +70,9 @@ private class FloatingDragSession( pointer = pointerScreenPx.sanitizedOrNull() ?: pointer val topLeft = pointer - grabOffsetPx origin.move(topLeft.x.toWindowCoordinate(), topLeft.y.toWindowCoordinate()) - workspace.dockPreview = workspace.dockTargetAt(pointer) + // From the window, not the pointer: the palette is what the user sees + // moving, so the zone its edge has reached is the one to preview. + workspace.dockPreview = workspace.dockTargetFor(entry, Rect(topLeft, windowSizePx()), pointer) } override fun end(pointerScreenPx: Offset) { @@ -78,8 +80,13 @@ private class FloatingDragSession( update(pointerScreenPx) val target = workspace.dockPreview cancel() - if (target != null) workspace.dock(entry.id, target.side, host = target.host) + if (target != null) workspace.dropAt(entry.id, target) } + + /** The window's own size; read live, since a resize mid-drag is allowed. */ + @Suppress("MagicNumber") // outer frame is [x, y, w, h] + private fun windowSizePx(): Size = + origin.outerBoundsPx()?.let { Size(it[2].toFloat(), it[3].toFloat()) } ?: Size.Zero } private class DockedDragSession( @@ -95,27 +102,39 @@ private class DockedDragSession( /** The host's px-per-dp, carried to the ghost window. */ private val scaleFactor: Float, ) : SatelliteDragSessionBase(workspace) { - private val own: DockTarget? = (entry.placement as? SatellitePlacement.Docked)?.let { DockTarget(host, it.side) } + /** Its own slot on its own side: dropping there changes nothing. */ + private val own: DockTarget? = workspace.ownTarget(entry, host) override fun update(pointerScreenPx: Offset) { if (!isLive) return pointer = pointerScreenPx.sanitizedOrNull() ?: pointer - workspace.dockPreview = workspace.dockTargetAt(pointer)?.takeIf { it != own } + val ghost = ghostRectPx() + // From the ghost, not the pointer: it is the thing on screen standing + // in for the panel, so the zone its edge has reached is the one to + // preview — the same rule as for a floating palette's window. + workspace.dockPreview = workspace.dockTargetFor(entry, ghost, pointer)?.takeIf { it != own } // Follows the pointer for the whole gesture, including over a dock // zone: the panel is out of the layout as soon as the drag starts, and - // seeing it hover is what makes the tear-out read. - workspace.dragGhost = DragGhost(entry, Rect(pointer - grabOffsetPx, panelScreenRectPx.size), scaleFactor) + // seeing it hover is what makes the tear-out read. A fixed panel has + // no tear-out to read, so it stays where it is and only the zone + // feedback moves — showing a ghost would promise a window the release + // does not produce. + if (entry.isFloatable) workspace.dragGhost = DragGhost(entry, ghost, scaleFactor) } + private fun ghostRectPx(): Rect = Rect(pointer - grabOffsetPx, panelScreenRectPx.size) + override fun end(pointerScreenPx: Offset) { if (!isLive) return pointer = pointerScreenPx.sanitizedOrNull() ?: pointer val drop = pointer - val target = workspace.dockTargetAt(drop)?.takeIf { it != own } + val target = workspace.dockTargetFor(entry, ghostRectPx(), drop)?.takeIf { it != own } cancel() when { - target != null -> workspace.dock(entry.id, target.side, host = target.host) - panelScreenRectPx.contains(drop) -> Unit + target != null -> workspace.dropAt(entry.id, target) + // Released on its own panel, or anywhere at all for a fixed one: + // the gesture was abandoned, not a tear-out. + !entry.isFloatable || panelScreenRectPx.contains(drop) -> Unit else -> workspace.undock(entry.id, workspace.floatingAtScreen(drop - grabOffsetPx, panelScreenRectPx.size)) } } @@ -154,18 +173,15 @@ internal class SatelliteTransferDrag( /** Written by the target that took the drop, read once the session ends. */ var drop: TransferDrop? = null - /** The zone the dragged panel already occupies; dropping back onto it changes nothing. */ - val own: DockTarget? = - (origin as? SatelliteDragOrigin.DockedPanel)?.let { panel -> - (entry.placement as? SatellitePlacement.Docked)?.let { DockTarget(panel.host, it.side) } - } + /** The slot the dragged panel already occupies; dropping back onto it changes nothing. */ + val own: DockTarget? = (origin as? SatelliteDragOrigin.DockedPanel)?.let { workspace.ownTarget(entry, it.host) } override fun end() { if (!workspace.isLiveTransfer(this)) return val outcome = drop workspace.endTransferDrag(this) when (outcome) { - is TransferDrop.Dock -> workspace.dock(entry.id, outcome.target.side, host = outcome.target.host) + is TransferDrop.Dock -> workspace.dropAt(entry.id, outcome.target) TransferDrop.Stay -> Unit null -> if (origin is SatelliteDragOrigin.DockedPanel) workspace.undock(entry.id) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatellitePlacement.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatellitePlacement.kt index 5b08b230a..aad414a69 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatellitePlacement.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatellitePlacement.kt @@ -1,11 +1,19 @@ package dev.nucleusframework.window.tao +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -/** Edge of a window's content area a docked satellite attaches to. */ +/** + * Edge of a window's content area a docked satellite attaches to. + * + * Sides are **physical**: [Left] is the left edge of the screen whatever the + * `LayoutDirection` in force, so a right-to-left app that wants its navigation + * panels on the right says [Right]. See [DockLayout] for how the four sides + * nest. + */ public enum class DockSide { /** Left edge; the panel runs the full content height. */ Left, @@ -22,6 +30,16 @@ public enum class DockSide { /** `true` for [Left] and [Right], whose extent is a width. */ public val isVertical: Boolean get() = this == Left || this == Right + + /** The edge across the content: [Left] for [Right], [Top] for [Bottom], and back. */ + public val opposite: DockSide + get() = + when (this) { + Left -> Right + Right -> Left + Top -> Bottom + Bottom -> Top + } } /** @@ -68,14 +86,41 @@ public sealed interface SatellitePlacement { * A panel composed inside a [DockLayout] of the window the satellite is * docked into ([SatelliteEntry.dockHost]). * + * How the panels on one side share it is the layout's decision + * (`DockLayout(layeredSides = …)`), and the two numbers here serve the two + * arrangements: on a *split* side the panels divide the side's length in + * proportion to their [weight] and share its thickness + * ([SatelliteWorkspace.dockExtent]); on a *layered* side each panel is a + * full-length layer of its own [extent], from the edge inwards. Both are + * kept up to date by the layout's splitters and travel with the + * [SatelliteLayoutSnapshot]. + * * @property side the edge the panel attaches to. - * @property order position among the panels docked on the same side, low - * to high from the top (left/right sides) or the left (top/bottom sides). + * @property order rank among the panels docked on the same side of the + * same layout, low to high from the top (left/right sides) or the left + * (top/bottom sides) on a split side, and from the edge towards the + * content on a layered one. [SatelliteWorkspace.dock] and + * [SatelliteWorkspace.undock] keep a side's ranks contiguous from `0` + * and remember the rank a satellite leaves with, so it comes back to + * it; a declared placement's order is the position it is inserted at. + * @property extent the panel's own thickness on a layered side — its + * width on [DockSide.Left] / [DockSide.Right], its height on + * [DockSide.Top] / [DockSide.Bottom]. `null` falls back to the side's + * [SatelliteWorkspace.dockExtent]; [SatelliteWorkspace.dock] seeds it + * from the floating window's size. Ignored on a split side. + * @property weight the panel's share of a split side's length, relative + * to its neighbours. Ignored on a layered side. */ public data class Docked( val side: DockSide, val order: Int = 0, - ) : SatellitePlacement + val extent: Dp? = null, + val weight: Float = 1f, + ) : SatellitePlacement { + init { + require(weight > 0f) { "weight must be positive, was $weight" } + } + } } private const val DEFAULT_GAP_DP = 12 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWindow.kt index 5e89a2542..b64b3c028 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWindow.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge -import dev.nucleusframework.window.tao.workspace.supportsScreenPlacement import kotlinx.coroutines.delay /** @@ -485,7 +484,7 @@ private class SatelliteAnchoring( * origin, and the moves they would issue are ignored. Ownership, z-order * and the hide-while-parent-fills rule still apply. */ - val canPlace: Boolean get() = satellite.supportsScreenPlacement + val canPlace: Boolean get() = satellite.canPlaceOnScreen private var offsetXPx = 0 private var offsetYPx = 0 @@ -829,7 +828,7 @@ private fun anchoredWindowPosition( ): WindowPosition { // Native Wayland: the parent rect this would anchor to is the screen // origin, and the compositor places the window anyway. - if (!parent.supportsScreenPlacement) return WindowPosition.PlatformDefault + if (!parent.canPlaceOnScreen) return WindowPosition.PlatformDefault val scale = parent.scaleFactor.takeIf { it > 0f } ?: 1f val childSizePx = Size(state.size.width.value * scale, state.size.height.value * scale) val origin = anchoredOriginPx(parent, state, childSizePx) ?: return WindowPosition.PlatformDefault diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspace.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspace.kt index a0c8f535c..673220fc9 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspace.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspace.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.roundToIntRect +import dev.nucleusframework.window.tao.workspace.DockDropZone import dev.nucleusframework.window.tao.workspace.DragController import dev.nucleusframework.window.tao.workspace.HostGeometry import dev.nucleusframework.window.tao.workspace.HostGeometryRegistry @@ -24,8 +25,8 @@ import dev.nucleusframework.window.tao.workspace.TransferGhostSource import dev.nucleusframework.window.tao.workspace.WindowGroup import dev.nucleusframework.window.tao.workspace.clientOriginPx import dev.nucleusframework.window.tao.workspace.sanitizedOrNull -import dev.nucleusframework.window.tao.workspace.supportsScreenPlacement import dev.nucleusframework.window.tao.workspace.warnScreenPlacementUnsupported +import kotlin.math.abs /** * One satellite known to a [SatelliteWorkspace]: identity, placement and the @@ -42,6 +43,25 @@ public class SatelliteEntry internal constructor( title: String, initialPlacement: SatellitePlacement, isOpen: Boolean, + /** + * The sides this satellite may be docked on. Every other side is neither + * offered to a drag nor accepted by [SatelliteWorkspace.dock]; empty means + * the satellite only ever floats. Declared with [Satellite]. + */ + public val dockSides: Set = DockSide.entries.toSet(), + /** + * Whether this satellite can be a window of its own. `false` is a fixed + * panel: [SatelliteWorkspace.undock] refuses it, a drag can only move it + * within the dock, and a restore never floats it. Declared with [Satellite]. + */ + public val isFloatable: Boolean = true, + /** + * Whether the user may change this satellite's rank on its side. `false` + * pins it to the rank it was declared with: its own drag offers it none, + * and another panel can only be dropped after it, never in front of it. + * Declared with [Satellite]. + */ + public val isReorderable: Boolean = true, ) { /** Human-readable title, shown by the default header. */ public var title: String by mutableStateOf(title) @@ -66,9 +86,21 @@ public class SatelliteEntry internal constructor( /** `true` while [placement] is [SatellitePlacement.Docked]. */ public val isDocked: Boolean get() = placement is SatellitePlacement.Docked - /** The side [SatelliteScope.dock] targets when none is given: the last docked side. */ + /** `true` while the satellite is open and declared, i.e. a [DockLayout] would show its panel. */ + internal val isShown: Boolean get() = isOpen && content != null + + /** + * The side [SatelliteScope.dock] targets when none is given: the last + * docked side — to begin with the declared one, else the right side when + * [dockSides] allows it, else the first side it allows. + */ public var preferredDockSide: DockSide by - mutableStateOf((initialPlacement as? SatellitePlacement.Docked)?.side ?: DockSide.Right) + mutableStateOf( + (initialPlacement as? SatellitePlacement.Docked)?.side + ?: DockSide.Right.takeIf { it in dockSides } + ?: dockSides.firstOrNull() + ?: DockSide.Right, + ) internal set /** @@ -83,6 +115,15 @@ public class SatelliteEntry internal constructor( /** Floating geometry to return to when undocking without a lift-off rect. */ internal var lastFloating: SatellitePlacement.Floating = floatingOf(initialPlacement) + /** + * The docked placement this satellite last held on each side it has left + * — the declared one to begin with — so [SatelliteWorkspace.dock] can put + * it back at the rank and the share it had there rather than at the end + * of the stack. + */ + internal val dockMemory: MutableMap = + (initialPlacement as? SatellitePlacement.Docked)?.let { mutableMapOf(it.side to it) } ?: mutableMapOf() + internal var content: (@Composable SatelliteScope.() -> Unit)? by mutableStateOf(null) internal var header: (@Composable SatelliteScope.() -> Unit)? by mutableStateOf(null) @@ -118,8 +159,13 @@ public data class SatelliteSnapshot( * satellite's placement and open state plus the dock extents. Produce it with * [SatelliteWorkspace.snapshot], apply it with [SatelliteWorkspace.restore]. * + * A docked satellite's own size — [SatellitePlacement.Docked.extent] and + * [SatellitePlacement.Docked.weight] — rides in its placement, so the + * per-panel geometry of a layered or split side is part of the picture too. + * * @property satellites snapshots keyed by satellite id. - * @property dockExtents width (left/right) or height (top/bottom) of each dock side. + * @property dockExtents width (left/right) or height (top/bottom) of each + * split dock side, shared by the panels on it. */ public data class SatelliteLayoutSnapshot( val satellites: Map, @@ -219,10 +265,41 @@ public class SatelliteWorkspace( public fun plannedDockExtent( entry: SatelliteEntry, side: DockSide, - ): Dp = - extents[side] ?: entry.windowState.size + ): Dp = extents[side] ?: dockSeedExtent(entry, side) + + /** + * The thickness [entry] brings with it when docked on [side]: its own + * extent when it comes from a dock on the same axis, else the size of its + * floating window along that axis. What [dock] gives the panel and, for a + * side with no extent of its own yet, what it seeds the side with — so a + * drop preview drawn at this width shows the width the drop produces. + */ + internal fun dockSeedExtent( + entry: SatelliteEntry, + side: DockSide, + ): Dp { + val docked = entry.placement as? SatellitePlacement.Docked + if (docked != null && docked.side.isVertical == side.isVertical) { + return docked.extent ?: dockExtent(docked.side) + } + return entry.windowState.size .let { if (side.isVertical) it.width else it.height } .coerceAtLeast(MinDockExtent) + } + + /** + * The weight [entry] takes among the panels of a split side once docked on + * [side]: the one it has where it is docked now, else the one it last held + * on [side], else `1`. What [dock] gives the panel, and what the drop + * preview divides the stack with. + */ + internal fun dockSeedWeight( + entry: SatelliteEntry, + side: DockSide, + ): Float = + (entry.placement as? SatellitePlacement.Docked)?.weight + ?: entry.dockMemory[side]?.weight + ?: 1f /** Sets [dockExtent]; clamped to [MinDockExtent]. Driven by the [DockLayout] splitters. */ public fun setDockExtent( @@ -232,6 +309,41 @@ public class SatelliteWorkspace( extents[side] = extent.coerceAtLeast(MinDockExtent) } + /** + * Sets the own thickness of the docked satellite [id] + * ([SatellitePlacement.Docked.extent]), clamped to [MinDockExtent]. What + * the splitter of a panel on a *layered* side drags; a no-op for a + * satellite that is not docked. + */ + public fun setDockedExtent( + id: String, + extent: Dp, + ) { + updateDocked(id) { it.copy(extent = extent.coerceAtLeast(MinDockExtent)) } + } + + /** + * Sets the share of a split side the docked satellite [id] takes + * ([SatellitePlacement.Docked.weight]); values at or below zero are + * clamped to a small positive share. What the divider between two panels + * on a *split* side drags; a no-op for a satellite that is not docked. + */ + public fun setDockedWeight( + id: String, + weight: Float, + ) { + updateDocked(id) { it.copy(weight = weight.coerceAtLeast(MIN_DOCK_WEIGHT)) } + } + + private fun updateDocked( + id: String, + transform: (SatellitePlacement.Docked) -> SatellitePlacement.Docked, + ) { + val entry = entryMap[id] ?: return + val docked = entry.placement as? SatellitePlacement.Docked ?: return + entry.placement = transform(docked) + } + // ── Members ────────────────────────────────────────────────────────── /** @@ -284,9 +396,31 @@ public class SatelliteWorkspace( /** * Docks the satellite [id] on [side] of a [DockLayout]: the one in [host] * when given, else — for a satellite already docked — the host it is in, - * else the current [owner]'s. [order] positions it among the panels on - * that side; `null` appends it after them. The first satellite docked on a - * side seeds that side's [dockExtent] from its floating size. + * else the current [owner]'s. + * + * [order] is the position the panel takes among the panels docked on that + * side of that layout, closed ones included, counted from the top (left + * and right sides) or the left (top and bottom sides) on a split side and + * from the edge inwards on a layered one; the panels from there on move + * one rank down, and the ranks of the side are kept contiguous from `0`. + * `null` puts the satellite back at the rank it last held on that side — + * the one it was declared with, or the one it left by [undock] or by a + * move to another side — and appends it when it has never sat there, so a + * palette that is floated and docked again lands where it was rather + * than at the end. A re-dock on the side it already occupies keeps its + * rank. + * + * The satellite brings its thickness along ([dockSeedExtent]): its own + * extent when it comes from a dock on the same axis, else the size of its + * floating window. A side with no [dockExtent] of its own yet is seeded + * with it, so the panel keeps the width it had wherever it lands. The + * weight is kept across a move between docks and remembered with the rank. + * + * A side the satellite was not declared for ([SatelliteEntry.dockSides]) + * is refused: nothing changes. [order] is ignored for a pinned satellite + * ([SatelliteEntry.isReorderable] `false`), which keeps its declared + * rank, and is pushed past the pinned panels of the side for any other — + * a drop can join them but never displace one. */ public fun dock( id: String, @@ -295,33 +429,80 @@ public class SatelliteWorkspace( host: TaoWindow? = null, ) { val entry = entryMap[id] ?: return + if (side !in entry.dockSides) return val current = entry.placement - if (current is SatellitePlacement.Floating) { - entry.lastFloating = currentFloating(entry, current) - if (side !in extents) setDockExtent(side, plannedDockExtent(entry, side)) - } - entry.placement = SatellitePlacement.Docked(side, order ?: nextOrder(side, exclude = entry)) - entry.preferredDockSide = side + val extent = dockSeedExtent(entry, side) + val weight = dockSeedWeight(entry, side) + if (current is SatellitePlacement.Floating) entry.lastFloating = currentFloating(entry, current) + leaveStack(entry) + val remembered = entry.dockMemory[side] + if (side !in extents) setDockExtent(side, extent) entry.dockHost = host?.takeIf { it in members } ?: entry.dockHost?.takeIf { it in members } ?: owner + entry.placement = SatellitePlacement.Docked(side, order = 0, extent, weight) + // A pinned panel takes the rank it was declared with, whatever the + // caller asks: that rank is the whole point of pinning it. + insertInStack(entry, order?.takeIf { entry.isReorderable } ?: remembered?.order) + entry.preferredDockSide = side + } + + /** + * Docks the satellite [id] where a drag resolved to: [DockTarget.order] + * counts the panels *shown* on the side — what the user aimed between — + * and is turned into the rank among every panel docked there, closed ones + * included, before [dock] applies it. + */ + internal fun dropAt( + id: String, + target: DockTarget, + ) { + val entry = entryMap[id] ?: return + val order = + target.order?.let { slot -> + val stack = stackOf(target.side, target.host, exclude = entry) + val before = stack.filter { it.isShown }.getOrNull(slot) + before?.let(stack::indexOf) ?: stack.size + } + dock(id, target.side, order, target.host) + } + + /** + * The target that drops the docked satellite [entry] back where it is in + * [host]: its side, at its own slot among the panels shown there — `null` + * order when it is alone, which is what a drop on an empty side resolves + * to. `null` for a satellite not docked in [host]. + */ + internal fun ownTarget( + entry: SatelliteEntry, + host: TaoWindow, + ): DockTarget? { + val docked = entry.placement as? SatellitePlacement.Docked ?: return null + if (entry.dockHost !== host) return null + if (!entry.isReorderable) return DockTarget(host, docked.side) + val shown = stackOf(docked.side, host, exclude = null).filter { it.isShown } + return DockTarget(host, docked.side, shown.indexOf(entry).takeIf { shown.size > 1 && it >= 0 }) } /** * Turns the docked satellite [id] back into a floating window: at * [placement] when given, else over the panel it just was when the host's * geometry is known, else at its last floating position. No-op for a - * floating satellite. + * floating satellite, and for a fixed one + * ([SatelliteEntry.isFloatable] `false`), which never leaves the dock. */ public fun undock( id: String, placement: SatellitePlacement.Floating? = null, ) { val entry = entryMap[id] ?: return + if (!entry.isFloatable) return val docked = entry.placement as? SatellitePlacement.Docked ?: return entry.preferredDockSide = docked.side - applyFloating(entry, placement ?: liftOffPlacement(entry) ?: entry.lastFloating) + val floating = placement ?: liftOffPlacement(entry) ?: entry.lastFloating + leaveStack(entry) + applyFloating(entry, floating) } /** @@ -384,6 +565,25 @@ public class SatelliteWorkspace( /** The drag currently owning the feedback state, or `null`. */ internal val activeDragSession: SatelliteDragSession? get() = drags.active + /** + * How the satellite in flight is being carried, or `null` while none is. + * + * Read it to draw a drag the way it actually behaves: + * [SatelliteDragKind.Window] moves a real window under the pointer, so + * [dragGhost] is published and a torn-out panel is something the user sees + * leaving; [SatelliteDragKind.Transfer] carries the satellite in the + * platform's drag-and-drop session — the picture under the pointer is the + * drag icon the compositor draws, no window follows, and [dragGhost] stays + * `null`. [draggedSatellite] and [dockPreview] are published either way. + */ + public val dragKind: SatelliteDragKind? + get() = + when { + drags.active != null -> SatelliteDragKind.Window + transferDrag != null -> SatelliteDragKind.Transfer + else -> null + } + /** `true` while [session] is the one the workspace is publishing. */ internal fun isLiveDrag(session: SatelliteDragSession): Boolean = drags.isLive(session) @@ -404,13 +604,73 @@ public class SatelliteWorkspace( * nothing of it is on screen to drop onto. `null` over content or outside * every layout. */ - public fun dockTargetAt(screenPx: Offset): DockTarget? { + public fun dockTargetAt(screenPx: Offset): DockTarget? = zoneOf { it.dockHitTest(screenPx, DockZoneWidth) } + + /** + * The dock zone the satellite being dragged would land in, decided from + * **where the satellite is** rather than from where the pointer is: the + * zone [draggedScreenRectPx] — the floating window's frame, or the ghost + * of a panel being torn out — has entered, the nearest edge winning. That + * is what the user sees moving, so a palette whose edge has reached the + * left strip highlights it even though the pointer is still in the middle + * of the palette. + * + * The rect has to overlap the layout at all; a window merely parked beside + * one is no drop. When the rect covers several zones at once — a palette + * larger than the layout — [pointerScreenPx] breaks the tie, so a drop + * still goes where the user is aiming. Overlapping layouts are tried as + * for the pointer overload: the [owner]'s first, then by focus recency, + * stopping at the layout the pointer is over. + */ + public fun dockTargetAt( + draggedScreenRectPx: Rect, + pointerScreenPx: Offset, + ): DockTarget? = zoneOf { it.dockHitTest(draggedScreenRectPx, pointerScreenPx, DockZoneWidth) } + + /** + * Whether dragging [entry] could change anything: it can float, it has + * another side or another window's dock to go to, or it may take another + * rank among the panels shown beside it. `false` makes + * [Modifier.satelliteDragHandle] inert rather than leaving a gesture that + * cannot end anywhere. + */ + internal fun canBeDragged(entry: SatelliteEntry): Boolean { + if (entry.isFloatable) return true + val docked = entry.placement as? SatellitePlacement.Docked ?: return true + if (entry.dockSides.any { it != docked.side }) return true + if (members.size > 1) return true + return entry.isReorderable && stackOf(docked.side, entry.dockHost, exclude = entry).any { it.isShown } + } + + /** [dockTargetAt] resolved for the satellite [entry] — see [targetFor]. */ + internal fun dockTargetFor( + entry: SatelliteEntry, + draggedScreenRectPx: Rect, + pointerScreenPx: Offset, + ): DockTarget? = dockTargetAt(draggedScreenRectPx, pointerScreenPx)?.let { targetFor(entry, it) } + + /** + * [target] as a target for [entry]: `null` on a side [entry] was not + * declared for, and without a rank for a pinned one — [dock] would ignore + * it, so a preview drawn from it would promise a move that does not happen. + */ + internal fun targetFor( + entry: SatelliteEntry, + target: DockTarget, + ): DockTarget? = + when { + target.side !in entry.dockSides -> null + entry.isReorderable -> target + else -> target.copy(order = null) + } + + private inline fun zoneOf(hitTest: (HostGeometry) -> DockHit?): DockTarget? { val hit = dockHosts .ordered(group.membersByRecency) .asSequence() .filter { !it.minimized() } - .firstNotNullOfOrNull { it.dockHitTest(screenPx, DockZoneWidth) } + .firstNotNullOfOrNull(hitTest) return (hit as? DockHit.Zone)?.target } @@ -439,7 +699,7 @@ public class SatelliteWorkspace( is SatelliteDragOrigin.FloatingWindow -> origin.window is SatelliteDragOrigin.DockedPanel -> origin.host } - if (!from.supportsScreenPlacement) { + if (!from.canPlaceOnScreen) { from.warnScreenPlacementUnsupported("SatelliteWorkspace.beginDrag") return null } @@ -601,12 +861,26 @@ public class SatelliteWorkspace( title: String, initialPlacement: SatellitePlacement, initiallyOpen: Boolean, + dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, + reorderable: Boolean = true, ): SatelliteEntry { entryMap[id]?.let { it.title = title return it } - val entry = SatelliteEntry(id, title, initialPlacement, initiallyOpen) + require((initialPlacement as? SatellitePlacement.Docked)?.side?.let { it in dockSides } != false) { + "satellite '$id' is declared docked on ${(initialPlacement as SatellitePlacement.Docked).side}, " + + "a side its dockSides $dockSides do not allow" + } + require(floatable || initialPlacement is SatellitePlacement.Docked) { + "satellite '$id' cannot float and is not declared docked: it would have nowhere to live" + } + require(reorderable || initialPlacement is SatellitePlacement.Docked) { + "satellite '$id' is pinned to a rank and is not declared docked: there is no rank to pin it to" + } + val entry = + SatelliteEntry(id, title, initialPlacement, initiallyOpen, dockSides, floatable, reorderable) if (initialPlacement is SatellitePlacement.Docked) entry.dockHost = owner entryMap[id] = entry pendingRestore.remove(id)?.let { apply(entry, it) } @@ -625,13 +899,22 @@ public class SatelliteWorkspace( saved: SatelliteSnapshot, ) { entry.isOpen = saved.isOpen + // A snapshot is a consistent picture of every side, so the ranks it + // carries are applied as they are; only the memory is kept up to date. + (entry.placement as? SatellitePlacement.Docked)?.let { entry.dockMemory[it.side] = it } when (val placement = saved.placement) { is SatellitePlacement.Floating -> { + // A fixed panel has no floating placement to go back to: the + // snapshot predates the declaration, and the dock stands. + if (!entry.isFloatable) return applyFloating(entry, placement) // Already on screen: move it, since placement is otherwise one-shot. entry.windowState.reanchor() } is SatellitePlacement.Docked -> { + // A snapshot written before the declaration changed may name a + // side the satellite no longer docks on: its placement is left as it is. + if (placement.side !in entry.dockSides) return val current = entry.placement if (current is SatellitePlacement.Floating) entry.lastFloating = currentFloating(entry, current) entry.placement = placement @@ -702,15 +985,63 @@ public class SatelliteWorkspace( ) } - private fun nextOrder( + /** + * The panels docked on [side] of [host]'s layout — open or not, every one + * of them holds a rank — in rank order, without [exclude]. + */ + private fun stackOf( side: DockSide, - exclude: SatelliteEntry, - ): Int = + host: TaoWindow?, + exclude: SatelliteEntry?, + ): List = entryMap.values - .filter { it !== exclude } - .mapNotNull { (it.placement as? SatellitePlacement.Docked)?.takeIf { d -> d.side == side }?.order } - .maxOrNull() - ?.plus(1) ?: 0 + .filter { + it !== exclude && + it.dockHost === host && + (it.placement as? SatellitePlacement.Docked)?.side == side + }.sortedWith(compareBy({ (it.placement as SatellitePlacement.Docked).order }, { it.id })) + + /** + * Takes [entry] out of the stack it is docked in, remembering the + * placement it held there and closing the rank it leaves behind. A no-op + * for a floating satellite. + */ + private fun leaveStack(entry: SatelliteEntry) { + val docked = entry.placement as? SatellitePlacement.Docked ?: return + entry.dockMemory[docked.side] = docked + renumber(stackOf(docked.side, entry.dockHost, exclude = entry)) + } + + /** + * Puts the freshly docked [entry] at [index] of its side's stack — the + * end when `null` or past it — and renumbers the stack from `0`. + * + * A reorderable [entry] cannot land in front of a pinned panel: the ranks + * are contiguous, so inserting there would shift every pinned panel from + * that rank on. The insertion is pushed past the last of them. A pinned + * [entry] itself is placed at the rank it asks for, which is the one it + * was declared with. + */ + private fun insertInStack( + entry: SatelliteEntry, + index: Int?, + ) { + val docked = entry.placement as SatellitePlacement.Docked + val stack = stackOf(docked.side, entry.dockHost, exclude = entry).toMutableList() + val floor = if (entry.isReorderable) pinnedFloor(stack) else 0 + stack.add((index ?: stack.size).coerceIn(floor, stack.size), entry) + renumber(stack) + } + + /** The first rank of [stack] a reorderable panel may take: past every pinned panel. */ + internal fun pinnedFloor(stack: List): Int = stack.indexOfLast { !it.isReorderable } + 1 + + private fun renumber(stack: List) { + stack.forEachIndexed { rank, member -> + val docked = member.placement as SatellitePlacement.Docked + if (docked.order != rank) member.placement = docked.copy(order = rank) + } + } /** Constants shared with [DockLayout]. */ public companion object { @@ -723,6 +1054,9 @@ public class SatelliteWorkspace( /** Depth of the drop zone inside each edge of a [DockLayout]. */ public val DockZoneWidth: Dp = 64.dp + /** Smallest share a split-side panel can be dragged down to; keeps its divider reachable. */ + private const val MIN_DOCK_WEIGHT = 0.05f + /** Pins the satellite's top-left corner at [offset] from the owner's, sliding on-screen if needed. */ internal fun offsetPositioner(offset: DpOffset): WindowPositioner = WindowPositioner( @@ -734,10 +1068,38 @@ public class SatelliteWorkspace( } } -/** A dock zone: the [side] of the [DockLayout] in [host]. */ +/** + * How a satellite drag in flight is carried — see [SatelliteWorkspace.dragKind]. + */ +public enum class SatelliteDragKind { + /** + * The satellite's own window, or a ghost window standing in for a docked + * panel, follows the pointer. [SatelliteWorkspace.dragGhost] is published + * for a panel being torn out. + */ + Window, + + /** + * The platform's drag-and-drop session carries it, because the window + * cannot be placed by the app ([TaoWindow.canPlaceOnScreen] `false`). The + * source is not told where the pointer is: the window under it resolves + * the drop and the source acts on that record. + */ + Transfer, +} + +/** + * A dock zone: the [side] of the [DockLayout] in [host], and the rank + * ([SatellitePlacement.Docked.order]) the dropped panel takes among the + * panels shown on that side — `null` leaves the choice to + * [SatelliteWorkspace.dock]: the rank the satellite last held there, else the + * end. A drag resolves the rank from where the pointer is over the side's + * stack, so a panel can be dropped between two others. + */ public data class DockTarget( val host: TaoWindow, val side: DockSide, + val order: Int? = null, ) /** @@ -816,6 +1178,128 @@ internal fun HostGeometry.dockHitTest( return if (side != null) DockHit.Zone(DockTarget(host, side)) else DockHit.Content } +/** + * Where the dragged satellite [draggedRectPx] falls on this [DockLayout] + * geometry, with the pointer at [pointerPx]: [DockHit.Zone] for the zone it + * has entered, [DockHit.Content] when it is over the layout but clear of every + * zone, `null` when neither it nor the pointer is on this layout at all. + */ +internal fun HostGeometry.dockHitTest( + draggedRectPx: Rect, + pointerPx: Offset, + zoneWidth: Dp, +): DockHit? { + val rect = layoutScreenRectPx() ?: return null + val overlaps = !rect.intersect(draggedRectPx).isEmpty + val onPointer = rect.contains(pointerPx) + if (!overlaps && !onPointer) return null + val zones = zoneScreenRectsPx(zoneWidth.value * scaleFactor()) ?: return null + // Over the layout, in a zone or not: no other layout under it is + // consulted, exactly as for a pointer hit. + val side = dockSideEntered(zones, draggedRectPx, pointerPx) ?: return DockHit.Content + return DockHit.Zone(DockTarget(host, side, zones.getValue(side).slotAt(pointerPx))) +} + +/** + * The zone of [zones] the dragged satellite has brought its edge to, or — + * failing that — the zone [pointer] is in. + * + * [zones] are the rectangles the target actually draws, so the region that + * lights up is the region a drag is measured against: on a layered side that + * is the strip inset behind the layers already docked there, not the window's + * own edge, which sits behind them. + * + * "Brought its edge to" is the satellite's own edge within one zone thickness + * of the zone's outer edge, and the satellite overlapping the zone across the + * other axis. The edge rather than any overlap is what keeps a tear-out + * possible: a panel as tall as the layout overlaps the top and bottom strips + * wherever it is dragged, and treating that as "entered" would pin it to a + * zone for the whole gesture. + * + * The pointer over a side's stack — its [DockDropZone.slots] — is a zone + * entered too: that is how a panel is dropped between two others. Several + * zones at once — a palette larger than the layout reaches all four, a strip + * runs across the corner of a neighbouring stack — are resolved by the + * pointer: the one stack it is over, else the one strip it is in, so an + * ambiguous overlap still drops where the user aims; else the closest edge + * wins. + */ +internal fun dockSideEntered( + zones: Map, + dragged: Rect, + pointer: Offset, +): DockSide? { + val live = zones.filterValues { !it.strip.isEmpty } + val gaps = + live + .filter { (side, zone) -> overlapsAcross(zone.strip, dragged, side) } + .mapValues { (side, zone) -> abs(edgePx(dragged, side) - outerEdgePx(zone.strip, side)) } + .filter { (side, gap) -> gap <= thicknessPx(live.getValue(side).strip, side) } + val overStack = live.filterValues { zone -> zone.slots.any { it.contains(pointer) } }.keys + val underPointer = live.filterValues { it.contains(pointer) }.keys + val candidates = gaps.keys + underPointer + candidates.singleOrNull()?.let { return it } + if (candidates.isEmpty()) return null + overStack.singleOrNull()?.let { return it } + underPointer.singleOrNull()?.let { return it } + return candidates.minBy { gaps[it] ?: Float.MAX_VALUE } +} + +/** Whether [dragged] overlaps [zone] along the axis the zone runs on. */ +private fun overlapsAcross( + zone: Rect, + dragged: Rect, + side: DockSide, +): Boolean = + if (side.isVertical) { + dragged.top < zone.bottom && zone.top < dragged.bottom + } else { + dragged.left < zone.right && zone.left < dragged.right + } + +/** The zone's outer boundary: the one against the layout's [side] edge. */ +private fun outerEdgePx( + zone: Rect, + side: DockSide, +): Float = + when (side) { + DockSide.Left -> zone.left + DockSide.Right -> zone.right + DockSide.Top -> zone.top + DockSide.Bottom -> zone.bottom + } + +/** The zone's own thickness: how far a satellite's edge may sit from it and still count. */ +private fun thicknessPx( + zone: Rect, + side: DockSide, +): Float = if (side.isVertical) zone.width else zone.height + +/** A strip of [widthPx] inside [rect]'s [side] edge: the zone a plain layout offers. */ +internal fun edgeStripPx( + rect: Rect, + side: DockSide, + widthPx: Float, +): Rect = + when (side) { + DockSide.Left -> Rect(rect.left, rect.top, rect.left + widthPx, rect.bottom) + DockSide.Right -> Rect(rect.right - widthPx, rect.top, rect.right, rect.bottom) + DockSide.Top -> Rect(rect.left, rect.top, rect.right, rect.top + widthPx) + DockSide.Bottom -> Rect(rect.left, rect.bottom - widthPx, rect.right, rect.bottom) + } + +/** The edge of [rect] that faces [side]'s zone. */ +private fun edgePx( + rect: Rect, + side: DockSide, +): Float = + when (side) { + DockSide.Left -> rect.left + DockSide.Right -> rect.right + DockSide.Top -> rect.top + DockSide.Bottom -> rect.bottom + } + /** * The dock zone of [rect] that [point] falls in: the nearest edge when the * point is within [zonePx] of it, else `null` (over the content, or outside diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabDragSessions.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabDragSessions.kt index 6385af2c1..8ffc1e813 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabDragSessions.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabDragSessions.kt @@ -107,7 +107,26 @@ private class TabWindowDragSession( // Its own strip moved with the window and is under the pointer the // whole time; only another window's strip is a target, and the search // has to look *past* its own rather than stop at it. - workspace.dropPreview = workspace.dropTargetAt(pointer, exclude = entry, excludeGroup = entry.group) + // + // That own strip is also what stands in for the card here: the window + // is what the user is moving, so a merge is previewed as soon as its + // strip reaches another's, before the pointer is over it — the same + // rule as for a tab carried under a ghost. + workspace.dropPreview = + workspace.dropTargetAt(stripScreenRectPx(topLeft), pointer, exclude = entry, excludeGroup = entry.group) + workspace.dragPointerScreenPx = pointer + } + + /** + * Where this window's own strip would be with its frame at [topLeftPx]: + * the band that stands in for the dragged card. `null` before the strip + * has published its geometry. + */ + private fun stripScreenRectPx(topLeftPx: Offset): Rect? { + val geometry = workspace.stripHosts[origin.window] ?: return null + val outer = origin.outerBoundsPx() ?: return null + val clientInset = (geometry.clientOriginPx() ?: return null) - Offset(outer[0].toFloat(), outer[1].toFloat()) + return geometry.layoutBoundsInWindowPx.translate(topLeftPx + clientInset) } override fun end(pointerScreenPx: Offset) { @@ -137,24 +156,49 @@ private class TabTearOffDragSession( /** The source window's px-per-dp, carried to the ghost and the new window. */ private val scaleFactor: Float, ) : TabDragSessionBase(workspace) { + private val velocity = HorizontalVelocity() + override fun update(pointerScreenPx: Offset) { if (!isLive) return pointer = pointerScreenPx.sanitizedOrNull() ?: pointer - workspace.dropPreview = workspace.dropTargetAt(pointer, exclude = entry) - // Follows the pointer for the whole gesture, including over a strip: - // the tab is out of its strip as soon as the drag starts, and seeing it - // hover is what makes the tear-out read. - workspace.dragGhost = TabDragGhost(entry, Rect(pointer - grabOffsetPx, tabSizePx), scaleFactor) + workspace.dragVelocityPxPerSecond = velocity.sample(pointer.x) + // Resolved from the card as well as from the pointer: a tab whose top + // edge has come up into a strip is previewed there before the pointer + // reaches it, so the drop reads while the card is still below the + // strip rather than over it. + val card = ghostRectPx() + val target = workspace.dropTargetAt(card, pointer, exclude = entry) + workspace.dropPreview = target + workspace.dragPointerScreenPx = pointer + // Over its own strip the tab has not left: the strip holds it under the + // pointer and its neighbours make room, the way a browser's do. Over + // another window's strip, or clear of every strip, it *is* leaving — + // and seeing it hover is what makes the move and the tear-out read. + val inOwnStrip = target != null && target.group === entry.group + workspace.dragGhost = if (inOwnStrip) null else TabDragGhost(entry, card, scaleFactor) } + /** Where the card is on screen: the grabbed tab, carried at the grab offset. */ + private fun ghostRectPx(): Rect = Rect(pointer - grabOffsetPx, tabSizePx) + override fun end(pointerScreenPx: Offset) { if (!isLive) return pointer = pointerScreenPx.sanitizedOrNull() ?: pointer val drop = pointer - val target = workspace.dropTargetAt(drop, exclude = entry) + val target = workspace.dropTargetAt(ghostRectPx(), drop, exclude = entry) + val group = entry.group + // Read before the release clears the drag: the slide home starts with + // the speed the pointer had, so a flick carries through. + val speed = workspace.dragVelocityPxPerSecond cancel() if (target != null) { - workspace.move(entry.id, target.group, target.index) + if (target.group === group && group != null) { + // Inside its own strip: the strip slides the tab into its new + // place and applies the reorder itself, so nothing jumps. + workspace.pendingReorder = TabReorderSettle(entry, group, target.index, speed) + } else { + workspace.move(entry.id, target.group, target.index) + } return } // A window the size of the one it came from, with the grabbed tab @@ -163,6 +207,47 @@ private class TabTearOffDragSession( } } +/** + * How fast the pointer is travelling along one axis, from the samples the + * session is fed: the strip hands it to the spring that slides a released tab + * home, so a flick carries through and a slow move does not overshoot. + * + * Smoothed over the last samples rather than taken from the last pair: one + * pointer report can land a millisecond after the one before it and read as + * thousands of px per second. + */ +private class HorizontalVelocity { + private var lastX = Float.NaN + private var lastNanos = 0L + private var smoothed = 0f + + fun sample(x: Float): Float { + val now = System.nanoTime() + val elapsed = now - lastNanos + if (!lastX.isNaN() && elapsed in 1..MAX_GAP_NANOS) { + val instant = (x - lastX) / (elapsed / NANOS_PER_SECOND) + smoothed = smoothed * (1f - SMOOTHING) + instant * SMOOTHING + } else if (lastX.isNaN() || elapsed > MAX_GAP_NANOS) { + // A first sample, or a pause long enough that the pointer has + // stopped: no speed to carry. + smoothed = 0f + } + lastX = x + lastNanos = now + return smoothed + } + + private companion object { + const val NANOS_PER_SECOND = 1_000_000_000f + + /** Longer than this between samples and the pointer was at rest, not travelling. */ + const val MAX_GAP_NANOS = 100_000_000L + + /** How much of the newest sample the estimate takes: enough to follow a flick, not a jitter. */ + const val SMOOTHING = 0.4f + } +} + /** * The DnD-carried tab drag (native Wayland, see [TransferDrag]) of [entry] out * of [group]'s strip in [window]. Sizes are still readable there, so the diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabHoverPreview.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabHoverPreview.kt new file mode 100644 index 000000000..9e715a9bb --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabHoverPreview.kt @@ -0,0 +1,439 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.math.max +import kotlin.math.roundToInt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * What the card of a hovered tab gets to see: the tab, its workspace, and the + * last picture taken of its body. + */ +public interface TabHoverPreviewScope { + /** The workspace the tab belongs to. */ + public val workspace: TabWorkspace + + /** The group whose strip the pointer is over. */ + public val group: TabWindowGroup + + /** The tab under the pointer. */ + public val tab: TabEntry + + /** + * The last picture taken of [tab]'s body, or `null` when there is none — + * captures are off, or the tab has not been on screen yet. See + * [TabEntry.thumbnail]. + */ + public val thumbnail: ImageBitmap? get() = tab.thumbnail +} + +internal class TabHoverPreviewScopeImpl( + override val workspace: TabWorkspace, + override val group: TabWindowGroup, + override val tab: TabEntry, +) : TabHoverPreviewScope + +/** + * How a strip previews the tab under the pointer: a browser's hover card, + * shown under the tab after a pause and gone as soon as the pointer leaves it. + * + * Never for the selected tab, whose body is on screen anyway — see + * [TabStripScope.hoveredTab] for every case a card is withheld. + * + * The whole card is [content], so an app draws its own — the title, the path, + * a picture of the page, whatever it knows about the tab — and the stock + * [TabHoverPreviewCard] is one composable it can build on or replace outright: + * + * ```kotlin + * TabStrip( + * hoverPreview = + * TabHoverPreview(delay = 400.milliseconds) { + * TabHoverPreviewCard(subtitle = { Text(documents[tab.id]?.path.orEmpty()) }) + * }, + * ) + * ``` + * + * Pass it to [TabStrip], or compose [TabHoverPreviewPopup] with it in a strip + * written from scratch. + * + * @property delay how long the pointer has to rest on a tab before the first + * card appears. Moving to another tab while one is shown switches at once, + * the way a browser does. + * @property offset where the card sits relative to the tab's bottom-left + * corner — its bottom-*right* in a right-to-left strip, so the card grows + * into the reading direction on both. + * @property nativeLayer whether the card is hosted on a native popup surface + * ([NativePopupLayers]), which is what lets it hang below the window like a + * browser's. `false` draws it inside the window's own scene, where it is + * kept within the window's bounds and clipped by them. + * @property content the card. Composed with the hovered tab as receiver. + */ +@Immutable +public class TabHoverPreview( + public val delay: Duration = HoverPreviewDelay, + public val offset: DpOffset = HoverPreviewOffset, + public val nativeLayer: Boolean = true, + public val content: @Composable TabHoverPreviewScope.() -> Unit = { TabHoverPreviewCard() }, +) { + /** The stock hover card, for a strip that wants a browser's behaviour and nothing else. */ + public companion object { + /** [TabHoverPreview] with every default: the stock card, after the stock pause. */ + public val Default: TabHoverPreview = TabHoverPreview() + } +} + +/** + * The tab the pointer is resting on in this strip, which is what a hover card + * follows. + * + * `null` in every case where a card would be wrong: + * + * - the pointer is over no tab of this strip; + * - the tab under it is the *selected* one — its body is on screen already, + * and a card of what is being read is nothing but in the way; + * - a tab of the workspace is being dragged, which passes it over every + * neighbour in turn without pointing at any of them; + * - a press is in flight on the hovered tab, until the pointer has moved on. + * + * Published by [Modifier.tabSlot], so a strip written from scratch has it as + * soon as it marks its slots. + */ +public val TabStripScope.hoveredTab: TabEntry? + get() { + if (workspace.draggedTab != null || group.hoverBlocked) return null + val id = group.hoveredId ?: return null + if (id == group.selectedId) return null + if (id !in group.ids) return null + return workspace.tab(id) + } + +/** + * The hover card of this strip: [preview]'s content under the tab the pointer + * rests on, at the place [Modifier.tabSlot] published for it. + * + * [TabStrip] composes it for its `hoverPreview`; a strip written from scratch + * composes it once, next to its tabs, and needs nothing else — the tab is + * [hoveredTab] and the anchor is the slot the strip already marks. + * + * The card is never a hover target itself: reaching it with the pointer puts + * it away, since reaching it means having left the tab. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Suppress("FunctionNaming") +@Composable +public fun TabStripScope.TabHoverPreviewPopup(preview: TabHoverPreview = TabHoverPreview.Default) { + val candidate = hoveredTab + // The card waits out `delay` on the first tab and then follows the pointer + // from tab to tab without a pause, as a browser's does. + var shown by remember(group) { mutableStateOf(null) } + LaunchedEffect(candidate, preview.delay) { + if (candidate == null) { + shown = null + return@LaunchedEffect + } + if (shown == null) delay(preview.delay) + shown = candidate + } + + val tab = shown ?: return + // Read off the settled layout the strip publishes, re-read when the strip + // order changes: the slots are written from layout and are not snapshot + // state, so `ids` is what says the anchor may have moved. + val order = group.ids + val density = LocalDensity.current + val position = + remember(tab, order, preview.offset, density) { + val slot = group.slotInWindowPx(tab.id) ?: return@remember null + TabHoverPreviewPosition( + anchorPx = slot, + offsetPx = + with(density) { + IntOffset(preview.offset.x.roundToPx(), preview.offset.y.roundToPx()) + }, + ) + } ?: return + val scope = remember(workspace, group, tab) { TabHoverPreviewScopeImpl(workspace, group, tab) } + + val card = + @Composable { + Popup( + popupPositionProvider = position, + properties = + PopupProperties( + // Never takes focus and never eats a pointer event: + // the card appears while the strip is being used, and + // the click that follows belongs to the tab. + focusable = false, + dismissOnBackPress = false, + dismissOnClickOutside = false, + // On a native surface the card may hang below the + // window, which is where a browser's sits; drawn + // in-scene it has to stay inside the window or it is + // cut off at its edge. + clippingEnabled = !preview.nativeLayer, + ), + ) { + // The card is no target of its own: the moment the pointer + // reaches it, the tab it belongs to has been left behind, and + // a browser's card goes away. It has to be said here — a popup + // surface takes the pointer off the window beneath it, so the + // tab never hears the pointer leave and the card would sit + // over the content it covers until something else moved. + Box(Modifier.onPointerEvent(PointerEventType.Enter) { group.noteHoverExit(tab.id) }) { + preview.content(scope) + } + } + } + if (preview.nativeLayer) NativePopupLayers { card() } else card() +} + +/** + * Where a hover card goes: under the tab it belongs to. + * + * The anchor is the tab's own slot in window pixels — the rect + * [Modifier.tabSlot] publishes — and not the `anchorBounds` handed in, which + * is the strip's whole width: the card is composed once for the strip, not per + * tab, so the tab it points at is the one the strip picked. + */ +internal class TabHoverPreviewPosition( + private val anchorPx: Rect, + private val offsetPx: IntOffset, +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + // The card grows into the reading direction: from the tab's leading + // edge, which is its right in a right-to-left strip. + val x = + if (layoutDirection == LayoutDirection.Rtl) { + anchorPx.right.roundToInt() - popupContentSize.width - offsetPx.x + } else { + anchorPx.left.roundToInt() + offsetPx.x + } + val y = anchorPx.bottom.roundToInt() + offsetPx.y + // Kept within the window across the strip: a card that runs past the + // last tab would otherwise hang off the side of the window. + val maxX = (windowSize.width - popupContentSize.width).coerceAtLeast(0) + return IntOffset(x.coerceIn(0, maxX), y) + } +} + +/** + * The stock hover card: the tab's full title, whatever [subtitle] adds under + * it, and the last picture taken of the tab's body when there is one + * ([TabHoverPreviewScope.thumbnail]). + * + * Colours come from the window and title-bar styles, so the card matches the + * chrome the app installed. Anything else is the app's own card — + * [TabHoverPreview] takes it whole. + * + * @param modifier applied to the card itself, which is where a fixed width or + * a different padding goes. + * @param subtitle a second line under the title: the path of a file, the host + * of a page. Nothing by default, since the workspace knows only the title. + */ +@Composable +public fun TabHoverPreviewScope.TabHoverPreviewCard( + modifier: Modifier = Modifier, + subtitle: (@Composable () -> Unit)? = null, +) { + val titleColors = LocalTitleBarStyle.current.colors + val background = LocalDecoratedWindowStyle.current.colors.background + val shape = RoundedCornerShape(HoverCardCornerRadius) + Column( + modifier = + modifier + .widthIn(min = HoverCardMinWidth, max = HoverCardMaxWidth) + .background(background, shape) + .border(HoverCardBorderWidth, titleColors.border, shape) + .padding(HoverCardPadding), + ) { + BasicText( + text = tab.title, + style = + TextStyle( + color = titleColors.content, + fontSize = HOVER_CARD_TITLE_SP.sp, + fontWeight = FontWeight.Medium, + ), + maxLines = HOVER_CARD_TITLE_LINES, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Spacer(Modifier.height(HoverCardGap)) + subtitle() + } + thumbnail?.let { picture -> + Spacer(Modifier.height(HoverCardGap)) + Image( + bitmap = picture, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(picture.width.toFloat() / picture.height.toFloat()) + .clip(RoundedCornerShape(HoverCardPictureRadius)), + contentScale = ContentScale.Crop, + ) + } + } +} + +/** + * Records the tab's body into a layer of its own and keeps a reduced picture + * of it on the entry, which is what a hover card of a tab that is not the + * selected one has to draw. + * + * Composed by [TabWindows] around the selected tab's body, and only for a + * workspace built with `captureThumbnails` — it sits *above* the relocation + * anchor, so the path from that anchor down to the content is the same in + * every window and `rememberSaveable` state still follows a tab across. + */ +@Suppress("FunctionNaming") +@Composable +internal fun TabThumbnailRecorder( + tab: TabEntry, + content: @Composable () -> Unit, +) { + val recorded = rememberGraphicsLayer() + val reduced = rememberGraphicsLayer() + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + Box( + modifier = + Modifier.fillMaxSize().drawWithContent { + recorded.record { this@drawWithContent.drawContent() } + drawLayer(recorded) + }, + ) { + content() + } + LaunchedEffect(tab, recorded, reduced, density, layoutDirection) { + snapshotFlow { tab.thumbnailRequest }.collectLatest { + // The body has to have drawn once for the layer to hold anything, + // and a picture taken the frame a tab arrives catches it mid + // animation: one settle, then the readback. `collectLatest` + // collapses a burst of requests into the last one. + delay(ThumbnailSettleMillis) + reducedPicture(recorded, reduced, density, layoutDirection)?.let { tab.thumbnail = it } + } + } +} + +/** + * [source] drawn into [into] at a size no larger than [THUMBNAIL_MAX_SIDE_PX] + * on its longest side, and read back. + * + * Reduced rather than read back whole: a hover card is a couple of hundred dp + * across, and keeping a window-sized bitmap per tab would cost megabytes for + * something that is never drawn at that size. + */ +@Suppress("TooGenericExceptionCaught") +private suspend fun reducedPicture( + source: GraphicsLayer, + into: GraphicsLayer, + density: Density, + layoutDirection: LayoutDirection, +): ImageBitmap? { + val size = source.size + if (size.width <= 0 || size.height <= 0) return null + val factor = (THUMBNAIL_MAX_SIDE_PX.toFloat() / max(size.width, size.height)).coerceAtMost(1f) + val target = + IntSize( + (size.width * factor).roundToInt().coerceAtLeast(1), + (size.height * factor).roundToInt().coerceAtLeast(1), + ) + // A picture is cosmetic: a readback that fails must leave the last one in + // place, never take the window with it. + return try { + into.record(density, layoutDirection, target) { + scale(factor, factor, Offset.Zero) { drawLayer(source) } + } + into.toImageBitmap() + } catch (error: Exception) { + thumbnailLogger.log(Level.FINE, "tab thumbnail readback failed", error) + null + } +} + +private val thumbnailLogger: Logger = Logger.getLogger("dev.nucleusframework.window.tao.tabthumbnail") + +/** How long a body is given to draw and settle before its picture is taken. */ +private val ThumbnailSettleMillis: Duration = THUMBNAIL_SETTLE_MILLIS.milliseconds + +private val HoverPreviewDelay: Duration = HOVER_PREVIEW_DELAY_MILLIS.milliseconds +private val HoverPreviewOffset: DpOffset = DpOffset(0.dp, 4.dp) +private val HoverCardMinWidth: Dp = 160.dp +private val HoverCardMaxWidth: Dp = 280.dp +private val HoverCardPadding: Dp = 10.dp +private val HoverCardGap: Dp = 6.dp +private val HoverCardCornerRadius: Dp = 8.dp +private val HoverCardPictureRadius: Dp = 4.dp +private val HoverCardBorderWidth: Dp = 1.dp +private const val HOVER_PREVIEW_DELAY_MILLIS = 650 +private const val HOVER_CARD_TITLE_SP = 12 +private const val HOVER_CARD_TITLE_LINES = 2 +private const val THUMBNAIL_SETTLE_MILLIS = 400 +private const val THUMBNAIL_MAX_SIDE_PX = 512 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStrip.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStrip.kt index 09670928a..3e3343313 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStrip.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStrip.kt @@ -1,15 +1,18 @@ package dev.nucleusframework.window.tao +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -18,6 +21,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -28,7 +33,6 @@ import androidx.compose.ui.composed import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draw.alpha -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType @@ -43,11 +47,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.tao.workspace.ScreenDrag import dev.nucleusframework.window.tao.workspace.positionInWindowPx import dev.nucleusframework.window.tao.workspace.publishHostGeometry import dev.nucleusframework.window.tao.workspace.rememberHostGeometry -import dev.nucleusframework.window.tao.workspace.screenDragHandle /** What tab-strip chrome gets to see: the workspace and the group this strip belongs to. */ public interface TabStripScope { @@ -79,6 +81,23 @@ internal class TabStripScopeImpl( * Colours come from [LocalTitleBarStyle], so the strip matches whatever * title-bar theme the app installed. * + * A tab dragged along its own strip stays in the strip's hands: it is drawn + * under the pointer, its neighbours slide aside as its edge crosses their + * centres, and on release it slides into the slot it was over before the + * order changes — the motion of a browser's tab strip. Taken out of the strip + * it becomes a ghost window, as a tab dragged to another window does, and the + * strip it is carried over opens a slot of its width where it would land, + * showing the same card ([dropGhost]), so the tab is seen taking its place + * before it is let go. + * + * @param reorderAnimation how a tab travels along the strip — pushed aside, + * or sliding home; `null` moves it at once. Only the drawing is animated: + * the strip's published geometry is the settled layout throughout, so a + * drop resolved mid-motion still lands where the strip says it will. + * @param hoverPreview the card shown under the tab the pointer rests on; + * `null`, the default, shows none. [TabHoverPreview.Default] is a browser's + * behaviour, and [TabHoverPreview] takes the card whole for an app that + * wants to draw its own. * @param trailing chrome placed right after the last tab — a new-tab button, * typically. It sits inside the strip, so the strip stays a single drop * target and a tab released over it is appended. @@ -86,37 +105,155 @@ internal class TabStripScopeImpl( @Composable public fun TabStripScope.TabStrip( modifier: Modifier = Modifier, + reorderAnimation: AnimationSpec? = TabReorderAnimation, + hoverPreview: TabHoverPreview? = null, trailing: @Composable TabStripScope.() -> Unit = {}, ) { val entries = tabs - val dragged = workspace.draggedTab - val preview = workspace.dropPreview?.takeIf { it.group === group } + val motion = rememberTabStripMotion(reorderAnimation) + // A tab still sliding home after being let go: the tabs themselves show + // where it lands, and a slot would say it twice. + val ghost = dropGhost?.takeIf { motion.animating == null } + // The slot shuts with a slide when the tab moves on — but not when the tab + // lands in it. The tab then takes the slot's place in the very frame the + // drag ends, so the card is seen becoming the tab rather than shutting + // beside it: the slots are keyed on a generation that turns over at the + // landing, which drops the open one from the composition at once. + val landing = remember(group) { TabLandingMemo() } + workspace.draggedTab?.let { dragged -> if (ghost != null) landing.expect(dragged, ghost.index) } + if (ghost == null) landing.settle(entries) + val closing = remember(group) { mutableStateListOf() } Row( modifier = modifier.fillMaxWidth().tabStripGeometry(workspace, group), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { entries.forEachIndexed { index, entry -> - // The gap the dragged tab would take, so the strip shows where the - // drop lands rather than only that it will land somewhere. - if (preview?.index == index) DropIndicator() - TabItem( - scope = this@TabStrip, - tab = entry, - selected = entry.id == group.selectedId, - // Dimmed while its ghost is being dragged: it is on its way out. - leaving = dragged === entry && workspace.dragGhost != null, - // An equal share of whatever the chrome leaves, capped at - // [TabMaxWidth] — so tabs shrink together as more open, the way - // a browser's do. Without the weight the strip would serve the - // first tabs their full width and leave the last ones zero-wide: - // present in the model, unclickable on screen. - modifier = Modifier.tabSlot(group, index).weight(1f, fill = false), - ) + // The slot a tab coming from *another* window would take. + key(landing.generation) { TabDropGhostSlot(ghost, index) } + // Keyed on the tab, not on its place in the strip: Compose + // otherwise identifies the items by position, so a reorder would + // hand the arriving tab the state of the one that left — its hover + // for a start — and no item would have moved for an animation to + // follow. + key(entry.id) { + TabStripItem( + scope = this@TabStrip, + entry = entry, + index = index, + motion = motion, + closing = closing, + slotModifier = Modifier.weight(1f, fill = false).fillMaxHeight(), + ) + } } - if (preview != null && preview.index >= entries.size) DropIndicator() + key(landing.generation) { TabDropGhostSlot(ghost, entries.size) } trailing() } + // Outside the Row: the card is a popup anchored to the tab's own slot, so + // it belongs to the strip rather than to any one tab, and nothing about it + // takes part in the strip's layout. + hoverPreview?.let { TabHoverPreviewPopup(it) } +} + +/** + * Which tab the strip's open slot stands for, and where — so the frame that + * shows the tab landed there can tell a landing from a drag that moved on. + * Plain fields: bookkeeping read in the composition that writes it, never a + * reason to recompose. + */ +private class TabLandingMemo { + private var entry: TabEntry? = null + private var index = -1 + + /** Turned over at every landing; the slots are keyed on it. */ + var generation = 0 + private set + + fun expect( + entry: TabEntry, + index: Int, + ) { + this.entry = entry + this.index = index + } + + /** The slot has closed: if the tab it stood for is now at its place, it landed — snap the slot away. */ + fun settle(entries: List) { + val expected = entry ?: return + if (entries.getOrNull(index) === expected) generation++ + entry = null + index = -1 + } +} + +/** + * The slot a tab dragged from another window would fill in this strip: the + * place it lands, the width it brings and its title — drawn with + * [TabDropGhostCard] where [TabStrip]'s own layout puts it, or by a strip + * written from scratch at [index] among its tabs (`tabs.size` is after the + * last one). + * + * `null` while nothing is dragged over this strip, and for a tab of this very + * strip in the strip's own hands: its neighbours moving aside already show + * where it lands. + */ +public val TabStripScope.dropGhost: TabDropGhost? + get() { + val preview = workspace.dropPreview?.takeIf { it.group === group } ?: return null + val dragged = workspace.draggedTab ?: return null + if (dragged.group === group && workspace.dragGhost == null) return null + return TabDropGhost(preview.index.coerceIn(0, tabs.size), workspace.draggedTabWidth(dragged), dragged.title) + } + +/** + * Where a tab dragged from another window would land in a strip, and what it + * looks like there — see [TabStripScope.dropGhost]. + * + * @property index the place among the strip's tabs; `tabs.size` is after the last. + * @property width the width the tab has in the strip it comes from. + * @property title the tab's title. + */ +public data class TabDropGhost( + val index: Int, + val width: Dp, + val title: String, +) + +/** + * The card a [TabDropGhost] is drawn as: [TabDropGhost.width] wide, the + * strip's height, the same card the tab travels under. A strip written from + * scratch composes it at [TabDropGhost.index] among its tabs. + */ +@Composable +public fun TabDropGhostCard( + ghost: TabDropGhost, + modifier: Modifier = Modifier, +) { + TabGhostCard(ghost.title, modifier.width(ghost.width).fillMaxHeight()) +} + +/** + * One of the strip's gaps — before the tab at [index], or after the last — + * opening to [ghost]'s width while [ghost] lands there and shutting when it + * moves on, so the tabs slide aside for it as they do for one of their own. + */ +@Composable +private fun TabDropGhostSlot( + ghost: TabDropGhost?, + index: Int, +) { + val shown = ghost?.takeIf { it.index == index } + // Kept through the exit, which still needs a width and a title to shut. + var last by remember { mutableStateOf(shown) } + if (shown != null) last = shown + AnimatedVisibility( + visible = shown != null, + enter = expandHorizontally(TabEnterAnimation, clip = false), + exit = shrinkHorizontally(TabExitAnimation, clip = false), + ) { + last?.let { TabDropGhostCard(it) } + } } /** @@ -205,9 +342,14 @@ private class TabTransferTarget( * Marks this element as the slot of the tab at [index] in [group], which is * what turns a pointer position into an insertion index. * + * It is also what publishes the tab under the pointer + * ([TabStripScope.hoveredTab]) and the rect a hover card is anchored to, so a + * strip that marks its slots gets [TabHoverPreviewPopup] for nothing. + * * [TabStrip] applies it already; a strip written from scratch must apply it to * every tab, in strip order. */ +@OptIn(ExperimentalComposeUiApi::class) public fun Modifier.tabSlot( group: TabWindowGroup, index: Int, @@ -220,75 +362,50 @@ public fun Modifier.tabSlot( // ones still placed, so a stale rect cannot shift an insertion index. group.slotsInWindowPx = slots.take(group.ids.size.coerceAtLeast(index + 1)) } - -/** - * Makes this element the grip that drags [tab] between windows. - * - * Dragging the only tab of a window moves that window along with the pointer; - * one of several is lifted out under a ghost. In both cases every strip in the - * workspace shows where the tab would be inserted - * ([TabWorkspace.dropPreview]), and releasing: - * - * - over a strip inserts the tab there, reordering it when that is its own - * strip; - * - anywhere else tears it into a window of its own under the pointer — or, - * for the only tab of a window, just leaves that window where it was - * dropped. - * - * A press without movement does nothing, so the close button and a plain - * click-to-select still work. The press is claimed, which keeps the title bar - * from starting the native window move instead — the window is moved by the - * workspace so the drop can be decided from the pointer position, at the cost - * of the OS's own snapping while a tab is dragged. - * - * On native **Wayland** the gesture rides the platform's drag-and-drop - * session instead, since the workspace can neither move a window nor hit-test - * a strip from the source: a card with the tab's title follows the pointer, - * the strip under it previews the insertion, and releasing there inserts the - * tab; releasing anywhere else tears one of several tabs into a window the - * compositor places, and leaves the only tab of a window where it is (that - * window moves by its title bar's compositor drag). - * - * No-op outside a Tao window. Drives [TabWorkspace.beginDrag]. - */ -public fun Modifier.tabDragHandle( - workspace: TabWorkspace, - tab: TabEntry, -): Modifier = - screenDragHandle( - key = tab, - isDragging = { workspace.draggedTab === tab }, - beginTransfer = { window -> workspace.beginTransferDrag(tab.id, window) }, - ) { window, pointerScreenPx -> - workspace.beginDrag(tab.id, TabDragOrigin.Strip(window), pointerScreenPx)?.asScreenDrag() - } - -private fun TabDragSession.asScreenDrag(): ScreenDrag = - object : ScreenDrag { - override fun update(pointerScreenPx: Offset) = this@asScreenDrag.update(pointerScreenPx) - - override fun end(pointerScreenPx: Offset) = this@asScreenDrag.end(pointerScreenPx) - - override fun cancel() = this@asScreenDrag.cancel() - } + // The id is resolved at event time, not captured: the slot at an index + // is whichever tab the strip has put there. + .onPointerEvent(PointerEventType.Enter) { group.noteHoverEnter(group.ids.getOrNull(index)) } + .onPointerEvent(PointerEventType.Exit) { group.noteHoverExit(group.ids.getOrNull(index)) } + .onPointerEvent(PointerEventType.Press) { group.noteHoverPress(group.ids.getOrNull(index)) } /** One tab: its title, a close button, and the whole thing a drag handle. */ @OptIn(ExperimentalComposeUiApi::class) @Composable -private fun TabItem( +@Suppress("LongParameterList") +internal fun TabItem( scope: TabStripScope, tab: TabEntry, selected: Boolean, leaving: Boolean, + held: Boolean, + /** `true` while a tab of this strip is in hand: the others stop reacting to the pointer. */ + hoverSuppressed: Boolean, modifier: Modifier, + onClose: () -> Unit, ) { val colors = LocalTitleBarStyle.current.colors var hovered by remember { mutableStateOf(false) } val shape = RoundedCornerShape(topStart = TabCornerRadius, topEnd = TabCornerRadius) + // A tab in hand is faded, and it fades rather than switches, so picking one + // up and putting it down again is one motion. Held inside its own strip it + // stays nearly solid — it is a card being carried, not a tab on its way out. + val targetAlpha = + when { + held -> TAB_HELD_ALPHA + leaving -> TAB_LEAVING_ALPHA + else -> 1f + } + val leavingAlpha by animateFloatAsState(targetAlpha, TabFadeAnimation) val background = when { + // Carried, it needs a body of its own: a tab whose background is + // the strip's would travel as a bare title and read as nothing. + held -> colors.content.copy(alpha = TAB_HELD_BACKGROUND_ALPHA) selected -> colors.content.copy(alpha = TAB_SELECTED_ALPHA) - hovered -> colors.content.copy(alpha = TAB_HOVER_ALPHA) + // A tab under the pointer while another is being carried over it is + // not being pointed at, it is being passed: highlighting it would + // light up every tab the carried one crosses. + hovered && !hoverSuppressed -> colors.content.copy(alpha = TAB_HOVER_ALPHA) else -> Color.Transparent } Row( @@ -296,9 +413,8 @@ private fun TabItem( modifier .widthIn(max = TabMaxWidth) .fillMaxHeight() - .alpha(if (leaving) TAB_LEAVING_ALPHA else 1f) + .alpha(leavingAlpha) .background(background, shape) - .tabDragHandle(scope.workspace, tab) .clickable { scope.workspace.select(tab.id) } .onPointerEvent(PointerEventType.Enter) { hovered = true } .onPointerEvent(PointerEventType.Exit) { hovered = false } @@ -317,7 +433,7 @@ private fun TabItem( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - TabCloseButton(colors.content) { scope.workspace.close(tab.id) } + TabCloseButton(colors.content, onClose) } } @@ -336,35 +452,19 @@ private fun TabCloseButton( } } -/** The gap a dropped tab would fill: where in the strip the drag would land. */ -@Composable -private fun DropIndicator() { - val accent = LocalTitleBarStyle.current.colors.content - Box( - Modifier - .width(DropIndicatorWidth) - .fillMaxHeight() - .padding(vertical = DropIndicatorInset) - .background(accent.copy(alpha = DROP_INDICATOR_ALPHA), RoundedCornerShape(DropIndicatorWidth / 2)), - ) -} - /** - * The translucent card a tab dragged out of its strip is previewed as, filling - * the ghost window. + * The card a tab is previewed as while it is dragged — following the pointer + * out of its strip, and drawn on the slot it would take in another: its title + * on the shared [DragPreviewSurface]. */ @Composable -internal fun TabGhostCard(title: String) { +internal fun TabGhostCard( + title: String, + modifier: Modifier = Modifier, +) { val accent = LocalTitleBarStyle.current.colors.content - val shape = RoundedCornerShape(TabCornerRadius) - Box( - modifier = - Modifier - .fillMaxSize() - .background(accent.copy(alpha = GHOST_FILL_ALPHA), shape) - .border(GhostBorderWidth, accent.copy(alpha = GHOST_BORDER_ALPHA), shape), - contentAlignment = Alignment.CenterStart, - ) { + Box(modifier = modifier, contentAlignment = Alignment.CenterStart) { + DragPreviewSurface(Modifier.matchParentSize()) BasicText( text = title, modifier = Modifier.padding(horizontal = TabHorizontalPadding), @@ -379,14 +479,19 @@ internal val TabMaxWidth: Dp = 220.dp private val TabHorizontalPadding: Dp = 8.dp private val TabCornerRadius: Dp = 8.dp private val TabCloseInset: Dp = 3.dp -private val DropIndicatorWidth: Dp = 3.dp -private val DropIndicatorInset: Dp = 4.dp -private val GhostBorderWidth: Dp = 1.dp private const val TAB_SELECTED_ALPHA = 0.16f private const val TAB_HOVER_ALPHA = 0.08f private const val TAB_LEAVING_ALPHA = 0.35f -private const val DROP_INDICATOR_ALPHA = 0.8f -private const val GHOST_FILL_ALPHA = 0.22f -private const val GHOST_BORDER_ALPHA = 0.55f + +/** A tab held under the pointer in its own strip: almost solid, and clearly in hand. */ +private const val TAB_HELD_ALPHA = 0.7f + +/** The body a carried tab is given, so it travels as a card rather than as a title. */ +private const val TAB_HELD_BACKGROUND_ALPHA = 0.16f private const val TAB_TITLE_SP = 12 private const val TAB_CLOSE_SP = 14 + +private const val TAB_REORDER_MILLIS = 180 +private const val TAB_ENTER_MILLIS = 200 +private const val TAB_EXIT_MILLIS = 200 +private const val TAB_FADE_MILLIS = 150 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripAnimation.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripAnimation.kt new file mode 100644 index 000000000..0e5c8d239 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripAnimation.kt @@ -0,0 +1,339 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.zIndex +import dev.nucleusframework.window.noWindowDrag +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * How a tab travels along its strip by default — pushed aside by the one in + * hand, or sliding into its new place on release: a soft spring, the motion of + * a browser's tab strip. + */ +public val TabReorderAnimation: AnimationSpec = spring(stiffness = Spring.StiffnessMediumLow) + +/** How a tab opens: its width grows into the strip. */ +internal val TabEnterAnimation: FiniteAnimationSpec = + tween(durationMillis = TAB_ENTER_MILLIS, easing = FastOutSlowInEasing) + +/** How a tab closes: its width shuts, taking the strip with it. */ +internal val TabExitAnimation: FiniteAnimationSpec = + tween(durationMillis = TAB_EXIT_MILLIS, easing = FastOutSlowInEasing) + +/** The fade that goes with a tab closing, and with one being picked up. */ +internal val TabFadeAnimation: FiniteAnimationSpec = tween(durationMillis = TAB_FADE_MILLIS) + +/** + * The motion of one strip's tabs while one of them is in hand, after the + * pattern of a reorderable row: every tab has a draw-time offset, the tab in + * hand is drawn at the pointer's travel since the grab, and a neighbour + * slides a whole tab aside the moment the carried tab's leading edge crosses + * its centre — back again when it uncrosses. On release the carried tab + * slides into the slot it was over, and only then does the order change, so + * nothing is ever seen jumping. + * + * Offsets are drawn through `graphicsLayer`, so none of this moves a layout: + * the slots the workspace resolves a drop against stay where the settled + * layout put them, and the neighbours' shifts read from those same slots. + */ +internal class TabStripMotion( + private val scope: CoroutineScope, +) { + private val offsets = HashMap>() + + /** Each tab's slot in window px, from its last placement. */ + private val slots = HashMap() + + /** How far the tab [id] is drawn from its slot right now; `0` for one at rest. */ + fun drawnOffsetOf(id: String): Float = offsets[id]?.value ?: 0f + + /** Where the tab [id]'s slot is, in window px, or `null` before its first placement. */ + fun slotOf(id: String): Rect? = slots[id] + + /** The tab in hand, or the one still sliding home after a release. */ + var animating: String? by mutableStateOf(null) + private set + + /** The tab under the pointer; `null` once it has been let go. */ + var held: String? by mutableStateOf(null) + private set + + var spec: AnimationSpec? = TabReorderAnimation + + fun offsetOf(id: String): Animatable = offsets.getOrPut(id) { Animatable(0f) } + + fun placed( + id: String, + slot: Rect, + ) { + slots[id] = slot + } + + /** + * The tab [id] has been carried [slidePx] from where it was grabbed. Its + * own offset snaps there — it is the pointer — and every other tab of + * [order] is pushed a slot aside or let back, by where the carried tab's + * edges now are against their centres. + */ + fun carry( + id: String, + order: List, + slidePx: Float, + ) { + held = id + animating = id + val own = slots[id] ?: return + scope.launch { offsetOf(id).snapTo(slidePx) } + val currentStart = own.left + slidePx + val currentEnd = own.right + slidePx + val neighbours = order.filter { it != id }.mapNotNull { other -> slots[other]?.let { other to it.center.x } } + for ((other, centre) in neighbours) { + val target = + when { + currentStart < own.left && centre in currentStart..own.left -> own.width + currentStart > own.left && centre in own.right..currentEnd -> -own.width + else -> 0f + } + moveTo(other, target) + } + } + + /** The pointer let go, but the carried tab keeps its offset: the settle slides it from there. */ + fun letHold() { + held = null + } + + /** + * The tab in hand has left the strip's hands — a ghost took it out of the + * window, or the drag was abandoned: everything slides back where it was. + */ + fun letGo(order: List) { + held = null + for (id in order) moveTo(id, 0f) + animating = null + } + + /** + * The tab [id], released, slides into the slot of rank [target] in + * [order]; suspends until it has arrived. The caller then changes the + * order and calls [rest], in that sequence, so the frame that shows the + * new order shows every tab at zero offset exactly where it already was. + */ + suspend fun settle( + id: String, + order: List, + target: Int, + velocityPxPerSecond: Float = 0f, + ) { + held = null + animating = id + val from = order.indexOf(id) + val own = slots[id] + val into = order.getOrNull(target)?.let(slots::get) + val destination = + if (own == null || into == null || from < 0) { + 0f + } else if (target > from) { + into.right - own.right + } else { + into.left - own.left + } + val animate = spec + if (animate == null) { + offsetOf(id).snapTo(destination) + } else { + offsetOf(id).animateTo(destination, animate, initialVelocity = velocityPxPerSecond) + } + } + + /** Every offset back to zero at once: the order has just changed under the tabs. */ + suspend fun rest() { + for (animatable in offsets.values) animatable.snapTo(0f) + animating = null + } + + private fun moveTo( + id: String, + target: Float, + ) { + val animatable = offsetOf(id) + if (animatable.targetValue == target) return + val animate = spec + scope.launch { + if (animate == null) animatable.snapTo(target) else animatable.animateTo(target, animate) + } + } +} + +@Composable +internal fun TabStripScope.rememberTabStripMotion(spec: AnimationSpec?): TabStripMotion { + val scope = rememberCoroutineScope() + val motion = remember(group) { workspace.motionFor(group, scope) } + motion.spec = spec + val workspace = workspace + // Where the app places its own windows the drag is the workspace's, and + // the pointer it publishes is what the strip animates from; the local + // gesture of a compositor-placed window drives the motion itself. + LaunchedEffect(motion, workspace, group) { + snapshotFlow { + val tab = workspace.draggedTab + val pointer = workspace.dragPointerScreenPx + val grab = workspace.dragGrabScreenPx + val inHand = + tab != null && + tab.group === group && + pointer != null && + grab != null && + workspace.dragGhost == null && + workspace.dropPreview?.group === group + if (inHand) Triple(tab!!.id, pointer!!.x - grab!!.x, workspace.tabsOf(group).map { it.id }) else null + }.collect { sample -> + if (sample != null) { + motion.carry(sample.first, sample.third, sample.second) + } else if (motion.held != null && workspace.pendingReorder == null) { + motion.letGo(workspace.tabsOf(group).map { it.id }) + } + } + } + // The release inside this strip: slide home, then reorder. + val settle = workspace.pendingReorder?.takeIf { it.group === group } + LaunchedEffect(settle) { + if (settle == null) return@LaunchedEffect + val order = workspace.tabsOf(group).map { it.id } + motion.settle(settle.tab.id, order, settle.index, settle.velocityPxPerSecond) + workspace.reorder(settle.tab.id, settle.index) + motion.rest() + if (workspace.pendingReorder === settle) workspace.pendingReorder = null + } + return motion +} + +/** + * One tab of the strip: its slot, which is the geometry a drop resolves + * against, and inside it the tab as it is drawn — carried, pushed aside, + * sliding home, opening or closing. + * + * @param slotModifier the share of the strip the caller gives this tab. + */ +@Suppress("LongParameterList") +@Composable +internal fun TabStripItem( + scope: TabStripScope, + entry: TabEntry, + index: Int, + motion: TabStripMotion, + closing: SnapshotStateList, + slotModifier: Modifier, +) { + val workspace = scope.workspace + val group = scope.group + val held = motion.held == entry.id + val coroutineScope = rememberCoroutineScope() + + // A tab the strip has not shown yet opens; one the close button took + // shuts, and only then leaves the workspace. + var visible by remember { mutableStateOf(!entry.isEntering) } + LaunchedEffect(entry) { + entry.isEntering = false + visible = true + } + if (entry.id in closing) visible = false + + AnimatedVisibility( + visible = visible, + // In hand or sliding home, it is drawn over its neighbours: a Row draws + // its children in order, so a tab carried past the ones after it would + // otherwise slide underneath them. + modifier = slotModifier.zIndex(if (motion.animating == entry.id) 1f else 0f), + // A tab opens and closes by width, so the strip never jumps. Unclipped: + // a tab in hand is drawn outside its own slot, and a clip would cut it + // at the slot's edges. + enter = expandHorizontally(TabEnterAnimation, clip = false), + exit = shrinkHorizontally(TabExitAnimation, clip = false) + fadeOut(TabFadeAnimation), + ) { + Box( + modifier = + Modifier + .widthIn(max = TabMaxWidth) + .fillMaxHeight() + // The slot is this box, and it is never animated: what the + // workspace resolves a drop against is the settled layout, + // whatever the drawing is doing. + .tabSlot(group, index) + .onPlaced { motion.placed(entry.id, it.boundsInWindow()) } + // The grip is the slot, not the card: the card is drawn + // translated under the pointer, and a gesture on a node + // that follows the pointer reads no movement at all — + // in its own coordinates the pointer never moves. + // Never the window's move: a tab is dragged by the pointer, + // and on a compositor-placed surface the title bar's move is + // a grab that swallows the whole gesture. The grip claims the + // press on Main, but the bar arms on Final for *any* + // unclaimed press — a press this gesture is not ready for + // (the one that lands while the previous is winding down) + // would take the window with it. + .noWindowDrag() + .tabStripGripFor(workspace, entry, motion), + ) { + val offset = motion.offsetOf(entry.id) + TabItem( + scope = scope, + tab = entry, + selected = entry.id == group.selectedId, + // On its way out of this window, which dims it right down; + // held inside the strip, which draws it as a card in hand. + leaving = entry === workspace.draggedTab && workspace.dragGhost != null, + held = held, + hoverSuppressed = motion.held != null, + // Drawn where the motion puts it — at draw time, so a layer + // translation moves no layout and recomposes nothing. + modifier = Modifier.fillMaxSize().graphicsLayer { translationX = offset.value }, + ) { + if (entry.id !in closing) { + closing += entry.id + coroutineScope.launch { + delay(TAB_EXIT_MILLIS.toLong()) + closing -= entry.id + workspace.close(entry.id) + } + } + } + } + } +} + +private const val TAB_ENTER_MILLIS = 200 +private const val TAB_EXIT_MILLIS = 200 +private const val TAB_FADE_MILLIS = 150 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripDrag.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripDrag.kt new file mode 100644 index 000000000..a9a39c41a --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripDrag.kt @@ -0,0 +1,259 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import dev.nucleusframework.window.tao.workspace.ScreenDrag +import dev.nucleusframework.window.tao.workspace.TransferDragGesture +import dev.nucleusframework.window.tao.workspace.screenDragHandle +import dev.nucleusframework.window.tao.workspace.transferDragHandle + +/** + * Makes this element the grip that drags [tab]: a reorder inside its own + * strip, and — where the platform allows it — a move to another window or a + * window of its own. + * + * While the tab is in hand the strip draws it under the pointer and its + * neighbours step aside; see [TabStrip], which applies this already. + * + * The gesture a window can carry depends on one thing, whether the app is the + * one placing its windows ([TaoWindow.canPlaceOnScreen]): + * + * - where it is, the drag is the workspace's ([TabWorkspace.beginDrag]) and + * speaks screen pixels: the strip animates the reorder from the pointer + * that drag publishes, another window's strip can be dropped on, and a + * release clear of every strip tears the tab off under a ghost; + * - where it is not — a native Wayland surface — the reorder is a *local* + * gesture ([tabStripLocalDragHandle]), driven by the pointer's travel + * inside the window and resolved against the strip's own slots, because + * that is the only thing a client is told. A release clear of the strip + * defers the drop to the window the compositor hands the pointer to next, + * which is how a merge into another window still resolves there. + * + * No-op outside a Tao window. + */ +public fun Modifier.tabDragHandle( + workspace: TabWorkspace, + tab: TabEntry, +): Modifier = + composed { + val group = tab.group ?: return@composed Modifier + val scope = rememberCoroutineScope() + val motion = remember(workspace, group) { workspace.motionFor(group, scope) } + tabStripGripFor(workspace, tab, motion) + } + +/** [tabDragHandle] with the strip's own motion in hand — see it for the two paths. */ +internal fun Modifier.tabStripGripFor( + workspace: TabWorkspace, + tab: TabEntry, + motion: TabStripMotion, +): Modifier = + composed { + val window = LocalTaoWindow.current ?: return@composed Modifier + if (window.canPlaceOnScreen) { + screenDragHandle( + key = tab, + isDragging = { workspace.draggedTab === tab }, + beginTransfer = { host -> workspace.beginTransferDrag(tab.id, host) }, + ) { host, pointerScreenPx -> + workspace.beginDrag(tab.id, TabDragOrigin.Strip(host), pointerScreenPx)?.asScreenDrag() + } + } else { + tabStripLocalDragHandle(workspace, tab, motion) + } + } + +/** + * The strip's own grip, for a window the app cannot place. + * + * Reordering is *local*: driven by the pointer's travel inside the window and + * resolved against the strip's own slots, so it needs no screen coordinate and + * no window to move. The moment the pointer leaves the strip the gesture is + * handed to the platform's drag-and-drop session + * ([Modifier.transferDragHandle]), and that is the only reason it can be: no + * other window of the app hears a thing about a pointer another window holds, + * so until that session exists no strip can show where a drop would land. With + * it, every window's strip gets the drag in its own coordinates and previews + * the drop, and the release resolves there. + */ +internal fun Modifier.tabStripLocalDragHandle( + workspace: TabWorkspace, + tab: TabEntry, + motion: TabStripMotion, +): Modifier = + composed { + val window = LocalTaoWindow.current ?: return@composed Modifier + var coordinates by remember { mutableStateOf(null) } + val gesture = + remember(workspace, tab, motion) { + TabStripTransferGesture(workspace, motion, tab) { coordinates } + } + Modifier + .pointerHoverIcon( + if (workspace.draggedTab === tab) TaoPointerIcons.Grabbing else TaoPointerIcons.Grab, + ).onGloballyPositioned { coordinates = it } + .transferDragHandle( + key = tab, + window = window, + begin = { workspace.beginTransferDrag(tab.id, window) }, + gesture = gesture, + ) + } + +/** + * The strip's half of the gesture: it reorders while the pointer is over the + * strip, and hands over the moment it leaves. + */ +private class TabStripTransferGesture( + private val workspace: TabWorkspace, + private val motion: TabStripMotion, + private val tab: TabEntry, + private val coordinates: () -> LayoutCoordinates?, +) : TransferDragGesture { + private var carry: TabStripCarry? = null + private var origin = 0f + + override fun onStart(pressPosition: Offset) { + origin = pressPosition.x + val group = tab.group ?: return + workspace.takeInStrip(tab.id) + carry = TabStripCarry(workspace, motion, tab) { workspace.tabsOf(group).map { it.id } } + carry?.travel(0f) + } + + override fun onDrag(position: Offset): Boolean { + val live = carry ?: return true + val inWindow = coordinates()?.takeIf { it.isAttached }?.localToWindow(position) + if (live.leftTheStrip(inWindow)) { + // The tab is leaving: the strip lets go of it, and the platform + // session carries it from here — the drag icon under the pointer, + // every window's strip previewing the drop. + live.abandon() + carry = null + return true + } + live.travel(position.x - origin, sampleVelocity = true) + return false + } + + override fun onEnd(released: Boolean) { + val live = carry ?: return + carry = null + if (released) live.release() else live.abandon() + } +} + +/** + * One in-strip reorder in flight: how far the tab has travelled, the motion it + * drives, and the speed it carries into the slide home. + */ +private class TabStripCarry( + private val workspace: TabWorkspace, + private val motion: TabStripMotion, + private val tab: TabEntry, + private val order: () -> List, +) { + private var live = true + private val velocity = CarryVelocity() + + fun travel( + slidePx: Float, + sampleVelocity: Boolean = false, + ) { + if (!live) return + if (sampleVelocity) velocity.sample(slidePx) + motion.carry(tab.id, order(), slidePx) + workspace.carryInStrip(tab.id, slidePx) + } + + /** + * Whether the pointer has left the strip's own rectangle — the only + * question this gesture can ask, since it is told nothing about the + * screen. `false` before the grip has been placed. + */ + fun leftTheStrip(pointerInWindowPx: Offset?): Boolean { + val group = tab.group ?: return false + val strip = workspace.stripGeometry(group)?.layoutBoundsInWindowPx ?: return false + val pointer = pointerInWindowPx ?: return false + return !strip.inflate(STRIP_SLACK_PX).contains(pointer) + } + + /** Let go inside the strip: it slides into the place the strip is showing. */ + fun release() { + if (!live) return + live = false + motion.letHold() + workspace.dropInStrip(tab.id, velocity.perSecond()) + } + + /** The gesture was abandoned: everything back to its slot. */ + fun abandon() { + if (!live) return + live = false + motion.letGo(order()) + workspace.cancelInStrip() + } + + private companion object { + /** A press right on the strip's edge should not read as leaving it. */ + const val STRIP_SLACK_PX = 2f + } +} + +/** + * How fast the tab is travelling along the strip, from the travels the gesture + * reports: what the slide home starts with, so a flick carries through. + * Smoothed, since one change can land a millisecond after the one before it. + */ +private class CarryVelocity { + private var smoothed = 0f + private var lastNanos = 0L + private var lastTravel = Float.NaN + + fun sample(travelPx: Float) { + val now = System.nanoTime() + val elapsed = now - lastNanos + val previous = lastTravel + lastNanos = now + lastTravel = travelPx + if (previous.isNaN() || elapsed !in 1..MAX_GAP_NANOS) { + smoothed = 0f + return + } + val instant = (travelPx - previous) / (elapsed / NANOS_PER_SECOND) + smoothed = smoothed * (1f - SMOOTHING) + instant * SMOOTHING + } + + fun perSecond(): Float = smoothed.coerceIn(-MAX_SPEED, MAX_SPEED) + + private companion object { + const val NANOS_PER_SECOND = 1_000_000_000f + + /** Longer than this between samples and the pointer was at rest, not travelling. */ + const val MAX_GAP_NANOS = 100_000_000L + + /** How much of the newest sample the estimate takes: enough to follow a flick, not a jitter. */ + const val SMOOTHING = 0.4f + + /** A flick harder than this is the pointer teleporting, not a throw. */ + const val MAX_SPEED = 6_000f + } +} + +private fun TabDragSession.asScreenDrag(): ScreenDrag = + object : ScreenDrag { + override fun update(pointerScreenPx: Offset) = this@asScreenDrag.update(pointerScreenPx) + + override fun end(pointerScreenPx: Offset) = this@asScreenDrag.end(pointerScreenPx) + + override fun cancel() = this@asScreenDrag.cancel() + } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWindows.kt index 30d93e47b..02ae19676 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWindows.kt @@ -116,6 +116,9 @@ public fun ApplicationScope.Tab( * * A group appears when a tab is torn off and disappears when its last tab * leaves, so windows follow the tabs without the app opening or closing any. + * The strip is the top of the window and the selected tab fills the rest; + * [windowBodyWrapper] is where an app puts chrome of its own between the two — + * `examples/reader-dock-demo` hangs a whole `DockLayout` of satellites there. * [onLastWindowClosed] fires when the final group goes, which is where an app * calls `exitApplication`. * @@ -130,6 +133,12 @@ public fun ApplicationScope.Tab( * @param windowContentWrapper composed around each window's chrome and * content, inside that window's scene — the hook framework layers use to * provide their per-window locals. Must invoke the lambda it is given. + * @param windowBodyWrapper composed *inside* each window, below the tab strip, + * around the selected tab's body: where chrome that belongs to the window + * rather than to a tab goes — a `DockLayout` and its satellites, an activity + * bar, a status bar. The strip stays at the very top of the window, and the + * wrapper is one call site for every window, so nothing a tab change does + * rebuilds it. Must invoke the lambda it is given. * @param onLastWindowClosed called every time the workspace goes from holding * groups to holding none — never for the empty workspace this composable * first sees, since the tabs are declared after it. @@ -141,6 +150,7 @@ public fun ApplicationScope.TabWindows( compositionLocalContext: CompositionLocalContext? = null, strip: @Composable TabStripScope.() -> Unit = { TabStrip() }, windowContentWrapper: @Composable TaoDecoratedWindowScope.(content: @Composable () -> Unit) -> Unit = { it() }, + windowBodyWrapper: @Composable TaoDecoratedWindowScope.(body: @Composable () -> Unit) -> Unit = { it() }, onLastWindowClosed: () -> Unit = {}, ) { val ghost = workspace.dragGhost @@ -151,10 +161,9 @@ public fun ApplicationScope.TabWindows( title = ghost.tab.title, compositionLocalContext = compositionLocalContext, ) { - TabGhostCard(ghost.tab.title) + TabGhostCard(ghost.tab.title, Modifier.fillMaxSize()) } } - val currentOnLastClosed = rememberUpdatedState(onLastWindowClosed) // The groups to compose, mirrored out of the workspace by an effect rather @@ -187,7 +196,14 @@ public fun ApplicationScope.TabWindows( for (group in groups) { key(group.id) { - TabWindow(workspace, group, compositionLocalContext, strip, windowContentWrapper) + TabWindow( + workspace, + group, + compositionLocalContext, + strip, + windowContentWrapper, + windowBodyWrapper, + ) } } } @@ -201,6 +217,7 @@ private fun ApplicationScope.TabWindow( compositionLocalContext: CompositionLocalContext?, strip: @Composable TabStripScope.() -> Unit, windowContentWrapper: @Composable TaoDecoratedWindowScope.(content: @Composable () -> Unit) -> Unit, + windowBodyWrapper: @Composable TaoDecoratedWindowScope.(body: @Composable () -> Unit) -> Unit, ) { val state = rememberWindowState( @@ -243,7 +260,11 @@ private fun ApplicationScope.TabWindow( }, ) { padding -> Box(Modifier.fillMaxSize().padding(padding)) { - TabBody(workspace, selected) + // The app's window-level chrome sits here, under the + // strip: one call site for every window, so a tab + // change neither rebuilds it nor moves the body's + // relocation keys. + windowScope.windowBodyWrapper { TabBody(workspace, selected) } } } } @@ -265,6 +286,11 @@ private fun ApplicationScope.TabWindow( * its `rememberSaveable` registry entries. The key is above the relocation * anchor, not below it, so the path from the anchor down to the content is * still identical in every window. + * + * A workspace that keeps pictures of its tabs for its hover cards + * ([TabWorkspace.captureThumbnails]) has the body wrapped in a recorder — + * above the anchor too, and the same wrapper in every window, so it changes + * nothing about what follows a tab across. */ @Suppress("FunctionNaming") @Composable @@ -275,7 +301,12 @@ private fun TabBody( if (tab == null) return key(tab.id) { val scope = remember(workspace, tab) { TabScopeImpl(workspace, tab) } - RelocatedContentHost(tab.stateSlot, scope, tab.content) + val body = @Composable { RelocatedContentHost(tab.stateSlot, scope, tab.content) } + if (workspace.captureThumbnails) { + TabThumbnailRecorder(tab) { body() } + } else { + body() + } } } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWorkspace.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWorkspace.kt index baa76043b..97e791d0b 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWorkspace.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWorkspace.kt @@ -9,6 +9,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -18,8 +21,8 @@ import dev.nucleusframework.window.tao.workspace.HostGeometryRegistry import dev.nucleusframework.window.tao.workspace.RelocatableSlot import dev.nucleusframework.window.tao.workspace.WindowGroup import dev.nucleusframework.window.tao.workspace.sanitizedOrNull -import dev.nucleusframework.window.tao.workspace.supportsScreenPlacement import dev.nucleusframework.window.tao.workspace.warnScreenPlacementUnsupported +import kotlinx.coroutines.CoroutineScope /** * One tab known to a [TabWorkspace]: its identity, title and body. @@ -44,8 +47,39 @@ public class TabEntry internal constructor( /** `true` while this tab is the selected one of its group. */ public val isSelected: Boolean get() = group?.selectedId == id + /** + * The last picture taken of this tab's body, for a hover card to draw + * ([TabHoverPreviewScope.thumbnail]). + * + * `null` unless the workspace was built with `captureThumbnails`, and + * `null` for a tab that has not been on screen yet: only the selected tab + * of a window is composed, so the picture is the one taken while this tab + * was that tab. [TabWorkspace.captureThumbnail] takes a fresh one of the + * tab currently shown. + */ + public var thumbnail: ImageBitmap? by mutableStateOf(null) + internal set + + /** + * Bumped to ask for a new [thumbnail]; the window showing the tab takes + * one and stores it. Starts at 0, which is the first capture. + */ + internal var thumbnailRequest: Int by mutableStateOf(0) + private set + + internal fun requestThumbnail() { + thumbnailRequest++ + } + internal var content: (@Composable TabScope.() -> Unit)? by mutableStateOf(null) + /** + * `true` until a strip has drawn this tab once: what tells the chrome to + * open it with an animation instead of having it appear at full width. + * Cleared by the first strip that shows it. + */ + internal var isEntering: Boolean = true + /** `rememberSaveable` values carried across a move between groups. */ internal val stateSlot: RelocatableSlot = RelocatableSlot() } @@ -94,6 +128,45 @@ public class TabWindowGroup internal constructor( /** Rect of each tab in [ids], in window coordinates (physical px), published by the strip. */ internal var slotsInWindowPx: List = emptyList() + /** + * The slot of the tab [id], in window coordinates (physical px), or `null` + * before the strip has placed it. What a hover card is anchored to. + */ + internal fun slotInWindowPx(id: String): Rect? { + val index = tabIds.indexOf(id).takeIf { it >= 0 } ?: return null + return slotsInWindowPx.getOrNull(index)?.takeUnless { it.isEmpty } + } + + /** + * The tab the pointer is over in this group's strip, published by + * `Modifier.tabSlot` — see [TabStripScope.hoveredTab]. + */ + internal var hoveredId: String? by mutableStateOf(null) + private set + + /** + * `true` from a press on the hovered tab until the pointer leaves it: a + * hover card must not sit under a tab being clicked, and must not come + * back until the pointer has been away, which is what a browser does. + */ + internal var hoverBlocked: Boolean by mutableStateOf(false) + private set + + internal fun noteHoverEnter(id: String?) { + hoveredId = id + hoverBlocked = false + } + + internal fun noteHoverExit(id: String?) { + if (hoveredId != id) return + hoveredId = null + hoverBlocked = false + } + + internal fun noteHoverPress(id: String?) { + if (hoveredId == id) hoverBlocked = true + } + /** * Bumped every time [position] / [size] are set by the workspace rather * than by the user. [TabWindows] pushes the new placement onto its window @@ -173,10 +246,18 @@ public data class TabLayoutSnapshot( * * @param defaultWindowSize the size a group's window gets when nothing else * determines it: the first group, and any group restored without a size. + * @param captureThumbnails whether a picture of the selected tab's body is + * kept for a hover card to draw ([TabEntry.thumbnail]). Off by default: it + * records the body into a layer of its own and reads it back, which is a + * cost a workspace should only pay when its chrome shows the pictures. A + * `NativeView` or a `TextureView` in the body draws through a native surface + * of its own rather than into the scene, so it is missing from the picture — + * a body built around one is better off without captures. */ @Suppress("TooManyFunctions") public class TabWorkspace( public val defaultWindowSize: DpSize = DefaultWindowSize, + public val captureThumbnails: Boolean = false, ) { private val windows = WindowGroup(followFocus = true) @@ -244,6 +325,24 @@ public class TabWorkspace( entry.group?.selectedId = tabId } + /** + * Takes a fresh picture of [tabId]'s body for its hover card + * ([TabEntry.thumbnail]). + * + * Only the selected tab of a window is composed, so this reaches a tab + * that is on screen right now; for any other it does nothing and the + * picture stays the one taken while it was shown. A no-op altogether + * unless the workspace was built with `captureThumbnails`. + * + * Call it when the tab's content has changed enough for its old picture to + * be misleading — nothing else refreshes it, since the workspace cannot + * know what a body draws. + */ + public fun captureThumbnail(tabId: String) { + if (!captureThumbnails) return + entryMap[tabId]?.requestThumbnail() + } + /** * Removes the tab [tabId] from the workspace: its group selects a * neighbour, and a group left empty is dropped along with its window. @@ -399,10 +498,151 @@ public class TabWorkspace( private val drags = DragController { draggedTab = null + dragPointerScreenPx = null + dragGrabScreenPx = null + dragVelocityPxPerSecond = 0f dropPreview = null dragGhost = null } + /** + * Where the pointer of the live tab drag is, in physical screen px, or + * `null` while none is dragging — what a strip needs to hold the dragged + * tab under the pointer. Absent on the drag-and-drop path (native + * Wayland), where the source is never told where the pointer is. + */ + internal var dragPointerScreenPx: Offset? by mutableStateOf(null) + + /** Where the pointer was when the live tab drag started, in physical screen px; `null` while none is dragging. */ + internal var dragGrabScreenPx: Offset? by mutableStateOf(null) + + /** + * How fast the pointer of the live drag is travelling along the strip, in + * px per second — what the strip hands the spring that slides a released + * tab home, so a flick carries and a slow move does not overshoot. + */ + internal var dragVelocityPxPerSecond: Float = 0f + + /** + * A tab released inside its own strip, waiting for that strip to slide it + * into its new place before the order changes: the strip animates, then + * applies [reorder] and clears this. Set by the drag session, which does + * not reorder itself on that path, so that the tab is never seen jumping + * from under the pointer to its slot. + */ + internal var pendingReorder: TabReorderSettle? by mutableStateOf(null) + + private val stripMotions = HashMap() + + /** + * Takes [entry] in hand for a drag: it becomes the dragged tab, and the + * selected tab of its group. + * + * Selecting here is what stops an accidental drag from swallowing a click. + * The grip claims the press before the tab's own click gesture does, so a + * click whose pointer drifts past the touch slop becomes a drag — and a + * drag that ends where it started leaves the strip exactly as it was, with + * the click lost and the tab having wobbled for nothing. A browser selects + * a tab on the press for this reason, which also means the tab being + * carried is always the one on screen. + */ + private fun holdForDrag(entry: TabEntry) { + draggedTab = entry + entry.group?.selectedId = entry.id + } + + /** + * Takes the tab [tabId] in hand for a reorder inside its own strip, with + * no coordinate space but the strip's own: this is the gesture that has to + * work where a client is told nothing about the screen (native Wayland), + * so it is driven by [carryInStrip] with the pointer's travel in window px + * and resolved by the same edge-crossing rule the strip animates with. + * + * `null` when the tab is not in a group. Ends with [dropInStrip] or + * [releaseDrag]; a drag that leaves the strip hands over to [beginDrag] or + * [beginTransferDrag] instead. + */ + internal fun takeInStrip(tabId: String): TabWindowGroup? { + val entry = entryMap[tabId] ?: return null + val group = entry.group ?: return null + transferDrag?.cancel() + releaseDrag(null) + holdForDrag(entry) + dropPreview = TabDropTarget(group, group.tabIds.indexOf(tabId)) + return group + } + + /** + * The tab in hand has travelled [slidePx] along its strip: publishes the + * place it would take, by the rule of [reorderTarget]. + */ + internal fun carryInStrip( + tabId: String, + slidePx: Float, + ) { + val entry = entryMap[tabId] ?: return + val group = entry.group ?: return + val index = reorderTarget(group, entry, slidePx) ?: group.tabIds.indexOf(tabId) + dropPreview = TabDropTarget(group, index) + } + + /** + * The tab in hand has been let go inside its strip: records the place for + * the strip to slide it into, at [velocityPxPerSecond], and clears the + * drag. The strip applies the reorder once the tab has arrived. + */ + internal fun dropInStrip( + tabId: String, + velocityPxPerSecond: Float, + ) { + val entry = entryMap[tabId] ?: return + val group = entry.group ?: return + val index = dropPreview?.takeIf { it.group === group }?.index ?: group.tabIds.indexOf(tabId) + draggedTab = null + dropPreview = null + pendingReorder = TabReorderSettle(entry, group, index, velocityPxPerSecond) + } + + /** + * Tears the tab [tabId] out of [window] into a window of its own, at the + * size a pointer drag would give it and wherever the compositor puts it: + * the release of the local strip gesture, on a window the app cannot place. + */ + internal fun tearOffWhereverTheCompositorPuts( + tabId: String, + window: TaoWindow, + ) { + val entry = entryMap[tabId] ?: return + if (entry.group?.tabIds?.size == 1) return + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val outer = window.outerBoundsPx() + val size = + outer?.let { tearOffSizePx(window, it, scale) } + ?: Size(defaultWindowSize.width.value * scale, defaultWindowSize.height.value * scale) + // A rect at the origin: the position is the compositor's and only the + // size survives — see TaoWindow.canPlaceOnScreen. + tearOff(tabId, Rect(Offset.Zero, size), scale) + } + + /** The tab in hand is put back where it was: no reorder, no feedback. */ + internal fun cancelInStrip() { + draggedTab = null + dropPreview = null + } + + /** + * The motion of [group]'s strip — which tab is in hand and how far every + * tab of the strip is drawn from its slot. Created by the strip on its + * first composition; readable from here so a test can assert the motion + * the same way the drawing does. + */ + internal fun motionOf(group: TabWindowGroup): TabStripMotion? = stripMotions[group.id] + + internal fun motionFor( + group: TabWindowGroup, + scope: CoroutineScope, + ): TabStripMotion = stripMotions.getOrPut(group.id) { TabStripMotion(scope) } + /** * The tab being dragged right now, or `null`. While it is set every strip * in the workspace shows where the tab can be dropped. @@ -459,34 +699,161 @@ public class TabWorkspace( screenPx: Offset, exclude: TabEntry? = null, excludeGroup: TabWindowGroup? = null, - ): TabDropTarget? = - stripHosts - .ordered(windows.membersByRecency) - .asSequence() - .filterNot { it.minimized() } - .mapNotNull { geometry -> - val strip = geometry.layoutScreenRectPx() ?: return@mapNotNull null - if (!strip.contains(screenPx)) return@mapNotNull null - val group = groupOf(geometry.host)?.takeIf { it !== excludeGroup } ?: return@mapNotNull null - val client = geometry.clientOriginPx() ?: return@mapNotNull null - TabDropTarget(group, insertionIndex(group, screenPx.x - client.x, exclude)) - }.firstOrNull() + ): TabDropTarget? = dropTargetAt(null, screenPx, exclude, excludeGroup) + + /** + * The strip the tab being dragged would land in, decided from **where the + * tab is** as well as from where the pointer is: the strip + * [draggedScreenRectPx] — the ghost card following the pointer — has + * reached counts as entered, so a tab whose top edge has come up into a + * strip is previewed there before the pointer itself arrives. That is what + * the user sees moving, and it is the rule the dock zones already follow. + * + * The pointer still wins where both answer: a strip it is actually in is + * the target, whatever the card overlaps. Otherwise the first strip the + * card has reached, by the same order as the pointer overload. A `null` + * rect is the pointer alone. + * + * [exclude] and [excludeGroup] are as in the pointer overload. + */ + public fun dropTargetAt( + draggedScreenRectPx: Rect?, + screenPx: Offset, + exclude: TabEntry? = null, + excludeGroup: TabWindowGroup? = null, + ): TabDropTarget? { + // The excluded group is dropped from the search rather than ending it: + // a single-tab window's own strip travels with the pointer and covers + // whatever it is being dropped on, so the search has to look past it. + val candidates = + stripHosts + .ordered(windows.membersByRecency) + .filterNot { it.minimized() } + .mapNotNull { geometry -> + val group = groupOf(geometry.host)?.takeIf { it !== excludeGroup } ?: return@mapNotNull null + geometry.layoutScreenRectPx()?.let { Triple(geometry, group, it) } + } + val hit = + candidates.firstOrNull { (_, _, strip) -> strip.contains(screenPx) } + ?: draggedScreenRectPx?.let { card -> + candidates.firstOrNull { (_, _, strip) -> !strip.intersect(card).isEmpty } + } + ?: return null + val (geometry, group, _) = hit + val client = geometry.clientOriginPx() ?: return null + val ownSlide = exclude?.takeIf { it.group === group }?.let { slideIn(group, it, screenPx) } + val index = + if (ownSlide != null) { + reorderTarget(group, exclude, ownSlide) ?: group.tabIds.indexOf(exclude.id) + } else { + insertionIndex(group, screenPx.x - client.x, exclude) + } + return TabDropTarget(group, index) + } + + /** + * How far the tab in hand has been carried along its own strip: the + * pointer's travel since the grab, in px — the same in screen and window + * space. `null` before a grab is on record. + */ + private fun slideIn( + group: TabWindowGroup, + entry: TabEntry, + pointerScreenPx: Offset, + ): Float? { + if (group.tabIds.indexOf(entry.id) < 0) return null + val grab = dragGrabScreenPx ?: return null + return pointerScreenPx.x - grab.x + } + + /** + * The place a tab carried [slidePx] along its own strip would take, or + * `null` for the one it has: the last neighbour whose centre its leading + * edge has crossed. Which end of the crossed run counts is the reading + * direction's business, read from the slots as in [insertionIndex]. + * + * This is the rule of the strip's own animation, so what the drop preview + * says and where the tab settles are one and the same. + */ + internal fun reorderTarget( + group: TabWindowGroup, + entry: TabEntry, + slidePx: Float, + ): Int? { + val index = group.tabIds.indexOf(entry.id).takeIf { it >= 0 } ?: return null + val slots = group.slotsInWindowPx + val own = slots.getOrNull(index)?.takeIf { !it.isEmpty } ?: return null + val currentStart = own.left + slidePx + val currentEnd = own.right + slidePx + val placed = slots.filter { !it.isEmpty } + val rightToLeft = placed.size >= 2 && placed.first().left > placed.last().left + val crossed: (Int) -> Boolean = + when { + currentStart < own.left -> { j -> + j != index && + slots + .getOrNull(j) + ?.center + ?.x + ?.let { it in currentStart.. own.left -> { j -> + j != index && + slots + .getOrNull(j) + ?.center + ?.x + ?.let { it in own.right.. return null + } + val indices = slots.indices.filter(crossed) + if (indices.isEmpty()) return null + // Moving towards low x: the farthest crossed neighbour is the first + // in strip order, unless the strip runs right to left, where it is the last. + val towardsLowX = currentStart < own.left + return if (towardsLowX == !rightToLeft) indices.first() else indices.last() + } + + /** + * The width [entry] has in the strip it is dragged from, in dp of that + * strip's window — what the slot it lands in elsewhere opens to. Its slot + * is still published while it is in flight (dimmed, or moving with its + * window); before the strip ever placed it, the widest a tab gets. + */ + internal fun draggedTabWidth(entry: TabEntry): Dp { + val group = entry.group + val slot = group?.slotsInWindowPx?.getOrNull(group.tabIds.indexOf(entry.id))?.takeIf { !it.isEmpty } + val scale = group?.window?.scaleFactor?.takeIf { it > 0f } ?: 1f + return slot?.let { (it.width / scale).dp } ?: TabMaxWidth + } /** * The index [xInWindowPx] falls at in [group]'s strip: the number of tabs - * whose midpoint is left of it, counting the dragged tab's own slot out so - * the index it would land at is the one it already has. + * whose midpoint the pointer has passed, counting the dragged tab's own + * slot out so the index it would land at is the one it already has. + * + * "Passed" is a question of reading direction, and the direction is read + * from the published slots themselves rather than from a layout direction + * the workspace has no business knowing: a right-to-left strip puts its + * first tab at the *right*, so its slots run from high x to low, and the + * pointer passes a midpoint by going left. Without that, every drop on a + * Hebrew or Arabic strip resolves mirrored. */ internal fun insertionIndex( group: TabWindowGroup, xInWindowPx: Float, exclude: TabEntry?, - ): Int = - group.slotsInWindowPx - .zip(group.tabIds) + ): Int { + val slots = group.slotsInWindowPx.zip(group.tabIds) + val placed = slots.filterNot { (slot, _) -> slot.isEmpty } + val rightToLeft = placed.size >= 2 && placed.first().first.left > placed.last().first.left + return slots .filterNot { (_, id) -> id == exclude?.id } - .takeWhile { (slot, _) -> xInWindowPx >= slot.center.x } - .size + .takeWhile { (slot, _) -> + if (rightToLeft) xInWindowPx <= slot.center.x else xInWindowPx >= slot.center.x + }.size + } /** * Starts dragging the tab [tabId] from [origin], with the pointer at @@ -512,14 +879,16 @@ public class TabWorkspace( when (origin) { is TabDragOrigin.Strip -> origin.window } - if (!from.supportsScreenPlacement) { + if (!from.canPlaceOnScreen) { from.warnScreenPlacementUnsupported("TabWorkspace.beginDrag") return null } transferDrag?.cancel() val session = createTabDragSession(entry, origin, start) ?: return null drags.begin(session) - draggedTab = entry + holdForDrag(entry) + dragGrabScreenPx = start + dragPointerScreenPx = start return session } @@ -550,7 +919,7 @@ public class TabWorkspace( releaseDrag(null) val session = createTabTransferDrag(entry, group, window) transferDrag = session - draggedTab = entry + holdForDrag(entry) return session } @@ -685,7 +1054,17 @@ public class TabWorkspace( } } +/** A tab released inside its own strip, and the place it is sliding to — see [TabWorkspace.pendingReorder]. */ +internal class TabReorderSettle( + val tab: TabEntry, + val group: TabWindowGroup, + val index: Int, + /** The pointer's speed along the strip at the release; the slide home starts with it. */ + val velocityPxPerSecond: Float, +) + /** Where a tab drag would insert the tab: at [index] in [group]'s strip. */ + public data class TabDropTarget( val group: TabWindowGroup, val index: Int, @@ -744,5 +1123,7 @@ public interface TabDragSession { /** Remembers a [TabWorkspace] for the lifetime of the calling composition. */ @Composable -public fun rememberTabWorkspace(defaultWindowSize: DpSize = TabWorkspace.DefaultWindowSize): TabWorkspace = - remember { TabWorkspace(defaultWindowSize) } +public fun rememberTabWorkspace( + defaultWindowSize: DpSize = TabWorkspace.DefaultWindowSize, + captureThumbnails: Boolean = false, +): TabWorkspace = remember { TabWorkspace(defaultWindowSize, captureThumbnails) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt index 70cfbc4fe..cefc7c3e6 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt @@ -835,6 +835,28 @@ public class TaoWindow internal constructor( public val isNativeWaylandSurface: Boolean get() = linuxSurfaceKind() == WAYLAND_HANDLE_KIND + /** + * `true` when this window's position on screen is the client's to know and + * to set — every platform but a native Wayland surface, where xdg-shell + * gives the compositor full authority over toplevel placement: GDK reports + * every toplevel at `(0, 0)` there and ignores a move. + * + * This is the capability to branch on, rather than the platform + * ([isNativeWaylandSurface]): [outerBoundsPx] still carries a valid *size* + * where this is `false`, so a caller that needs only the size keeps using + * it, while anything that would treat its origin as a screen coordinate, + * move the window, or place another window against it must check here + * first. + * + * What it changes for an app: where it is `false`, moving the window is + * the compositor's gesture ([Modifier.windowDragArea]) and a cross-window + * drag rides the platform's drag-and-drop session instead of the window + * itself, so chrome that carries both has to give each one its own area — + * see [Satellite]'s `floatingCaption` and [SatelliteScope.isCompositorPlaced]. + */ + public val canPlaceOnScreen: Boolean + get() = !isNativeWaylandSurface + /** * `nativeLinuxHandles` slot 0, cached from the first call that returns a * realized surface: `0` while the native window does not exist yet (not diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/CrossWindowDrag.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/CrossWindowDrag.kt index 9c84fb3fc..92493e675 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/CrossWindowDrag.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/CrossWindowDrag.kt @@ -108,7 +108,7 @@ internal interface ScreenDrag { * window, which is what lets a drag leave one window and land on another. * * No-op outside a Tao window. On a window without client-side screen - * placement ([supportsScreenPlacement] — native Wayland) the gesture is a + * placement ([canPlaceOnScreen] — native Wayland) the gesture is a * [TransferDrag] instead, asked of [beginTransfer]: the platform's DnD session * carries it and the window the pointer is over resolves the drop, since no * window can be moved or hit-tested from here. See [transferDragHandle]. @@ -123,11 +123,11 @@ internal fun Modifier.screenDragHandle( ): Modifier = composed { val window = LocalTaoWindow.current ?: return@composed Modifier - if (!window.supportsScreenPlacement) { + if (!window.canPlaceOnScreen) { val currentBeginTransfer by rememberUpdatedState(beginTransfer) return@composed Modifier .pointerHoverIcon(if (isDragging()) draggingIcon else idleIcon) - .transferDragHandle(key, window) { currentBeginTransfer(window) } + .transferDragHandle(key, window, begin = { currentBeginTransfer(window) }) } val containerSize = LocalWindowInfo.current.containerSize var coordinates by remember { mutableStateOf(null) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/DragGhostWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/DragGhostWindow.kt index a9c9128ec..952f30dec 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/DragGhostWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/DragGhostWindow.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.window.tao.ApplicationScope import dev.nucleusframework.window.tao.DecoratedWindow +import dev.nucleusframework.window.tao.TaoWindow /** * A borderless, click-through, always-on-top window covering [screenRectPx] @@ -21,12 +22,18 @@ import dev.nucleusframework.window.tao.DecoratedWindow * never takes the pointer, so the drag gesture keeps running in the window * underneath. * - * @param screenRectPx outer frame of the ghost, physical screen pixels. + * @param screenRectPx outer frame of the ghost, physical pixels — on screen, + * or relative to [popupFor] when it is given, which is the space a popup + * overlay is positioned in on a compositor-placed surface. * @param scaleFactor physical pixels per dp of the window the rect came from. * The application scope this is composed in belongs to no window, so its * density is always 1 and cannot be used to convert. * @param title the window title (invisible, but what a screen reader announces). * @param compositionLocalContext parent locals bridged into the ghost's scene. + * @param popupFor the window this ghost overlays, on Linux: a popup of it + * rather than a toplevel of its own — a `wl_subsurface` on native Wayland, + * the only window kind a client may position there, so the ghost can follow + * the pointer at all. `null` is a plain window, placed on screen. * @param content what the ghost shows; fills the window. */ @Suppress("FunctionNaming") @@ -36,6 +43,7 @@ internal fun ApplicationScope.DragGhostWindow( scaleFactor: Float, title: String, compositionLocalContext: CompositionLocalContext?, + popupFor: TaoWindow? = null, content: @Composable () -> Unit, ) { val scale = scaleFactor.takeIf { it > 0f } ?: 1f @@ -60,6 +68,7 @@ internal fun ApplicationScope.DragGhostWindow( focusable = false, clickThrough = true, alwaysOnTop = true, + popupFor = popupFor, compositionLocalContext = compositionLocalContext, ) { content() diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/HostGeometry.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/HostGeometry.kt index 9a3e98496..4cae51523 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/HostGeometry.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/HostGeometry.kt @@ -9,7 +9,9 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.IntSize +import dev.nucleusframework.window.tao.DockSide import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.edgeStripPx /** * What a drop target inside a window publishes about itself: the window, the @@ -32,23 +34,86 @@ internal class HostGeometry( /** The host's content size when [layoutBoundsInWindowPx] was captured. */ var containerSizePx: IntSize = IntSize.Zero + /** + * The drop zones the target offers right now, in the host window + * (physical px) — exactly the rectangles it draws while a drag is in + * flight, so what a drag is hit-tested against is what the user sees. + * Empty while nothing is being dragged, or for a target that publishes + * none; the hit test then falls back to the edges of + * [layoutBoundsInWindowPx]. + */ + var zoneBoundsInWindowPx: Map = emptyMap() + /** Physical pixels per dp on the host, `1` while the window has none yet. */ fun scaleOrOne(): Float = scaleFactor().takeIf { it > 0f } ?: 1f /** * Screen position of the host's content origin, `null` before the first * layout, while unmapped, or on a host whose screen position is not - * knowable ([supportsScreenPlacement] — native Wayland), where the origin + * knowable ([canPlaceOnScreen] — native Wayland), where the origin * GDK reports would place every window at the top-left of the screen. */ fun clientOriginPx(): Offset? { - if (containerSizePx == IntSize.Zero || !host.supportsScreenPlacement) return null + if (containerSizePx == IntSize.Zero || !host.canPlaceOnScreen) return null val outer = outerBoundsPx() ?: return null return clientOriginPx(outer, containerSizePx) } /** The target's rect on screen (physical px), `null` while [clientOriginPx] is. */ fun layoutScreenRectPx(): Rect? = clientOriginPx()?.let { layoutBoundsInWindowPx.translate(it) } + + /** + * The drop zones on screen (physical px): the published + * [zoneBoundsInWindowPx], else a strip of [zoneWidthPx] inside each edge + * of the layout — the same four zones the pointer hit test uses. `null` + * while [clientOriginPx] is. + */ + fun zoneScreenRectsPx(zoneWidthPx: Float): Map? { + val origin = clientOriginPx() ?: return null + if (zoneBoundsInWindowPx.isNotEmpty()) { + return zoneBoundsInWindowPx.mapValues { (_, zone) -> zone.translate(origin) } + } + val rect = layoutBoundsInWindowPx.translate(origin) + return DockSide.entries.associateWith { side -> DockDropZone(edgeStripPx(rect, side, zoneWidthPx)) } + } +} + +/** + * What one side of a drop target offers a drag, in whichever px space the + * holder says: the [strip] a satellite enters the side by, and — when panels + * are already docked there — one [slots] rect per rank the dropped panel can + * take among them, in rank order, covering the stack and the strip between + * them. Empty [slots] mean the side has no panel to order against. + */ +internal data class DockDropZone( + val strip: Rect, + val slots: List = emptyList(), +) { + fun translate(offset: Offset): DockDropZone = + DockDropZone(strip.translate(offset), slots.map { it.translate(offset) }) + + /** Whether [point] is on the strip or on one of the slots. */ + fun contains(point: Offset): Boolean = strip.contains(point) || slots.any { it.contains(point) } + + /** + * The rank [point] aims at: the slot it is in, else the nearest one, so a + * pointer past either end of the stack means its first or last rank. + * `null` without slots: nothing to order against. An empty slot is a rank + * that is not on offer (it would displace a pinned panel) and is skipped. + */ + fun slotAt(point: Offset): Int? = + slots.indices + .filter { !slots[it].isEmpty } + .minByOrNull { distanceSquaredPx(slots[it], point) } + + private fun distanceSquaredPx( + rect: Rect, + point: Offset, + ): Float { + val dx = maxOf(rect.left - point.x, 0f, point.x - rect.right) + val dy = maxOf(rect.top - point.y, 0f, point.y - rect.bottom) + return dx * dx + dy * dy + } } /** diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/ScreenPlacement.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/ScreenPlacement.kt index 8f9db41a8..4d30cdd4c 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/ScreenPlacement.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/ScreenPlacement.kt @@ -4,23 +4,6 @@ import dev.nucleusframework.window.tao.TaoWindow import java.util.concurrent.ConcurrentHashMap import java.util.logging.Logger -/** - * Whether this window's screen position can be read and set by the client — - * the two primitives every cross-window gesture is built on (a drag resolved - * in screen pixels, a drop hit-tested against another window, a satellite - * following its owner). - * - * `false` on a native Wayland surface: xdg-shell gives the compositor full - * authority over toplevel placement, so GDK reports every toplevel at `(0, 0)` - * and ignores `gtk_window_move`. [TaoWindow.outerBoundsPx] still carries a - * valid *size* there, which is why callers that only need one keep using it; - * anything that would treat its origin as a screen coordinate must check this - * first. X11, XWayland (`NUCLEUS_TAO_LINUX_RENDERER=x11`), Windows and macOS - * all place. - */ -internal val TaoWindow.supportsScreenPlacement: Boolean - get() = !isNativeWaylandSurface - private val warnedFeatures = ConcurrentHashMap.newKeySet() /** Same JUL logger `TaoWindow` reports its other Wayland gaps on. */ @@ -29,7 +12,7 @@ private val waylandLogger: Logger = Logger.getLogger("dev.nucleusframework.windo /** * Logs once per process and per [feature] that the feature is unavailable on * this window because it has no client-side screen placement. A no-op where - * [supportsScreenPlacement] holds. + * [canPlaceOnScreen] holds. * * Per process rather than per window: the windows these features live in — * floating satellites, torn-off tab windows — are created and destroyed with @@ -37,7 +20,7 @@ private val waylandLogger: Logger = Logger.getLogger("dev.nucleusframework.windo * gesture. */ internal fun TaoWindow.warnScreenPlacementUnsupported(feature: String) { - if (supportsScreenPlacement || !warnedFeatures.add(feature)) return + if (canPlaceOnScreen || !warnedFeatures.add(feature)) return waylandLogger.warning( "$feature needs client-side screen placement, which native Wayland (xdg-shell) does not offer: " + "a client can neither read its windows' screen position nor move them. The built-in grips " + diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/TransferDrag.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/TransferDrag.kt index 1b86ed49e..635ef6370 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/TransferDrag.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/TransferDrag.kt @@ -3,6 +3,7 @@ package dev.nucleusframework.window.tao.workspace import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.gestures.drag import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi @@ -52,7 +53,7 @@ import kotlin.math.roundToInt /** * A cross-window drag carried by the platform's drag-and-drop session — the * path taken where the client cannot read or set window positions (native - * Wayland, see [supportsScreenPlacement]). + * Wayland, see [canPlaceOnScreen]). * * The roles are inverted with respect to [ScreenDrag]: the *source* learns * nothing about where the pointer is, and the *target* window — the one the @@ -114,13 +115,41 @@ internal fun Modifier.transferDragHandle( key: Any?, window: TaoWindow, begin: () -> TransferDrag?, + gesture: TransferDragGesture = TransferDragGesture.Immediate, ): Modifier { val accent = LocalTitleBarStyle.current.colors.content val measurer = rememberTextMeasurer() val grab = remember { GrabCoordinates() } return this .onGloballyPositioned { grab.coordinates = it } - .then(TransferDragElement(key, window, grab, begin, accent, measurer)) + .then(TransferDragElement(key, window, grab, begin, accent, measurer, gesture)) +} + +/** + * What a grip does with the gesture before the platform's drag-and-drop + * session takes it — the hook a tab strip uses to reorder locally first. + * + * [Immediate] hands over as soon as the touch slop is passed, which is what a + * palette wants. Anything else keeps the pointer for as long as [onDrag] + * answers `false`: every sample is the caller's, and the session starts on the + * first `true`. + * + * Starting it late is legal and is the only way a client that cannot place its + * windows can show a drop where it is aimed: until the platform session + * exists, no other window of the app hears anything about the pointer. + */ +internal interface TransferDragGesture { + /** The gesture has passed the slop, pressed at [pressPosition] in the grip. */ + fun onStart(pressPosition: Offset) = Unit + + /** A sample at [position] in the grip; `true` hands the gesture to the platform session. */ + fun onDrag(position: Offset): Boolean = true + + /** The gesture ended in the caller's hands; [released] tells a release from an abandon. */ + fun onEnd(released: Boolean) = Unit + + /** Hands over at once: every grip with nothing of its own to do. */ + object Immediate : TransferDragGesture } /** @@ -144,8 +173,9 @@ private data class TransferDragElement( val begin: () -> TransferDrag?, val accent: Color, val measurer: TextMeasurer, + val gesture: TransferDragGesture, ) : ModifierNodeElement() { - override fun create(): TransferDragNode = TransferDragNode(window, grab, begin, accent, measurer) + override fun create(): TransferDragNode = TransferDragNode(window, grab, begin, accent, measurer, gesture) override fun update(node: TransferDragNode) { node.window = window @@ -153,6 +183,7 @@ private data class TransferDragElement( node.begin = begin node.accent = accent node.measurer = measurer + node.gesture = gesture } override fun InspectorInfo.inspectableProperties() { @@ -168,6 +199,7 @@ private class TransferDragNode( var begin: () -> TransferDrag?, var accent: Color, var measurer: TextMeasurer, + var gesture: TransferDragGesture, ) : DelegatingNode() { private val source = delegate( @@ -193,20 +225,42 @@ private class TransferDragNode( TransferGhostSource.None -> null } + /** + * Hands the gesture to the platform, if [gesture] says so: from the + * *press* position, since Compose only starts a transfer for a point + * inside the source node — and by then the pointer is long gone from it. + */ + private fun handOver( + pressPosition: Offset, + currentPosition: Offset, + ): Boolean { + if (!gesture.onDrag(currentPosition)) return false + if (!source.isRequestDragAndDropTransferRequired) return false + source.requestDragAndDropTransfer(pressPosition) + return true + } + init { delegate( SuspendingPointerInputModifierNode { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) down.consume() - awaitTouchSlopOrCancellation(down.id) { change, _ -> change.consume() } - ?: return@awaitEachGesture - // The press position, not the post-slop one: Compose only - // starts a transfer for a point inside the source node, and - // a grip is narrower than the slop. - if (source.isRequestDragAndDropTransferRequired) { - source.requestDragAndDropTransfer(down.position) - } + val start = + awaitTouchSlopOrCancellation(down.id) { change, _ -> change.consume() } + ?: return@awaitEachGesture + gesture.onStart(down.position) + var handedOver = handOver(down.position, start.position) + if (handedOver) return@awaitEachGesture + // The caller's gesture until it says otherwise: it keeps + // every sample, and the platform session starts on the + // first one it hands over. + val released = + drag(start.id) { change -> + change.consume() + if (!handedOver) handedOver = handOver(down.position, change.position) + } + if (!handedOver) gesture.onEnd(released) } }, ) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/DockLandingRectTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/DockLandingRectTest.kt new file mode 100644 index 000000000..077136ae3 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/DockLandingRectTest.kt @@ -0,0 +1,477 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.DockDropZone +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Where a drop preview is drawn ([DockLayoutState.landingRectPx]): along the + * side's band, not the whole layout; inside the layers already docked on a + * layered side; on the stack itself when the panel joins a split stack. + * + * The geometry is the reader layout — right side first and layered, bottom + * inside it — laid out at 1000 × 600 px, offset in the window by (20, 40). + */ +class DockLandingRectTest { + private val host = TaoWindow(handle = 1L) + private val workspace = SatelliteWorkspace().apply { join(host) } + private val state = + DockLayoutState(workspace).apply { + layeredSides = setOf(DockSide.Right) + layoutBoundsInWindowPx = Rect(20f, 40f, 1020f, 640f) + // The right band is the whole layout; the bottom band stops at the right stack. + bandBoundsInWindowPx[DockSide.Right] = Rect(20f, 40f, 1020f, 640f) + bandBoundsInWindowPx[DockSide.Bottom] = Rect(20f, 40f, 720f, 640f) + bandBoundsInWindowPx[DockSide.Left] = Rect(20f, 40f, 720f, 440f) + bandBoundsInWindowPx[DockSide.Top] = Rect(220f, 40f, 720f, 440f) + } + + private fun docked( + id: String, + side: DockSide, + order: Int, + boundsInWindowPx: Rect, + ): SatelliteEntry { + val entry = + workspace.register( + id, + id, + SatellitePlacement.Docked(side, order, extent = 100.dp), + initiallyOpen = true, + ) + entry.dockedBoundsInWindowPx = boundsInWindowPx + return entry + } + + @Test + fun `a bottom preview spans the bottom band, not the layout`() { + state.docked = emptyList() + assertEquals(Rect(0f, 540f, 700f, 600f), state.landingRectPx(DockSide.Bottom, 60f, joinsStack = true)) + } + + @Test + fun `a layered side previews a new innermost layer`() { + state.docked = + listOf( + docked("tree", DockSide.Right, 0, Rect(920f, 40f, 1020f, 640f)), + docked("toc", DockSide.Right, 1, Rect(820f, 40f, 920f, 640f)), + ) + assertEquals(Rect(740f, 0f, 800f, 600f), state.landingRectPx(DockSide.Right, 60f, joinsStack = true)) + } + + @Test + fun `a split side with a stack previews the stack the panel joins`() { + state.docked = listOf(docked("targum", DockSide.Left, 0, Rect(20f, 40f, 220f, 440f))) + assertEquals(Rect(0f, 0f, 200f, 400f), state.landingRectPx(DockSide.Left, 60f, joinsStack = true)) + // The idle outline stays a strip at the edge of the band. + assertEquals(Rect(0f, 0f, 60f, 400f), state.landingRectPx(DockSide.Left, 60f, joinsStack = false)) + } + + @Test + fun `an empty side previews a strip at the edge of its band`() { + state.docked = emptyList() + assertEquals(Rect(200f, 0f, 700f, 60f), state.landingRectPx(DockSide.Top, 60f, joinsStack = true)) + assertEquals(Rect(0f, 0f, 60f, 400f), state.landingRectPx(DockSide.Left, 60f, joinsStack = true)) + } + + @Test + fun `the side the dragged panel frees is counted as already gone`() { + // The reader shape: the bottom band stops at the layered right stack, + // and the left band stops above the bottom panel. + val comments = docked("comments", DockSide.Bottom, 0, Rect(20f, 440f, 720f, 640f)) + state.docked = listOf(comments) + + // Previewing the left side while dragging the *only* bottom panel: the + // bottom frees up, so the drop will run the full height of the band. + assertEquals( + Rect(0f, 0f, 60f, 600f), + state.landingRectPx(DockSide.Left, 60f, joinsStack = true, dragged = comments), + ) + // Without the drag it is the band as measured, above the panel. + assertEquals(Rect(0f, 0f, 60f, 400f), state.landingRectPx(DockSide.Left, 60f, joinsStack = true)) + } + + @Test + fun `a side the dragged panel shares with another is not freed`() { + val comments = docked("comments", DockSide.Bottom, 0, Rect(20f, 440f, 380f, 640f)) + val sources = docked("sources", DockSide.Bottom, 1, Rect(380f, 440f, 720f, 640f)) + state.docked = listOf(comments, sources) + + assertEquals( + Rect(0f, 0f, 60f, 400f), + state.landingRectPx(DockSide.Left, 60f, joinsStack = true, dragged = comments), + "the bottom side keeps its extent, so the left band is unchanged", + ) + } + + @Test + fun `without a measured band the layout itself is the band`() { + val bare = DockLayoutState(workspace).apply { layoutBoundsInWindowPx = Rect(0f, 0f, 400f, 300f) } + assertEquals(Rect(340f, 0f, 400f, 300f), bare.landingRectPx(DockSide.Right, 60f, joinsStack = true)) + } +} + +/** + * Which sides a drag is offered ([hintedSides]): all four, minus the one the + * dragged panel already occupies in the very window being hinted. + */ +class DockZoneHintSidesTest { + private val host = TaoWindow(handle = 1L) + private val other = TaoWindow(handle = 2L) + private val workspace = SatelliteWorkspace().apply { join(host) } + + private val floating = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + @Test + fun `a floating satellite is offered every side`() { + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true) + assertEquals(DockSide.entries, hintedSides(entry, host, workspace.satellites)) + } + + @Test + fun `a docked panel is not offered the side it is alone on`() { + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true) + workspace.dock("tools", DockSide.Bottom, host = host) + assertEquals( + listOf(DockSide.Left, DockSide.Right, DockSide.Top), + hintedSides(entry, host, workspace.satellites), + ) + } + + @Test + fun `a docked panel with a neighbour is offered its own side, to be ranked among them`() { + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true) + val other = workspace.register("colors", "Colors", floating, initiallyOpen = true) + other.content = {} + workspace.dock("tools", DockSide.Bottom, host = host) + workspace.dock("colors", DockSide.Bottom, host = host) + assertEquals(DockSide.entries, hintedSides(entry, host, workspace.satellites)) + // A closed neighbour is not shown, so there is nothing to rank against. + workspace.close("colors") + assertEquals( + listOf(DockSide.Left, DockSide.Right, DockSide.Top), + hintedSides(entry, host, workspace.satellites), + ) + } + + @Test + fun `another window offers the side too, since dropping there is a move`() { + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true) + workspace.join(other) + workspace.dock("tools", DockSide.Bottom, host = host) + assertEquals(DockSide.entries, hintedSides(entry, other, workspace.satellites)) + } +} + +/** + * The ranks a drop can take among the panels of a side + * ([DockLayoutState.dropSlotsPx]) and the space drawn for one + * ([DockLayoutState.dropRectPx]), on the reader layout of + * [DockLandingRectTest]: layered right side, split bottom, layout px. + */ +class DockDropSlotsTest { + private val host = TaoWindow(handle = 1L) + private val workspace = SatelliteWorkspace().apply { join(host) } + private val state = + DockLayoutState(workspace).apply { + layeredSides = setOf(DockSide.Right) + layoutBoundsInWindowPx = Rect(20f, 40f, 1020f, 640f) + bandBoundsInWindowPx[DockSide.Right] = Rect(20f, 40f, 1020f, 640f) + bandBoundsInWindowPx[DockSide.Bottom] = Rect(20f, 40f, 720f, 640f) + } + + private fun docked( + id: String, + side: DockSide, + order: Int, + boundsInLayoutPx: Rect, + ): SatelliteEntry { + val entry = + workspace.register(id, id, SatellitePlacement.Docked(side, order, extent = 100.dp), initiallyOpen = true) + entry.content = {} + entry.dockedBoundsInWindowPx = boundsInLayoutPx.translate(Offset(20f, 40f)) + return entry + } + + private val tree = docked("tree", DockSide.Right, 0, Rect(900f, 0f, 1000f, 600f)) + private val toc = docked("toc", DockSide.Right, 1, Rect(800f, 0f, 900f, 600f)) + private val comments = docked("comments", DockSide.Bottom, 0, Rect(0f, 540f, 350f, 600f)) + private val sources = docked("sources", DockSide.Bottom, 1, Rect(350f, 540f, 700f, 600f)) + + init { + state.docked = listOf(tree, toc, comments, sources) + } + + @Test + fun `a layered side is cut at the layers' centres, from its edge through the strip`() { + val strip = state.landingRectPx(DockSide.Right, 60f, joinsStack = false) + assertEquals(Rect(740f, 0f, 800f, 600f), strip) + assertEquals( + listOf(Rect(950f, 0f, 1000f, 600f), Rect(850f, 0f, 950f, 600f), Rect(740f, 0f, 850f, 600f)), + state.dropSlotsPx(DockSide.Right, strip, dragged = null), + ) + // The dragged layer is left out: its neighbours' centres are the cuts, + // and the region it stands in is the rank it already holds. + assertEquals( + listOf(Rect(950f, 0f, 1000f, 600f), Rect(740f, 0f, 950f, 600f)), + state.dropSlotsPx(DockSide.Right, strip, dragged = toc), + ) + assertEquals( + 1, + DockDropZone(strip, state.dropSlotsPx(DockSide.Right, strip, dragged = toc)).slotAt(Offset(850f, 300f)), + ) + assertEquals(DockTarget(host, DockSide.Right, 1), workspace.ownTarget(toc, host)) + } + + @Test + fun `a split side is cut along its length, from the band's start`() { + val strip = state.landingRectPx(DockSide.Bottom, 60f, joinsStack = false) + assertEquals( + listOf(Rect(0f, 540f, 175f, 600f), Rect(175f, 540f, 525f, 600f), Rect(525f, 540f, 700f, 600f)), + state.dropSlotsPx(DockSide.Bottom, strip, dragged = null), + ) + } + + @Test + fun `no slots without another panel, or before it is placed`() { + val strip = state.landingRectPx(DockSide.Left, 60f, joinsStack = false) + assertEquals(emptyList(), state.dropSlotsPx(DockSide.Left, strip, dragged = null)) + tree.dockedBoundsInWindowPx = null + assertEquals(emptyList(), state.dropSlotsPx(DockSide.Right, strip, dragged = null)) + assertNull(DockDropZone(strip).slotAt(Offset.Zero)) + } + + @Test + fun `the pointer picks the slot it is in, else the nearest end`() { + val zone = + DockDropZone( + strip = Rect(0f, 540f, 700f, 600f), + slots = listOf(Rect(0f, 540f, 175f, 600f), Rect(175f, 540f, 525f, 600f), Rect(525f, 540f, 700f, 600f)), + ) + assertEquals(1, zone.slotAt(Offset(300f, 570f))) + assertEquals(0, zone.slotAt(Offset(-50f, 570f)), "past the start") + assertEquals(2, zone.slotAt(Offset(900f, 570f)), "past the end") + assertEquals(1, zone.slotAt(Offset(300f, 100f)), "off the stack: the rank under the pointer's x") + } + + @Test + fun `a pinned layer hides the ranks in front of it, for itself and for the others`() { + val pinned = + workspace.register( + "pinned", + "pinned", + SatellitePlacement.Docked(DockSide.Left, order = 0, extent = 100.dp), + initiallyOpen = true, + reorderable = false, + ) + pinned.content = {} + pinned.dockedBoundsInWindowPx = Rect(20f, 40f, 120f, 640f) + // The helper takes layout px; the pinned entry above is set in window px. + val movable = docked("movable", DockSide.Left, 1, Rect(100f, 0f, 200f, 600f)) + state.layeredSides = state.layeredSides + DockSide.Left + state.docked = listOf(pinned, movable) + state.bandBoundsInWindowPx[DockSide.Left] = Rect(20f, 40f, 1020f, 640f) + val strip = state.landingRectPx(DockSide.Left, 60f, joinsStack = false) + assertEquals(Rect(200f, 0f, 260f, 600f), strip, "inset behind the two layers") + + // Dragging the movable layer: rank 0 would push the pinned one in, so + // it is not on offer — an empty rect keeping the ranks aligned — and + // rank 1 covers the whole region, the pinned layer included. + assertEquals( + listOf(Rect.Zero, Rect(0f, 0f, 260f, 600f)), + state.dropSlotsPx(DockSide.Left, strip, dragged = movable), + ) + val zone = DockDropZone(strip, state.dropSlotsPx(DockSide.Left, strip, dragged = movable)) + assertEquals(1, zone.slotAt(Offset(50f, 300f)), "aimed at the pinned layer, it lands behind it") + // Aimed in front of it, the movable layer is shown right behind it. + assertEquals(Rect(100f, 0f, 160f, 600f), state.dropRectPx(DockSide.Left, movable, 0, 60f)) + // The pinned layer itself is offered no rank at all. + assertEquals(emptyList(), state.dropSlotsPx(DockSide.Left, strip, dragged = pinned)) + } + + @Test + fun `a layer dropped at a rank is drawn where that rank puts it, at its own extent`() { + // Layered right: rank 1 is between the tree (900..1000) and the toc, which moves in to make room. + assertEquals(Rect(840f, 0f, 900f, 600f), state.dropRectPx(DockSide.Right, null, 1, 60f)) + assertEquals(Rect(940f, 0f, 1000f, 600f), state.dropRectPx(DockSide.Right, null, 0, 60f), "the side's edge") + assertEquals(Rect(740f, 0f, 800f, 600f), state.dropRectPx(DockSide.Right, null, 2, 60f), "past the innermost") + // The toc dragged to rank 0: only the tree stays, behind it. + assertEquals(Rect(940f, 0f, 1000f, 600f), state.dropRectPx(DockSide.Right, toc, 0, 60f)) + } + + @Test + fun `a panel dropped in a split stack is drawn as the share the weights give it`() { + // Split bottom, the sources dragged: they and the comments share the length again. + assertEquals(Rect(350f, 540f, 700f, 600f), state.dropRectPx(DockSide.Bottom, sources, 1, 60f)) + assertEquals(Rect(0f, 540f, 350f, 600f), state.dropRectPx(DockSide.Bottom, sources, 0, 60f)) + // A third panel, weight 1, in the middle: a third each. + val notes = workspace.register("notes", "notes", SatellitePlacement.Floating(), initiallyOpen = true) + assertRectEquals(Rect(700f / 3, 540f, 1400f / 3, 600f), state.dropRectPx(DockSide.Bottom, notes, 1, 60f)) + // Twice the weight of each of the others, between them: half the stack. + workspace.dock("notes", DockSide.Left, host = host) + workspace.setDockedWeight("notes", 2f) + assertRectEquals(Rect(175f, 540f, 525f, 600f), state.dropRectPx(DockSide.Bottom, notes, 1, 60f)) + } + + private fun assertRectEquals( + expected: Rect, + actual: Rect, + ) { + val close = + listOf(expected.left to actual.left, expected.top to actual.top) + .plus(expected.right to actual.right) + .plus(expected.bottom to actual.bottom) + .all { (e, a) -> abs(e - a) < 0.01f } + assertTrue(close, "expected $expected, was $actual") + } + + @Test + fun `dropped on an empty side, the space is the strip along its edge`() { + assertEquals(Rect(0f, 0f, 60f, 600f), state.dropRectPx(DockSide.Left, null, 0, 60f)) + } +} + +/** + * Which zone a drag resolves to ([SatelliteWorkspace.dockTargetAt] with the + * dragged rect): the zone the satellite on screen has been brought against, + * with the pointer as a second trigger and as the tie-break. + * + * Host a's layout is (100, 140)-(900, 700) on screen, zone width 64 px. + */ +class DockTargetFromDraggedRectTest { + private val a = TaoWindow(handle = 1L) + + private fun workspace(): SatelliteWorkspace = + SatelliteWorkspace().apply { + join(a) + dockHosts.register( + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + }, + ) + } + + @Test + fun `the dragged rect decides the zone, not the pointer`() { + val workspace = workspace() + + // A 200 x 300 palette pushed against the left edge: its own edge is in + // the zone while the pointer sits in the middle of the palette, far + // from any edge of the layout. + val atLeft = Rect(120f, 300f, 320f, 600f) + assertEquals( + DockTarget(a, DockSide.Left), + workspace.dockTargetAt(atLeft, atLeft.center), + "the palette's own edge has entered the left zone", + ) + assertNull(workspace.dockTargetAt(atLeft.center), "the pointer alone is over the content") + + // Aligned from the outside too: pushed 20 px past the edge is still + // brought against it. + val justOver = Rect(80f, 300f, 280f, 600f) + assertEquals(DockTarget(a, DockSide.Left), workspace.dockTargetAt(justOver, justOver.center)) + + // Deep past the edge is no longer an alignment — but the pointer, now + // over the left strip itself, still is. + val overhanging = Rect(20f, 300f, 220f, 600f) + assertEquals(DockTarget(a, DockSide.Left), workspace.dockTargetAt(overhanging, Offset(120f, 450f))) + assertNull(workspace.dockTargetAt(overhanging, Offset(200f, 450f)), "neither edge nor pointer is at a zone") + + // Over the middle: no zone, whatever the pointer does. + val middle = Rect(400f, 350f, 600f, 500f) + assertNull(workspace.dockTargetAt(middle, middle.center), "nothing has entered a zone") + + // Beside the layout, not on it: no drop, even with the pointer inside. + val beside = Rect(950f, 300f, 1150f, 600f) + assertNull(workspace.dockTargetAt(beside, beside.center)) + + // Aligned with two sides at once: the pointer decides. + val topLeftCorner = Rect(120f, 150f, 320f, 250f) + assertEquals(DockTarget(a, DockSide.Top), workspace.dockTargetAt(topLeftCorner, Offset(300f, 240f))) + } + + @Test + fun `an inset zone is the target, not the window's own edge`() { + val workspace = workspace() + // What a layered right side draws while two columns are already + // docked: the strip is inset 200 px behind them, not at x 900. + val geometry = requireNotNull(workspace.dockHostGeometry(a)) + geometry.zoneBoundsInWindowPx = mapOf(DockSide.Right to DockDropZone(Rect(540f, 40f, 604f, 600f))) + + // The palette brought against the drawn strip docks… + val onStrip = Rect(440f, 300f, 700f, 600f) + assertEquals(DockTarget(a, DockSide.Right), workspace.dockTargetAt(onStrip, onStrip.center)) + // …while the window's own right edge, behind the columns, is nothing. + val atWindowEdge = Rect(700f, 300f, 900f, 600f) + assertNull(workspace.dockTargetAt(atWindowEdge, atWindowEdge.center)) + // The pointer in the drawn strip is a target too. + assertNull(workspace.dockTargetAt(atWindowEdge, Offset(880f, 400f)), "the window edge is not a zone") + assertEquals( + DockTarget(a, DockSide.Right), + workspace.dockTargetAt(atWindowEdge, Offset(670f, 400f)), + "the pointer inside the drawn strip", + ) + // A side the layout does not draw is not a target at all. + assertNull(workspace.dockTargetAt(Rect(120f, 300f, 320f, 600f), Offset(120f, 400f)), "no left zone is drawn") + } + + @Test + fun `the pointer over a stack picks a rank, and beats a strip across its corner`() { + val workspace = workspace() + val geometry = requireNotNull(workspace.dockHostGeometry(a)) + // A split left side with two panels (window px 0..200 wide, 40..600 + // tall) and an empty top side whose strip runs across the stack's top. + geometry.zoneBoundsInWindowPx = + mapOf( + DockSide.Left to + DockDropZone( + strip = Rect(0f, 40f, 64f, 600f), + slots = + listOf( + Rect(0f, 40f, 200f, 180f), + Rect(0f, 180f, 200f, 460f), + Rect(0f, 460f, 200f, 600f), + ), + ), + DockSide.Top to DockDropZone(Rect(0f, 40f, 800f, 104f)), + ) + // The dragged ghost sits over the content, the pointer over the stack. + val ghost = Rect(400f, 300f, 600f, 450f) + assertEquals(DockTarget(a, DockSide.Left, 1), workspace.dockTargetAt(ghost, Offset(250f, 400f))) + assertEquals(DockTarget(a, DockSide.Left, 2), workspace.dockTargetAt(ghost, Offset(250f, 650f))) + // In the corner both the top strip and the first rank hold the pointer: the rank wins. + assertEquals(DockTarget(a, DockSide.Left, 0), workspace.dockTargetAt(ghost, Offset(250f, 160f))) + // Brought against the strip with the pointer away from the stack: the nearest rank along it. + val atLeft = Rect(120f, 300f, 320f, 600f) + assertEquals(DockTarget(a, DockSide.Left, 1), workspace.dockTargetAt(atLeft, Offset(220f, 450f))) + // An unranked side stays unranked. + assertEquals(DockTarget(a, DockSide.Top), workspace.dockTargetAt(ghost, Offset(500f, 170f))) + } + + @Test + fun `a dragged rect covering every zone is resolved by the pointer`() { + val workspace = workspace() + // Larger than the layout: every side is within reach at once. + val covering = Rect(50f, 100f, 950f, 750f) + + assertEquals(DockTarget(a, DockSide.Left), workspace.dockTargetAt(covering, Offset(120f, 400f))) + assertEquals(DockTarget(a, DockSide.Bottom), workspace.dockTargetAt(covering, Offset(500f, 690f))) + // Pointer off the layout: the closest alignment decides instead — the + // covering rect overhangs the top by the least. + assertEquals(DockTarget(a, DockSide.Top), workspace.dockTargetAt(covering, Offset(0f, 0f))) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockRankTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockRankTest.kt new file mode 100644 index 000000000..2954d3d67 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockRankTest.kt @@ -0,0 +1,218 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.DockDropZone +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * The ranks of a dock side ([SatellitePlacement.Docked.order]) as + * [SatelliteWorkspace.dock] and [SatelliteWorkspace.undock] keep them: + * contiguous from `0`, inserted at the index asked for, and remembered per + * side so a satellite floated and docked again comes back to its place. + */ +class SatelliteDockRankTest { + private val a = TaoWindow(handle = 1L) + private val panelOrigin = SatelliteDragOrigin.DockedPanel(a) + + private val floatingRight = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + @Test + fun `dock order inserts at that rank and keeps the side contiguous`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("one", "One", floatingRight, initiallyOpen = true) + workspace.register("two", "Two", floatingRight, initiallyOpen = true) + workspace.register("three", "Three", floatingRight, initiallyOpen = true) + + workspace.dock("one", DockSide.Left) + workspace.dock("two", DockSide.Left) + // Out of range on either end clamps: the first rank, then the last. + workspace.dock("three", DockSide.Left, order = -5) + assertEquals(listOf("three", "one", "two"), workspace.ranksOn(DockSide.Left)) + workspace.dock("three", DockSide.Left, order = 99) + assertEquals(listOf("one", "two", "three"), workspace.ranksOn(DockSide.Left)) + workspace.dock("three", DockSide.Left, order = 1) + assertEquals(listOf("one", "three", "two"), workspace.ranksOn(DockSide.Left)) + // A re-dock on the same side with no rank keeps the one it has. + workspace.dock("three", DockSide.Left) + assertEquals(listOf("one", "three", "two"), workspace.ranksOn(DockSide.Left)) + } + + @Test + fun `a satellite docked again on the side it left returns to its rank`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + for ((rank, id) in listOf("tree", "toc", "notes").withIndex()) { + workspace.register(id, id, SatellitePlacement.Docked(DockSide.Right, order = rank), initiallyOpen = true) + } + + workspace.undock("toc") + // The gap closes behind it… + assertEquals(listOf("tree", "notes"), workspace.ranksOn(DockSide.Right)) + assertEquals(1, (workspace.satellite("notes")!!.placement as SatellitePlacement.Docked).order) + // …and it opens again where it was, through every path that names no rank. + workspace.dock("toc", DockSide.Right) + assertEquals(listOf("tree", "toc", "notes"), workspace.ranksOn(DockSide.Right)) + + // The rank it *leaves* with is the one remembered, not the declared one. + workspace.dock("tree", DockSide.Right, order = 2) + assertEquals(listOf("toc", "notes", "tree"), workspace.ranksOn(DockSide.Right)) + workspace.undock("tree") + workspace.dock("notes", DockSide.Left) + workspace.dock("tree", DockSide.Right) + assertEquals(listOf("toc", "tree"), workspace.ranksOn(DockSide.Right)) + } + + @Test + fun `a satellite new to a side is appended there and keeps its rank elsewhere`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("tree", "Tree", SatellitePlacement.Docked(DockSide.Right, order = 0), initiallyOpen = true) + workspace.register("toc", "Toc", SatellitePlacement.Docked(DockSide.Right, order = 1), initiallyOpen = true) + workspace.register( + "targum", + "Targum", + SatellitePlacement.Docked(DockSide.Left, order = 0), + initiallyOpen = true, + ) + + // Moved to a side it never sat on: after what is there. + workspace.dock("tree", DockSide.Left) + assertEquals(listOf("targum", "tree"), workspace.ranksOn(DockSide.Left)) + assertEquals(listOf("toc"), workspace.ranksOn(DockSide.Right)) + assertEquals(0, (workspace.satellite("toc")!!.placement as SatellitePlacement.Docked).order) + // Back to the right: at the rank it left, ahead of the toc. + workspace.dock("tree", DockSide.Right) + assertEquals(listOf("tree", "toc"), workspace.ranksOn(DockSide.Right)) + // A floating satellite that was never docked appends too. + workspace.register("notes", "Notes", floatingRight, initiallyOpen = true) + workspace.dock("notes", DockSide.Right) + assertEquals(listOf("tree", "toc", "notes"), workspace.ranksOn(DockSide.Right)) + } + + @Test + fun `a closed panel keeps its rank and the weight comes back with it`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("tree", "Tree", SatellitePlacement.Docked(DockSide.Right, order = 0), initiallyOpen = true) + workspace.register("toc", "Toc", SatellitePlacement.Docked(DockSide.Right, order = 1), initiallyOpen = true) + workspace.register("notes", "Notes", SatellitePlacement.Docked(DockSide.Right, order = 2), initiallyOpen = true) + workspace.setDockedWeight("toc", 3f) + + workspace.close("toc") + workspace.undock("notes") + workspace.dock("notes", DockSide.Right) + // The closed toc still holds rank 1; the notes return behind it. + assertEquals(listOf("tree", "toc", "notes"), workspace.ranksOn(DockSide.Right)) + + workspace.undock("toc") + workspace.dock("toc", DockSide.Right) + assertEquals(3f, (workspace.satellite("toc")!!.placement as SatellitePlacement.Docked).weight) + assertEquals(listOf("tree", "toc", "notes"), workspace.ranksOn(DockSide.Right)) + } + + /** The ids docked on [side] of the owner, in rank order; every rank is asserted contiguous from 0. */ + private fun SatelliteWorkspace.ranksOn(side: DockSide): List { + val stack = + satellites + .filter { (it.placement as? SatellitePlacement.Docked)?.side == side } + .sortedBy { (it.placement as SatellitePlacement.Docked).order } + assertEquals( + stack.indices.toList(), + stack.map { (it.placement as SatellitePlacement.Docked).order }, + "ranks on $side", + ) + return stack.map { it.id } + } + + /** + * Host `a` as the drag test sees it: outer frame at (100, 100), 800×600, + * content the same size, DockLayout below a 40 px bar — so its screen rect + * is (100, 140)–(900, 700), scale 1. + */ + private fun SatelliteWorkspace.registerHostA(): HostGeometry { + join(a) + val geometry = + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + } + dockHosts.register(geometry) + return geometry + } + + @Test + fun `a docked drag dropped on its own stack takes the rank under the pointer`() { + val workspace = SatelliteWorkspace() + val geometry = workspace.registerHostA() + val ids = listOf("tree", "toc", "notes") + for (id in ids) { + workspace.register(id, id, floatingRight, initiallyOpen = true).content = {} + workspace.dock(id, DockSide.Left) + } + // Stacked down the left side, 200 px wide, in window px. + val bounds = + listOf(Rect(0f, 40f, 200f, 226f), Rect(0f, 226f, 200f, 413f), Rect(0f, 413f, 200f, 600f)) + ids.forEachIndexed { index, id -> + workspace.satellite(id)!!.dockedBoundsInWindowPx = bounds[index] + workspace.satellite(id)!!.dockHostContainerSizePx = IntSize(800, 600) + } + // What the layout publishes while the notes are dragged: the tree and + // the toc cut at their centres, three ranks. + geometry.zoneBoundsInWindowPx = + mapOf( + DockSide.Left to + DockDropZone( + strip = Rect(0f, 40f, 64f, 600f), + slots = + listOf( + Rect(0f, 40f, 200f, 133f), + Rect(0f, 133f, 200f, 319.5f), + Rect(0f, 319.5f, 200f, 600f), + ), + ), + ) + + // Over its own rank: nothing to preview, and a release leaves it alone. + var session = requireNotNull(workspace.beginDrag("notes", panelOrigin, Offset(200f, 550f))) + session.update(Offset(210f, 560f)) + assertNull(workspace.dockPreview, "its own slot is not a target") + session.end(Offset(210f, 560f)) + assertEquals(ids, workspace.ranksOn(DockSide.Left)) + + // Over the top of the tree: first rank. + session = requireNotNull(workspace.beginDrag("notes", panelOrigin, Offset(200f, 550f))) + session.update(Offset(250f, 200f)) + assertEquals(DockTarget(a, DockSide.Left, 0), workspace.dockPreview) + session.end(Offset(250f, 200f)) + assertEquals(listOf("notes", "tree", "toc"), workspace.ranksOn(DockSide.Left)) + assertSame(a, workspace.satellite("notes")!!.dockHost) + + // A closed panel keeps its rank in the middle while the shown ones are aimed between. + workspace.close("tree") + // Shown: notes, toc. Dropping the toc at shown rank 0 lands ahead of both. + geometry.zoneBoundsInWindowPx = + mapOf( + DockSide.Left to + DockDropZone( + strip = Rect(0f, 40f, 64f, 600f), + slots = listOf(Rect(0f, 40f, 200f, 320f), Rect(0f, 320f, 200f, 600f)), + ), + ) + session = requireNotNull(workspace.beginDrag("toc", panelOrigin, Offset(200f, 550f))) + session.end(Offset(250f, 200f)) + assertEquals(listOf("toc", "notes", "tree"), workspace.ranksOn(DockSide.Left)) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockSidesTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockSidesTest.kt new file mode 100644 index 000000000..9a61b3b55 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockSidesTest.kt @@ -0,0 +1,161 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull + +/** + * A satellite declared for some sides only ([SatelliteEntry.dockSides]): the + * others are refused by [SatelliteWorkspace.dock], never previewed by a drag, + * not offered as hints, and not applied from a snapshot. + */ +class SatelliteDockSidesTest { + private val a = TaoWindow(handle = 1L) + private val notTop = setOf(DockSide.Left, DockSide.Right, DockSide.Bottom) + + private val floating = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + /** Host `a`: layout (100, 140)–(900, 700) on screen, scale 1. */ + private fun workspace(): SatelliteWorkspace = + SatelliteWorkspace().apply { + join(a) + dockHosts.register( + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + }, + ) + } + + @Test + fun `dock refuses a side the satellite was not declared for`() { + val workspace = workspace() + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true, dockSides = notTop) + + workspace.dock("tools", DockSide.Top) + assertEquals(floating, entry.placement, "a refused dock changes nothing") + + workspace.dock("tools", DockSide.Left) + assertEquals(DockSide.Left, assertIs(entry.placement).side) + workspace.dock("tools", DockSide.Top) + assertEquals(DockSide.Left, assertIs(entry.placement).side, "still where it was") + } + + @Test + fun `floating-only never docks, the preferred side follows the declaration`() { + val workspace = workspace() + val never = workspace.register("hud", "Hud", floating, initiallyOpen = true, dockSides = emptySet()) + workspace.dock("hud", DockSide.Right) + assertEquals(floating, never.placement) + + val leftOnly = + workspace.register( + "nav", + "Nav", + floating, + initiallyOpen = true, + dockSides = setOf(DockSide.Left), + ) + assertEquals(DockSide.Left, leftOnly.preferredDockSide, "the right side is not allowed: the first allowed one") + val notTopEntry = workspace.register("tools", "Tools", floating, initiallyOpen = true, dockSides = notTop) + assertEquals(DockSide.Right, notTopEntry.preferredDockSide) + } + + @Test + fun `a declared docked placement must name an allowed side`() { + val workspace = workspace() + assertFailsWith { + workspace.register( + "tools", + "Tools", + SatellitePlacement.Docked(DockSide.Top), + initiallyOpen = true, + dockSides = notTop, + ) + } + val ok = + workspace.register( + "nav", + "Nav", + SatellitePlacement.Docked(DockSide.Left), + initiallyOpen = true, + dockSides = notTop, + ) + assertEquals(DockSide.Left, assertIs(ok.placement).side) + } + + @Test + fun `a refused side is not hinted nor previewed, a release there keeps it floating`() { + val workspace = workspace() + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true, dockSides = notTop) + assertEquals( + listOf(DockSide.Left, DockSide.Right, DockSide.Bottom), + hintedSides(entry, a, workspace.satellites), + ) + + // The bare edges are a target for anyone… + val atTop = Rect(400f, 150f, 600f, 300f) + assertEquals(DockTarget(a, DockSide.Top), workspace.dockTargetAt(atTop, atTop.center)) + // …but not for this satellite. + assertNull(workspace.dockTargetFor(entry, atTop, atTop.center)) + + val satellite = TaoWindow(handle = 3L) + val origin = + SatelliteDragOrigin.FloatingWindow( + window = satellite, + outerBoundsPx = { longArrayOf(400L, 300L, 200L, 150L) }, + move = { _, _ -> }, + ) + val session = requireNotNull(workspace.beginDrag("tools", origin, Offset(500f, 310f))) + session.update(Offset(500f, 160f)) + assertNull(workspace.dockPreview, "the top zone is not previewed for a satellite that may not dock there") + session.end(Offset(500f, 160f)) + assertIs(entry.placement) + + val again = requireNotNull(workspace.beginDrag("tools", origin, Offset(500f, 310f))) + again.update(Offset(500f, 690f)) + assertEquals(DockTarget(a, DockSide.Bottom), workspace.dockPreview) + again.end(Offset(500f, 690f)) + assertEquals(DockSide.Bottom, assertIs(entry.placement).side) + } + + @Test + fun `a snapshot naming a refused side leaves the placement alone`() { + val workspace = workspace() + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true, dockSides = notTop) + workspace.restore( + SatelliteLayoutSnapshot( + satellites = + mapOf( + "tools" to SatelliteSnapshot(SatellitePlacement.Docked(DockSide.Top), isOpen = false), + ), + dockExtents = emptyMap(), + ), + ) + assertEquals(floating, entry.placement) + assertEquals(false, entry.isOpen, "the open state is still applied") + + workspace.restore( + SatelliteLayoutSnapshot( + satellites = + mapOf( + "tools" to SatelliteSnapshot(SatellitePlacement.Docked(DockSide.Left), isOpen = true), + ), + dockExtents = emptyMap(), + ), + ) + assertEquals(DockSide.Left, assertIs(entry.placement).side) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockedGeometryTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockedGeometryTest.kt new file mode 100644 index 000000000..80b53e64e --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockedGeometryTest.kt @@ -0,0 +1,151 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The per-panel geometry a [SatellitePlacement.Docked] carries — its own + * extent on a layered side, its weight on a split side — and how + * [SatelliteWorkspace] seeds, clamps and persists it. Driven without any + * native window, like [SatelliteWorkspaceTest]. + */ +class SatelliteDockedGeometryTest { + private val a = TaoWindow(handle = 1L) + private val b = TaoWindow(handle = 2L) + + private val floatingRight = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + // ── per-panel geometry: layered extents and split weights ──────────── + + @Test + fun `docking from a floating window brings its size along as the panel extent`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + + workspace.dock("tools", DockSide.Right) + val docked = assertIs(workspace.satellite("tools")?.placement) + assertEquals(200.dp, docked.extent, "a right layer is as wide as the window was") + assertEquals(1f, docked.weight) + + workspace.undock("tools") + workspace.dock("tools", DockSide.Bottom) + val bottom = assertIs(workspace.satellite("tools")?.placement) + assertEquals(300.dp, bottom.extent, "a bottom layer is as tall as the window was") + } + + @Test + fun `re-docking keeps the extent along the same axis and re-seeds it across axes`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + workspace.dock("tools", DockSide.Right) + workspace.setDockedExtent("tools", 240.dp) + workspace.setDockedWeight("tools", 2.5f) + + workspace.dock("tools", DockSide.Left) + val left = assertIs(workspace.satellite("tools")?.placement) + assertEquals(240.dp, left.extent, "left and right share the width axis") + assertEquals(2.5f, left.weight, "the weight travels with the panel") + + workspace.dock("tools", DockSide.Top) + val top = assertIs(workspace.satellite("tools")?.placement) + assertEquals(300.dp, top.extent, "a width is no height: the floating size seeds the top layer") + assertEquals(2.5f, top.weight) + } + + @Test + fun `a panel moved between docks seeds its new side with the width it had`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("comments", "Comments", floatingRight, initiallyOpen = true) + workspace.dock("comments", DockSide.Bottom) + workspace.setDockedExtent("comments", 220.dp) + + // The top side has no extent of its own: the arriving panel gives it + // the height it had at the bottom, and the preview promises exactly + // that — the two must agree, or the drop lands somewhere the preview + // did not show. + val entry = requireNotNull(workspace.satellite("comments")) + assertEquals(220.dp, workspace.plannedDockExtent(entry, DockSide.Top), "the preview height") + workspace.dock("comments", DockSide.Top) + assertEquals(220.dp, workspace.dockExtent(DockSide.Top), "the side took the panel's height") + assertEquals(220.dp, assertIs(entry.placement).extent) + + // Across the axes a height is no width: the floating size seeds it, + // and again the preview says the same. + assertEquals(200.dp, workspace.plannedDockExtent(entry, DockSide.Left)) + workspace.dock("comments", DockSide.Left) + assertEquals(200.dp, workspace.dockExtent(DockSide.Left)) + + // A side that already has an extent keeps it. + workspace.setDockExtent(DockSide.Right, 150.dp) + assertEquals(150.dp, workspace.plannedDockExtent(entry, DockSide.Right)) + workspace.dock("comments", DockSide.Right) + assertEquals(150.dp, workspace.dockExtent(DockSide.Right)) + } + + @Test + fun `docked extent and weight are clamped and ignored for a floating satellite`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + + workspace.setDockedExtent("tools", 10.dp) + assertIs(workspace.satellite("tools")?.placement, "floating: untouched") + + workspace.dock("tools", DockSide.Right) + workspace.setDockedExtent("tools", 10.dp) + workspace.setDockedWeight("tools", -3f) + val docked = assertIs(workspace.satellite("tools")?.placement) + assertEquals(SatelliteWorkspace.MinDockExtent, docked.extent) + assertTrue(docked.weight > 0f, "a weight is never zero or negative: ${docked.weight}") + assertEquals(DockSide.Right, docked.side) + assertEquals(0, docked.order) + } + + @Test + fun `a snapshot carries every panel's own extent and weight`() { + val source = SatelliteWorkspace() + source.join(a) + source.register("tree", "Tree", floatingRight, initiallyOpen = true) + source.register("toc", "Toc", floatingRight, initiallyOpen = true) + source.dock("tree", DockSide.Right) + source.dock("toc", DockSide.Right) + source.setDockedExtent("tree", 180.dp) + source.setDockedExtent("toc", 130.dp) + source.setDockedWeight("toc", 3f) + + val target = SatelliteWorkspace() + target.join(b) + target.restore(source.snapshot()) + val tree = assertIs(target.register("tree", "Tree", floatingRight, true).placement) + val toc = assertIs(target.register("toc", "Toc", floatingRight, true).placement) + assertEquals(SatellitePlacement.Docked(DockSide.Right, 0, 180.dp, 1f), tree) + assertEquals(SatellitePlacement.Docked(DockSide.Right, 1, 130.dp, 3f), toc) + } + + @Test + fun `a docked placement refuses a weight that is not positive`() { + assertFailsWith { SatellitePlacement.Docked(DockSide.Left, weight = 0f) } + } + + @Test + fun `every side has an opposite across the content`() { + for (side in DockSide.entries) { + assertNotEquals(side, side.opposite) + assertEquals(side, side.opposite.opposite) + assertEquals(side.isVertical, side.opposite.isVertical) + } + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDragKindTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDragKindTest.kt new file mode 100644 index 000000000..0056c1db8 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDragKindTest.kt @@ -0,0 +1,89 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What an app can ask about a drag in flight ([SatelliteWorkspace.dragKind]) + * and about the window it draws in ([TaoWindow.canPlaceOnScreen]) — the two + * public answers chrome needs to tell "move the window" from "move the + * satellite". + */ +class SatelliteDragKindTest { + private val a = TaoWindow(handle = 1L) + + private val floating = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + private fun workspace(): SatelliteWorkspace = + SatelliteWorkspace().apply { + join(a) + dockHosts.register( + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + }, + ) + } + + private val satellite = TaoWindow(handle = 3L) + private val origin = + SatelliteDragOrigin.FloatingWindow( + window = satellite, + outerBoundsPx = { longArrayOf(400L, 300L, 200L, 150L) }, + move = { _, _ -> }, + ) + + @Test + fun `a pointer drag is carried by the window, and the kind clears with it`() { + val workspace = workspace() + workspace.register("tools", "Tools", floating, initiallyOpen = true) + assertNull(workspace.dragKind, "nothing is dragging") + + val session = requireNotNull(workspace.beginDrag("tools", origin, Offset(500f, 310f))) + assertEquals(SatelliteDragKind.Window, workspace.dragKind) + session.update(Offset(500f, 690f)) + assertEquals(SatelliteDragKind.Window, workspace.dragKind, "still the window's own drag") + session.end(Offset(500f, 690f)) + assertNull(workspace.dragKind, "the release clears it") + + val cancelled = requireNotNull(workspace.beginDrag("tools", origin, Offset(500f, 310f))) + cancelled.cancel() + assertNull(workspace.dragKind) + } + + @Test + fun `a transfer drag is carried by the platform session, and publishes no ghost`() { + val workspace = workspace() + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true) + entry.content = {} + workspace.dock("tools", DockSide.Left) + entry.dockedBoundsInWindowPx = Rect(0f, 40f, 220f, 600f) + + val session = requireNotNull(workspace.beginTransferDrag("tools", SatelliteDragOrigin.DockedPanel(a))) + assertEquals(SatelliteDragKind.Transfer, workspace.dragKind) + assertEquals(entry, workspace.draggedSatellite, "the satellite is published either way") + assertNull(workspace.dragGhost, "no window follows a transfer drag") + session.end() + assertNull(workspace.dragKind) + } + + @Test + fun `a window that is not a native Wayland surface places on screen`() { + // Without a native surface the kind is unknown, which is the answer + // every platform but Wayland gives: the app places its own windows. + assertTrue(a.canPlaceOnScreen) + assertEquals(!a.isNativeWaylandSurface, a.canPlaceOnScreen) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteFixedPanelTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteFixedPanelTest.kt new file mode 100644 index 000000000..4c2b470ba --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteFixedPanelTest.kt @@ -0,0 +1,211 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.DockDropZone +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull + +/** + * A fixed panel: [SatelliteEntry.isFloatable] `false` keeps it out of a + * window of its own — [SatelliteWorkspace.undock] refuses it, a drag released + * over the content leaves it docked and shows no tear-out ghost, a snapshot + * that floats it is ignored — and [SatelliteEntry.isReorderable] `false` pins + * its rank: its own drag is offered none, and another panel can only be + * dropped after it. + */ +class SatelliteFixedPanelTest { + private val a = TaoWindow(handle = 1L) + private val panelOrigin = SatelliteDragOrigin.DockedPanel(a) + + private val floating = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + /** Host `a`: layout (100, 140)–(900, 700) on screen, scale 1. */ + private fun workspace(): Pair { + val workspace = SatelliteWorkspace() + workspace.join(a) + val geometry = + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + } + workspace.dockHosts.register(geometry) + return workspace to geometry + } + + /** A fixed panel of the left side, with a rect the drag code can read. */ + private fun SatelliteWorkspace.fixedPanel( + id: String, + order: Int = 0, + boundsInWindowPx: Rect = Rect(0f, 40f, 200f, 600f), + ): SatelliteEntry { + val entry = + register( + id, + id, + SatellitePlacement.Docked(DockSide.Left, order = order), + initiallyOpen = true, + dockSides = setOf(DockSide.Left), + floatable = false, + reorderable = false, + ) + entry.content = {} + entry.dockedBoundsInWindowPx = boundsInWindowPx + entry.dockHostContainerSizePx = IntSize(800, 600) + return entry + } + + @Test + fun `undock refuses a fixed panel`() { + val (workspace, _) = workspace() + val entry = workspace.fixedPanel("tree") + + workspace.undock("tree") + assertEquals(DockSide.Left, assertIs(entry.placement).side) + + workspace.undock("tree", floating) + assertIs(entry.placement, "an explicit placement is refused too") + } + + @Test + fun `a fixed satellite must be declared docked`() { + val (workspace, _) = workspace() + assertFailsWith { + workspace.register("tree", "Tree", floating, initiallyOpen = true, floatable = false) + } + assertFailsWith { + workspace.register("toc", "Toc", floating, initiallyOpen = true, reorderable = false) + } + } + + @Test + fun `a drag released over the content leaves a fixed panel docked, with no ghost`() { + val (workspace, _) = workspace() + val entry = workspace.fixedPanel("tree") + + val session = requireNotNull(workspace.beginDrag("tree", panelOrigin, Offset(150f, 300f))) + session.update(Offset(500f, 400f)) + assertNull(workspace.dragGhost, "a fixed panel shows no tear-out ghost") + assertNull(workspace.dockPreview, "the middle of the layout is no zone") + session.end(Offset(500f, 400f)) + assertEquals(DockSide.Left, assertIs(entry.placement).side) + assertNull(workspace.draggedSatellite) + + // Outside every layout — where a floating panel would be torn out. + val away = requireNotNull(workspace.beginDrag("tree", panelOrigin, Offset(150f, 300f))) + away.end(Offset(2_000f, 2_000f)) + assertIs(entry.placement, "released off every window, it stays docked") + } + + @Test + fun `a transfer drag with no record leaves a fixed panel docked`() { + val (workspace, _) = workspace() + val entry = workspace.fixedPanel("tree") + + val session = requireNotNull(workspace.beginTransferDrag("tree", panelOrigin)) + session.end() + assertEquals(DockSide.Left, assertIs(entry.placement).side) + } + + @Test + fun `a snapshot that floats a fixed panel is ignored, but its open state is not`() { + val (workspace, _) = workspace() + val entry = workspace.fixedPanel("tree") + + workspace.restore( + SatelliteLayoutSnapshot( + satellites = mapOf("tree" to SatelliteSnapshot(floating, isOpen = false)), + dockExtents = emptyMap(), + ), + ) + assertIs(entry.placement) + assertEquals(false, entry.isOpen) + } + + @Test + fun `a pinned panel is offered no rank and its drag changes nothing`() { + val (workspace, geometry) = workspace() + val tree = workspace.fixedPanel("tree", order = 0, boundsInWindowPx = Rect(0f, 40f, 200f, 320f)) + val toc = workspace.fixedPanel("toc", order = 1, boundsInWindowPx = Rect(0f, 320f, 200f, 600f)) + // Declared for the left side only, and pinned there: nothing to offer. + assertEquals(emptyList(), hintedSides(toc, a, workspace.satellites)) + assertEquals(DockTarget(a, DockSide.Left), workspace.ownTarget(toc, a), "its own side, at no rank") + geometry.zoneBoundsInWindowPx = + mapOf( + DockSide.Left to + DockDropZone( + strip = Rect(0f, 40f, 64f, 600f), + slots = listOf(Rect(0f, 40f, 200f, 180f), Rect(0f, 180f, 200f, 600f)), + ), + ) + + val session = requireNotNull(workspace.beginDrag("toc", panelOrigin, Offset(200f, 550f))) + session.update(Offset(250f, 200f)) + assertNull(workspace.dockPreview, "a pinned panel takes no rank, so its own side is no target") + session.end(Offset(250f, 200f)) + assertEquals(0, assertIs(tree.placement).order) + assertEquals(1, assertIs(toc.placement).order) + } + + @Test + fun `another panel is docked after the pinned ones, whatever rank it asks for`() { + val (workspace, _) = workspace() + workspace.fixedPanel("tree", order = 0) + workspace.fixedPanel("toc", order = 1) + val notes = + workspace.register( + "notes", + "Notes", + SatellitePlacement.Docked(DockSide.Left, order = 2), + initiallyOpen = true, + ) + notes.content = {} + + workspace.dock("notes", DockSide.Left, order = 0) + assertEquals(2, assertIs(notes.placement).order, "pushed past the pinned pair") + assertEquals(0, assertIs(workspace.satellite("tree")!!.placement).order) + assertEquals(1, assertIs(workspace.satellite("toc")!!.placement).order) + + // A pinned panel re-docked takes its own rank back, ahead of the movable one. + workspace.dock("tree", DockSide.Left) + assertEquals(0, assertIs(workspace.satellite("tree")!!.placement).order) + assertEquals(2, assertIs(notes.placement).order) + } + + @Test + fun `a panel that can go nowhere is no drag handle`() { + val (workspace, _) = workspace() + val tree = workspace.fixedPanel("tree") + assertEquals(false, workspace.canBeDragged(tree), "alone, one side, pinned: nothing a drag could do") + + // A second panel on the side gives a movable one somewhere to go… + val notes = + workspace.register( + "notes", + "Notes", + SatellitePlacement.Docked(DockSide.Left, order = 1), + initiallyOpen = true, + dockSides = setOf(DockSide.Left), + floatable = false, + ) + notes.content = {} + assertEquals(true, workspace.canBeDragged(notes)) + // …but not to the pinned one, which still cannot take another rank. + assertEquals(false, workspace.canBeDragged(tree)) + + // Another member's dock is somewhere to go, for either of them. + workspace.join(TaoWindow(handle = 2L)) + assertEquals(true, workspace.canBeDragged(tree)) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspaceTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspaceTest.kt index 5a5b256bd..c257f9853 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspaceTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspaceTest.kt @@ -29,6 +29,18 @@ class SatelliteWorkspaceTest { const val CHURN_CYCLES = 50 } + /** Side and order of a docked placement; the extent it was seeded with is the floating size, not the point. */ + private fun assertDockedAt( + side: DockSide, + order: Int, + placement: SatellitePlacement, + message: String? = null, + ) { + val docked = assertIs(placement, message) + assertEquals(side, docked.side, message) + assertEquals(order, docked.order, message) + } + private val a = TaoWindow(handle = 1L) private val b = TaoWindow(handle = 2L) @@ -101,23 +113,6 @@ class SatelliteWorkspaceTest { assertEquals(DockSide.Right, entry.preferredDockSide) } - @Test - fun `dock order appends after the panels already on that side`() { - val workspace = SatelliteWorkspace() - workspace.join(a) - workspace.register("one", "One", floatingRight, initiallyOpen = true) - workspace.register("two", "Two", floatingRight, initiallyOpen = true) - workspace.register("three", "Three", floatingRight, initiallyOpen = true) - - workspace.dock("one", DockSide.Left) - workspace.dock("two", DockSide.Left) - workspace.dock("three", DockSide.Left, order = -5) - - assertEquals(0, (workspace.satellite("one")!!.placement as SatellitePlacement.Docked).order) - assertEquals(1, (workspace.satellite("two")!!.placement as SatellitePlacement.Docked).order) - assertEquals(-5, (workspace.satellite("three")!!.placement as SatellitePlacement.Docked).order) - } - @Test fun `undock without host geometry returns to the last floating placement`() { val workspace = SatelliteWorkspace() @@ -225,7 +220,7 @@ class SatelliteWorkspaceTest { val tools = target.register("tools", "Tools", floatingRight, initiallyOpen = true) val restoredColors = target.register("colors", "Colors", floatingRight, initiallyOpen = true) - assertEquals(SatellitePlacement.Docked(DockSide.Left, 0), tools.placement) + assertDockedAt(DockSide.Left, 0, tools.placement) assertSame(b, tools.dockHost) assertEquals(333.dp, target.dockExtent(DockSide.Left)) assertFalse(restoredColors.isOpen) @@ -293,7 +288,7 @@ class SatelliteWorkspaceTest { session.end(Offset(880f, 400f)) assertNull(workspace.dockPreview) assertNull(workspace.draggedSatellite, "the hints must go away when the drag ends") - assertEquals(SatellitePlacement.Docked(DockSide.Right, 0), entry.placement) + assertDockedAt(DockSide.Right, 0, entry.placement) assertSame(a, entry.dockHost) } @@ -346,14 +341,14 @@ class SatelliteWorkspaceTest { var session = requireNotNull(workspace.beginDrag("tools", panelOrigin, Offset(150f, 200f))) session.update(Offset(160f, 300f)) session.end(Offset(160f, 300f)) - assertEquals(SatellitePlacement.Docked(DockSide.Left, 0), entry.placement, "released inside its own panel") + assertDockedAt(DockSide.Left, 0, entry.placement, "released inside its own panel") session = requireNotNull(workspace.beginDrag("tools", panelOrigin, Offset(150f, 200f))) assertSame(entry, workspace.draggedSatellite) session.update(Offset(500f, 690f)) assertEquals(DockTarget(a, DockSide.Bottom), workspace.dockPreview) session.end(Offset(500f, 690f)) - assertEquals(SatellitePlacement.Docked(DockSide.Bottom, 0), entry.placement) + assertDockedAt(DockSide.Bottom, 0, entry.placement) assertSame(a, entry.dockHost) assertNull(workspace.dockPreview) assertNull(workspace.draggedSatellite) @@ -375,7 +370,7 @@ class SatelliteWorkspaceTest { assertNull(workspace.draggedSatellite) assertNull(workspace.dockPreview) assertNull(workspace.dragGhost) - assertEquals(SatellitePlacement.Docked(DockSide.Left, 0), entry.placement) + assertDockedAt(DockSide.Left, 0, entry.placement) } // ── Adversarial drags: teleporting pointers, overlapping gestures, @@ -404,7 +399,7 @@ class SatelliteWorkspaceTest { assertEquals(DockTarget(a, DockSide.Bottom), workspace.dockPreview) session.end(Offset(880f, 400f)) - assertEquals(SatellitePlacement.Docked(DockSide.Right, 0), entry.placement) + assertDockedAt(DockSide.Right, 0, entry.placement) assertNull(workspace.draggedSatellite) // Every jump moved the window, and none of them overflowed. assertTrue(moves.all { (x, y) -> x in -1_000_000..1_000_000 && y in -1_000_000..1_000_000 }, "moves=$moves") @@ -437,10 +432,7 @@ class SatelliteWorkspaceTest { // A release carrying garbage still drops where the pointer last was. session.end(Offset.Unspecified) - assertEquals( - SatellitePlacement.Docked(DockSide.Right, 0), - requireNotNull(workspace.satellite("tools")).placement, - ) + assertDockedAt(DockSide.Right, 0, requireNotNull(workspace.satellite("tools")).placement) } @Test @@ -470,7 +462,7 @@ class SatelliteWorkspaceTest { live.update(Offset(880f, 400f)) assertEquals(DockTarget(a, DockSide.Right), workspace.dockPreview) live.end(Offset(880f, 400f)) - assertEquals(SatellitePlacement.Docked(DockSide.Right, 0), colors.placement) + assertDockedAt(DockSide.Right, 0, colors.placement) assertNull(workspace.draggedSatellite) } @@ -483,7 +475,7 @@ class SatelliteWorkspaceTest { session.end(Offset(880f, 400f)) val docked = entry.placement - assertEquals(SatellitePlacement.Docked(DockSide.Right, 0), docked) + assertDockedAt(DockSide.Right, 0, docked) // A duplicated release (a replayed event, a second finally block) must // not re-dock, re-order or resurrect the feedback. @@ -594,7 +586,7 @@ class SatelliteWorkspaceTest { // No accumulated order drift: it is still the only panel on its side. workspace.dock("tools", DockSide.Right) - assertEquals(SatellitePlacement.Docked(DockSide.Right, 0), entry.placement) + assertDockedAt(DockSide.Right, 0, entry.placement) assertNull(workspace.draggedSatellite, "churn must not leave a drag behind") } @@ -618,8 +610,8 @@ class SatelliteWorkspaceTest { workspace.dock("tools", DockSide.Left) workspace.dock("colors", DockSide.Left) - assertEquals(SatellitePlacement.Docked(DockSide.Left, 0), tools.placement) - assertEquals(SatellitePlacement.Docked(DockSide.Left, 1), colors.placement) + assertDockedAt(DockSide.Left, 0, tools.placement) + assertDockedAt(DockSide.Left, 1, colors.placement) assertNull(workspace.draggedSatellite) assertNull(workspace.dragGhost) } @@ -638,7 +630,7 @@ class SatelliteWorkspaceTest { session.update(Offset(500f, 400f)) workspace.undock("tools") workspace.restore(snapshot) - assertEquals(SatellitePlacement.Docked(DockSide.Left, 0), entry.placement) + assertDockedAt(DockSide.Left, 0, entry.placement) // The release reads the *current* placement, not the one the gesture // started from: released over the content, it tears the restored panel diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabHoverPreviewTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabHoverPreviewTest.kt new file mode 100644 index 000000000..43b7f015f --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabHoverPreviewTest.kt @@ -0,0 +1,196 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * The hover card of a tab strip, without a window: what the strip reports as + * the hovered tab, and where the card is placed against the tab's own slot. + * + * The headful suite covers the pointer actually travelling along a real strip; + * everything here is the state machine and the geometry behind it. + */ +class TabHoverPreviewTest { + private companion object { + /** Three placed tabs, left to right: "a" at 0..100, "b" at 100..200, "c" at 200..300. */ + val Slots = + listOf( + Rect(0f, 0f, 100f, 40f), + Rect(100f, 0f, 200f, 40f), + Rect(200f, 0f, 300f, 40f), + ) + val WindowSize = IntSize(width = 800, height = 600) + val Below = IntOffset(x = 0, y = 4) + val CardSize = IntSize(width = 260, height = 150) + } + + private fun strip(): TabStripScope { + val workspace = TabWorkspace() + for (id in listOf("a", "b", "c")) workspace.register(id, id.uppercase(), groupId = null) + val group = requireNotNull(workspace.groups.firstOrNull()) + group.slotsInWindowPx = Slots + // Said out loud rather than inherited from the declaration order — a + // tab is only hoverable while it is not the one being read, so which + // one is selected decides what every case below may hover. + workspace.select("a") + return TabStripScopeImpl(workspace, group) + } + + @Test + fun `the strip reports the tab the pointer rests on, and nothing once it leaves`() { + val strip = strip() + + strip.group.noteHoverEnter("b") + assertSame(strip.workspace.tab("b"), strip.hoveredTab, "the hovered tab is the one entered") + + // Another tab's exit is not this one's: the pointer crossing a + // neighbour on its way out must not put the card away. + strip.group.noteHoverExit("c") + assertSame(strip.workspace.tab("b"), strip.hoveredTab, "a neighbour's exit took the hover with it") + + strip.group.noteHoverExit("b") + assertNull(strip.hoveredTab, "the hover outlived the pointer") + } + + @Test + fun `a press puts the card away until the pointer has been elsewhere`() { + val strip = strip() + strip.group.noteHoverEnter("b") + strip.group.noteHoverPress("b") + + assertNull(strip.hoveredTab, "a card stayed under a tab being clicked") + + // Moving on to another tab is a new hover, and a browser shows its card. + strip.group.noteHoverEnter("c") + assertSame(strip.workspace.tab("c"), strip.hoveredTab, "the click blocked the next tab's card too") + } + + @Test + fun `a press on a tab the pointer is not on changes nothing`() { + val strip = strip() + strip.group.noteHoverEnter("b") + + strip.group.noteHoverPress("c") + + assertSame(strip.workspace.tab("b"), strip.hoveredTab, "a press elsewhere took this tab's card") + } + + @Test + fun `no card while a tab is being dragged`() { + val strip = strip() + strip.group.noteHoverEnter("b") + + // Carrying a tab passes it over its neighbours; every one of them is + // hovered on the way, and none of them is being pointed at. + strip.workspace.draggedTab = strip.workspace.tab("a") + assertNull(strip.hoveredTab, "a card followed a tab being carried") + + strip.workspace.draggedTab = null + assertSame(strip.workspace.tab("b"), strip.hoveredTab, "the hover did not come back after the drag") + } + + @Test + fun `a tab that has left the group is no longer hovered`() { + val strip = strip() + strip.group.noteHoverEnter("b") + + strip.workspace.close("b") + + assertNull(strip.hoveredTab, "a closed tab kept the hover, and its card an anchor") + } + + @Test + fun `the anchor of a card is the tab's own slot, and nothing before it is placed`() { + val strip = strip() + + assertEquals(Slots[1], strip.group.slotInWindowPx("b"), "the slot of a placed tab") + assertNull(strip.group.slotInWindowPx("nobody"), "an unknown tab has no slot") + + val unplaced = TabWorkspace() + unplaced.register("a", "A", groupId = null) + val fresh = requireNotNull(unplaced.groups.firstOrNull()) + assertNull(fresh.slotInWindowPx("a"), "a tab the strip has not placed yet has no anchor") + } + + @Test + fun `the card hangs from the tab's leading edge, below it`() { + val position = TabHoverPreviewPosition(anchorPx = Slots[1], offsetPx = Below) + + val at = + position.calculatePosition( + anchorBounds = IntRect.Zero, + windowSize = WindowSize, + layoutDirection = LayoutDirection.Ltr, + popupContentSize = CardSize, + ) + + assertEquals(IntOffset(x = 100, y = 44), at, "the card is not under the left edge of its tab") + } + + @Test + fun `a right-to-left strip hangs the card from the tab's right edge`() { + // The third slot, 200..300: a card mirrored off the second one would + // start at -60 and be slid back to 0 by the clamp, which is the same + // number a left-aligned card at the window's edge gives — it would + // pass whether the mirroring worked or not. + val position = TabHoverPreviewPosition(anchorPx = Slots[2], offsetPx = Below) + + val at = + position.calculatePosition( + anchorBounds = IntRect.Zero, + windowSize = WindowSize, + layoutDirection = LayoutDirection.Rtl, + popupContentSize = CardSize, + ) + + // The card grows into the reading direction: its right edge on the + // tab's right edge, so it runs leftwards under the tabs that follow. + assertEquals(IntOffset(x = 300 - CardSize.width, y = 44), at, "the card was not mirrored") + } + + @Test + fun `the selected tab has no card`() { + val strip = strip() + strip.group.noteHoverEnter("a") + + assertNull(strip.hoveredTab, "a card was offered for the tab already on screen") + + // Selecting another one leaves this tab off screen, and a card of it + // is worth something again — without the pointer having moved. + strip.workspace.select("b") + assertSame(strip.workspace.tab("a"), strip.hoveredTab, "the tab left behind never got its card") + } + + @Test + fun `a card that would run off the window is slid back in`() { + val nearTheEdge = Rect(700f, 0f, 800f, 40f) + val position = TabHoverPreviewPosition(anchorPx = nearTheEdge, offsetPx = Below) + + val at = + position.calculatePosition( + anchorBounds = IntRect.Zero, + windowSize = WindowSize, + layoutDirection = LayoutDirection.Ltr, + popupContentSize = CardSize, + ) + + assertEquals(IntOffset(x = WindowSize.width - CardSize.width, y = 44), at, "the card hung off the window") + + // And a card wider than the window keeps its leading edge visible. + val wider = + position.calculatePosition( + anchorBounds = IntRect.Zero, + windowSize = IntSize(width = 200, height = 600), + layoutDirection = LayoutDirection.Ltr, + popupContentSize = CardSize, + ) + assertEquals(IntOffset(x = 0, y = 44), wider, "a card wider than the window lost its start") + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabWorkspaceTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabWorkspaceTest.kt index a910e78cc..04368e0c5 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabWorkspaceTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabWorkspaceTest.kt @@ -36,6 +36,32 @@ class TabWorkspaceTest { // ── Declaration and placement ──────────────────────────────────────── + @Test + fun `a right-to-left strip resolves its insertion indices from the right`() { + val workspace = TabWorkspace() + val group = workspace.rtlStrip() + + // Slots run from high x to low: "a" is the rightmost tab. + // Right of every midpoint is the first place; left of every one, the last. + assertEquals(0, workspace.insertionIndex(group, 295f, exclude = null)) + assertEquals(1, workspace.insertionIndex(group, 205f, exclude = null)) + assertEquals(2, workspace.insertionIndex(group, 105f, exclude = null)) + assertEquals(3, workspace.insertionIndex(group, 5f, exclude = null)) + // The dragged tab's own slot is not counted, so the index it would land + // at is the one it already has. + assertEquals(0, workspace.insertionIndex(group, 295f, exclude = workspace.tab("a"))) + assertEquals(1, workspace.insertionIndex(group, 105f, exclude = workspace.tab("b"))) + } + + /** Three placed tabs, laid out right to left: "a" at 200..300, "b" at 100..200, "c" at 0..100. */ + private fun TabWorkspace.rtlStrip(): TabWindowGroup { + for (id in listOf("a", "b", "c")) register(id, id.uppercase(), groupId = null) + val group = requireNotNull(groups.firstOrNull()) + group.slotsInWindowPx = + listOf(Rect(200f, 0f, 300f, 40f), Rect(100f, 0f, 200f, 40f), Rect(0f, 0f, 100f, 40f)) + return group + } + @Test fun `the first tab opens a window and the next ones join it`() { val workspace = TabWorkspace() @@ -307,6 +333,42 @@ class TabWorkspaceTest { group.slotsInWindowPx = List(tabCount) { index -> Rect(index * 100f, 0f, (index + 1) * 100f, 40f) } } + @Test + fun `the card entering a strip is a drop before the pointer reaches it`() { + val workspace = TabWorkspace() + val (left, right) = workspace.twoStripWindows() + + // Pointer below the right strip (which spans y 0..40), the card it + // carries reaching up into it: the drop is previewed already. + val pointer = Offset(1020f, 60f) + val card = Rect(1020f, 20f, 1120f, 60f) + assertNull(workspace.dropTargetAt(pointer), "the pointer alone is below the strip") + assertEquals(TabDropTarget(right, 0), workspace.dropTargetAt(card, pointer)) + + // The pointer still wins where both answer: it is in the left strip + // while the card overlaps the right one. + assertEquals( + TabDropTarget(left, 1), + workspace.dropTargetAt(Rect(1020f, 0f, 1120f, 40f), Offset(80f, 20f)), + ) + + // Clear of every strip, card included: no drop. + assertNull(workspace.dropTargetAt(Rect(400f, 300f, 500f, 340f), Offset(400f, 340f))) + } + + @Test + fun `a dragged window's own strip never answers for the card either`() { + val workspace = TabWorkspace() + val (left, right) = workspace.twoStripWindows() + + // The card is the dragged window's own strip, laid over the other's: + // its own group is skipped and the search carries on to the one below. + assertEquals( + TabDropTarget(right, 0), + workspace.dropTargetAt(Rect(1000f, 0f, 1800f, 40f), Offset(1020f, 20f), excludeGroup = left), + ) + } + @Test fun `a drop resolves to the strip under the pointer and the index it falls at`() { val workspace = TabWorkspace() @@ -428,6 +490,46 @@ class TabWorkspaceTest { moves: MutableList> = mutableListOf(), ) = TabDragOrigin.Strip(window, outerBoundsPx = { frame }, move = { x, y -> moves += x to y }) + /** + * The grip covers the whole tab and claims the press before the tab's own + * click gesture, so a click whose pointer drifts past the touch slop + * becomes a drag. Ending it where it started must therefore still leave + * the tab selected — otherwise that click did nothing at all, which is how + * a strip comes to feel like it swallows clicks. + */ + @Test + fun `a drag selects the tab it lifted, so a click that drifts is never lost`() { + val workspace = TabWorkspace() + val (left, _) = workspace.twoStripWindows() + workspace.select("a") + assertEquals("a", left.selectedId, "the tab the drift starts from is not the selected one") + + val session = + assertNotNull( + workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f)), + ) + + assertEquals("b", left.selectedId, "lifting a tab did not select it") + + // Released where it was grabbed: nothing moves, and the selection the + // lift made stands. + session.end(Offset(110f, 20f)) + assertEquals(listOf("a", "b"), left.ids, "a drag that went nowhere reordered the strip") + assertEquals("b", left.selectedId, "the selection was undone by the release") + } + + /** The same, for the local strip gesture a window without screen placement uses. */ + @Test + fun `taking a tab in hand inside its own strip selects it too`() { + val workspace = TabWorkspace() + val (left, _) = workspace.twoStripWindows() + workspace.select("a") + + assertNotNull(workspace.takeInStrip("b")) + + assertEquals("b", left.selectedId, "the local strip gesture left the click lost") + } + @Test fun `dragging one of several tabs shows a ghost and inserts where it is dropped`() { val workspace = TabWorkspace() diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index eea76d82a..f9dc8b540 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt @@ -651,6 +651,111 @@ public object TaoSceneTestBattery { WindowPositionerTest().`an unconstrained placement is returned untouched by every adjustment`() } + run( + "SatelliteDockedGeometryTest: docking from a floating window brings its size along as the panel extent", + ) { + SatelliteDockedGeometryTest() + .`docking from a floating window brings its size along as the panel extent`() + } + run( + "SatelliteDockedGeometryTest: re-docking keeps the extent along the same axis and re-seeds it across axes", + ) { + SatelliteDockedGeometryTest() + .`re-docking keeps the extent along the same axis and re-seeds it across axes`() + } + run( + "SatelliteDockedGeometryTest: docked extent and weight are clamped and ignored for a floating satellite", + ) { + SatelliteDockedGeometryTest().`docked extent and weight are clamped and ignored for a floating satellite`() + } + run("SatelliteDockedGeometryTest: a panel moved between docks seeds its new side with the width it had") { + SatelliteDockedGeometryTest().`a panel moved between docks seeds its new side with the width it had`() + } + run("SatelliteDockedGeometryTest: a snapshot carries every panel's own extent and weight") { + SatelliteDockedGeometryTest().`a snapshot carries every panel's own extent and weight`() + } + run("SatelliteDockedGeometryTest: a docked placement refuses a weight that is not positive") { + SatelliteDockedGeometryTest().`a docked placement refuses a weight that is not positive`() + } + run("SatelliteDockedGeometryTest: every side has an opposite across the content") { + SatelliteDockedGeometryTest().`every side has an opposite across the content`() + } + run("DockLandingRectTest: a bottom preview spans the bottom band, not the layout") { + DockLandingRectTest().`a bottom preview spans the bottom band, not the layout`() + } + run("DockLandingRectTest: a layered side previews a new innermost layer") { + DockLandingRectTest().`a layered side previews a new innermost layer`() + } + run("DockLandingRectTest: a split side with a stack previews the stack the panel joins") { + DockLandingRectTest().`a split side with a stack previews the stack the panel joins`() + } + run("DockLandingRectTest: an empty side previews a strip at the edge of its band") { + DockLandingRectTest().`an empty side previews a strip at the edge of its band`() + } + run("DockLandingRectTest: the side the dragged panel frees is counted as already gone") { + DockLandingRectTest().`the side the dragged panel frees is counted as already gone`() + } + run("DockLandingRectTest: a side the dragged panel shares with another is not freed") { + DockLandingRectTest().`a side the dragged panel shares with another is not freed`() + } + run("DockLandingRectTest: without a measured band the layout itself is the band") { + DockLandingRectTest().`without a measured band the layout itself is the band`() + } + run("DockZoneHintSidesTest: a floating satellite is offered every side") { + DockZoneHintSidesTest().`a floating satellite is offered every side`() + } + run("DockZoneHintSidesTest: a docked panel is not offered the side it is alone on") { + DockZoneHintSidesTest().`a docked panel is not offered the side it is alone on`() + } + run("DockZoneHintSidesTest: a docked panel with a neighbour is offered its own side, to be ranked among them") { + DockZoneHintSidesTest().`a docked panel with a neighbour is offered its own side, to be ranked among them`() + } + run( + "DockDropSlotsTest: a layered side is cut at the layers' centres, from its edge through the strip", + ) { + DockDropSlotsTest() + .`a layered side is cut at the layers' centres, from its edge through the strip`() + } + run("DockDropSlotsTest: a split side is cut along its length, from the band's start") { + DockDropSlotsTest().`a split side is cut along its length, from the band's start`() + } + run("DockDropSlotsTest: no slots without another panel, or before it is placed") { + DockDropSlotsTest().`no slots without another panel, or before it is placed`() + } + run("DockDropSlotsTest: the pointer picks the slot it is in, else the nearest end") { + DockDropSlotsTest().`the pointer picks the slot it is in, else the nearest end`() + } + run("DockDropSlotsTest: a pinned layer hides the ranks in front of it, for itself and for the others") { + DockDropSlotsTest().`a pinned layer hides the ranks in front of it, for itself and for the others`() + } + run("DockDropSlotsTest: a layer dropped at a rank is drawn where that rank puts it, at its own extent") { + DockDropSlotsTest().`a layer dropped at a rank is drawn where that rank puts it, at its own extent`() + } + run("DockDropSlotsTest: a panel dropped in a split stack is drawn as the share the weights give it") { + DockDropSlotsTest().`a panel dropped in a split stack is drawn as the share the weights give it`() + } + run("DockDropSlotsTest: dropped on an empty side, the space is the strip along its edge") { + DockDropSlotsTest().`dropped on an empty side, the space is the strip along its edge`() + } + run("DockZoneHintSidesTest: another window offers the side too, since dropping there is a move") { + DockZoneHintSidesTest().`another window offers the side too, since dropping there is a move`() + } + run("DockTargetFromDraggedRectTest: the dragged rect decides the zone, not the pointer") { + DockTargetFromDraggedRectTest().`the dragged rect decides the zone, not the pointer`() + } + run("DockTargetFromDraggedRectTest: an inset zone is the target, not the window's own edge") { + DockTargetFromDraggedRectTest().`an inset zone is the target, not the window's own edge`() + } + run( + "DockTargetFromDraggedRectTest: the pointer over a stack picks a rank, and beats a strip across its corner", + ) { + DockTargetFromDraggedRectTest() + .`the pointer over a stack picks a rank, and beats a strip across its corner`() + } + run("DockTargetFromDraggedRectTest: a dragged rect covering every zone is resolved by the pointer") { + DockTargetFromDraggedRectTest().`a dragged rect covering every zone is resolved by the pointer`() + } + run("SatelliteWorkspaceTest: the first member to join owns the satellites until focus moves") { SatelliteWorkspaceTest().`the first member to join owns the satellites until focus moves`() } @@ -663,8 +768,77 @@ public object TaoSceneTestBattery { run("SatelliteWorkspaceTest: docking a floating satellite seeds the side extent and hosts it in the owner") { SatelliteWorkspaceTest().`docking a floating satellite seeds the side extent and hosts it in the owner`() } - run("SatelliteWorkspaceTest: dock order appends after the panels already on that side") { - SatelliteWorkspaceTest().`dock order appends after the panels already on that side`() + run("SatelliteDragKindTest: a pointer drag is carried by the window, and the kind clears with it") { + SatelliteDragKindTest().`a pointer drag is carried by the window, and the kind clears with it`() + } + run("SatelliteDragKindTest: a transfer drag is carried by the platform session, and publishes no ghost") { + SatelliteDragKindTest().`a transfer drag is carried by the platform session, and publishes no ghost`() + } + run("SatelliteDragKindTest: a window that is not a native Wayland surface places on screen") { + SatelliteDragKindTest().`a window that is not a native Wayland surface places on screen`() + } + run("SatelliteFixedPanelTest: undock refuses a fixed panel") { + SatelliteFixedPanelTest().`undock refuses a fixed panel`() + } + run("SatelliteFixedPanelTest: a fixed satellite must be declared docked") { + SatelliteFixedPanelTest().`a fixed satellite must be declared docked`() + } + run("SatelliteFixedPanelTest: a drag released over the content leaves a fixed panel docked, with no ghost") { + SatelliteFixedPanelTest().`a drag released over the content leaves a fixed panel docked, with no ghost`() + } + run("SatelliteFixedPanelTest: a transfer drag with no record leaves a fixed panel docked") { + SatelliteFixedPanelTest().`a transfer drag with no record leaves a fixed panel docked`() + } + run("SatelliteFixedPanelTest: a snapshot that floats a fixed panel is ignored, but its open state is not") { + SatelliteFixedPanelTest().`a snapshot that floats a fixed panel is ignored, but its open state is not`() + } + run( + "SatelliteFixedPanelTest: a pinned panel is offered no rank and its drag changes nothing", + ) { + SatelliteFixedPanelTest() + .`a pinned panel is offered no rank and its drag changes nothing`() + } + run("SatelliteFixedPanelTest: another panel is docked after the pinned ones, whatever rank it asks for") { + SatelliteFixedPanelTest().`another panel is docked after the pinned ones, whatever rank it asks for`() + } + run("SatelliteFixedPanelTest: a panel that can go nowhere is no drag handle") { + SatelliteFixedPanelTest().`a panel that can go nowhere is no drag handle`() + } + run("SatelliteDockSidesTest: dock refuses a side the satellite was not declared for") { + SatelliteDockSidesTest().`dock refuses a side the satellite was not declared for`() + } + run( + "SatelliteDockSidesTest: floating-only never docks, the preferred side follows the declaration", + ) { + SatelliteDockSidesTest() + .`floating-only never docks, the preferred side follows the declaration`() + } + run("SatelliteDockSidesTest: a declared docked placement must name an allowed side") { + SatelliteDockSidesTest().`a declared docked placement must name an allowed side`() + } + run( + "SatelliteDockSidesTest: a refused side is not hinted nor previewed, a release there keeps it floating", + ) { + SatelliteDockSidesTest() + .`a refused side is not hinted nor previewed, a release there keeps it floating`() + } + run("SatelliteDockSidesTest: a snapshot naming a refused side leaves the placement alone") { + SatelliteDockSidesTest().`a snapshot naming a refused side leaves the placement alone`() + } + run("SatelliteDockRankTest: dock order inserts at that rank and keeps the side contiguous") { + SatelliteDockRankTest().`dock order inserts at that rank and keeps the side contiguous`() + } + run("SatelliteDockRankTest: a satellite docked again on the side it left returns to its rank") { + SatelliteDockRankTest().`a satellite docked again on the side it left returns to its rank`() + } + run( + "SatelliteDockRankTest: a satellite new to a side is appended there and keeps its rank elsewhere", + ) { + SatelliteDockRankTest() + .`a satellite new to a side is appended there and keeps its rank elsewhere`() + } + run("SatelliteDockRankTest: a closed panel keeps its rank and the weight comes back with it") { + SatelliteDockRankTest().`a closed panel keeps its rank and the weight comes back with it`() } run("SatelliteWorkspaceTest: undock without host geometry returns to the last floating placement") { SatelliteWorkspaceTest().`undock without host geometry returns to the last floating placement`() @@ -696,6 +870,9 @@ public object TaoSceneTestBattery { run("SatelliteWorkspaceTest: a docked drag released in another zone re-docks and inside its own panel stays") { SatelliteWorkspaceTest().`a docked drag released in another zone re-docks and inside its own panel stays`() } + run("SatelliteDockRankTest: a docked drag dropped on its own stack takes the rank under the pointer") { + SatelliteDockRankTest().`a docked drag dropped on its own stack takes the rank under the pointer`() + } run("SatelliteWorkspaceTest: a cancelled drag leaves no feedback and no placement change") { SatelliteWorkspaceTest().`a cancelled drag leaves no feedback and no placement change`() } @@ -839,6 +1016,46 @@ public object TaoSceneTestBattery { TransferDragTest().`the hotspot follows the grab point into the reduced picture of a region`() } + run("TabHoverPreviewTest: the strip reports the tab the pointer rests on, and nothing once it leaves") { + TabHoverPreviewTest().`the strip reports the tab the pointer rests on, and nothing once it leaves`() + } + run("TabHoverPreviewTest: a press puts the card away until the pointer has been elsewhere") { + TabHoverPreviewTest().`a press puts the card away until the pointer has been elsewhere`() + } + run("TabHoverPreviewTest: a press on a tab the pointer is not on changes nothing") { + TabHoverPreviewTest().`a press on a tab the pointer is not on changes nothing`() + } + run("TabHoverPreviewTest: no card while a tab is being dragged") { + TabHoverPreviewTest().`no card while a tab is being dragged`() + } + run("TabHoverPreviewTest: a tab that has left the group is no longer hovered") { + TabHoverPreviewTest().`a tab that has left the group is no longer hovered`() + } + run("TabHoverPreviewTest: the anchor of a card is the tab's own slot, and nothing before it is placed") { + TabHoverPreviewTest().`the anchor of a card is the tab's own slot, and nothing before it is placed`() + } + run("TabHoverPreviewTest: the card hangs from the tab's leading edge, below it") { + TabHoverPreviewTest().`the card hangs from the tab's leading edge, below it`() + } + run("TabHoverPreviewTest: a right-to-left strip hangs the card from the tab's right edge") { + TabHoverPreviewTest().`a right-to-left strip hangs the card from the tab's right edge`() + } + run("TabHoverPreviewTest: a card that would run off the window is slid back in") { + TabHoverPreviewTest().`a card that would run off the window is slid back in`() + } + run("TabHoverPreviewTest: the selected tab has no card") { + TabHoverPreviewTest().`the selected tab has no card`() + } + + run("TabWorkspaceTest: a drag selects the tab it lifted, so a click that drifts is never lost") { + TabWorkspaceTest().`a drag selects the tab it lifted, so a click that drifts is never lost`() + } + run("TabWorkspaceTest: taking a tab in hand inside its own strip selects it too") { + TabWorkspaceTest().`taking a tab in hand inside its own strip selects it too`() + } + run("TabWorkspaceTest: a right-to-left strip resolves its insertion indices from the right") { + TabWorkspaceTest().`a right-to-left strip resolves its insertion indices from the right`() + } run("TabWorkspaceTest: the first tab opens a window and the next ones join it") { TabWorkspaceTest().`the first tab opens a window and the next ones join it`() } @@ -884,6 +1101,12 @@ public object TaoSceneTestBattery { run("TabWorkspaceTest: tearing off an unknown tab changes nothing") { TabWorkspaceTest().`tearing off an unknown tab changes nothing`() } + run("TabWorkspaceTest: the card entering a strip is a drop before the pointer reaches it") { + TabWorkspaceTest().`the card entering a strip is a drop before the pointer reaches it`() + } + run("TabWorkspaceTest: a dragged window's own strip never answers for the card either") { + TabWorkspaceTest().`a dragged window's own strip never answers for the card either`() + } run("TabWorkspaceTest: a drop resolves to the strip under the pointer and the index it falls at") { TabWorkspaceTest().`a drop resolves to the strip under the pointer and the index it falls at`() } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt index d1b52a7df..447dd883f 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt @@ -91,12 +91,22 @@ class TaoSceneTestBatteryDriftTest { LcdTextTest::class.java, WindowPositionerTest::class.java, SatelliteWorkspaceTest::class.java, + SatelliteDockedGeometryTest::class.java, + DockLandingRectTest::class.java, + DockZoneHintSidesTest::class.java, + DockDropSlotsTest::class.java, + SatelliteDockRankTest::class.java, + SatelliteDockSidesTest::class.java, + SatelliteDragKindTest::class.java, + SatelliteFixedPanelTest::class.java, + DockTargetFromDraggedRectTest::class.java, RelocatingSaveableStateRegistryTest::class.java, WindowGroupTest::class.java, HostGeometryTest::class.java, DragControllerTest::class.java, TransferDragTest::class.java, TabWorkspaceTest::class.java, + TabHoverPreviewTest::class.java, ) /** Classes that must stay out of the battery, with the reason. */ diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutFixture.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutFixture.kt new file mode 100644 index 000000000..651661e6d --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutFixture.kt @@ -0,0 +1,340 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.ApplicationScope +import dev.nucleusframework.window.tao.DefaultDockSideOrder +import dev.nucleusframework.window.tao.DefaultDockSplitter +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.DockSplitterScope +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.LocalTaoWindow +import dev.nucleusframework.window.tao.Satellite +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteScope +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.TaoWindow +import kotlin.math.abs + +/** One satellite the fixture declares: its id and where it starts. */ +internal class DockPanelSpec( + val id: String, + val placement: SatellitePlacement, + val open: Boolean = true, + val dockSides: Set = DockSide.entries.toSet(), + val floatable: Boolean = true, + val reorderable: Boolean = true, +) + +/** + * A `DockLayout` under observation: every panel body, every splitter and the + * content publish their window-px bounds, their layout direction and how many + * times they were built, so a case can assert on geometry the way a user sees + * it and on composition identity the way a `remember` experiences it. + * + * The layout's shape — side order, layered sides, direction — is state, so a + * case can change it mid-run and check what survived. + */ +internal class DockLayoutFixture( + val specs: List, + sideOrder: List = DefaultDockSideOrder, + layeredSides: Set = emptySet(), + direction: LayoutDirection = LayoutDirection.Ltr, + /** Draw the splitter as a 1 dp line whose grip is a wider, overflowing box. */ + val gripOverflow: Boolean = false, +) { + val workspace = SatelliteWorkspace() + val sideOrder = mutableStateOf(sideOrder) + val layeredSides = mutableStateOf(layeredSides) + val direction = mutableStateOf(direction) + + /** Bounds of each panel's `panel` slot (header and body), in host window px. */ + val panelBounds = mutableStateOf>(emptyMap()) + + /** Bounds of each docked panel's body, in host window px. */ + val bodyBounds = mutableStateOf>(emptyMap()) + + /** Bounds of each splitter grip, keyed by [splitterKey], in host window px. */ + val splitterBounds = mutableStateOf>(emptyMap()) + + /** Bounds of the layout's content slot, in host window px. */ + val contentBounds = mutableStateOf(null) + val contentDirection = mutableStateOf(null) + val bodyDirections = mutableStateOf>(emptyMap()) + + /** What each satellite's chrome was told about its window: `isCompositorPlaced`, per host kind. */ + val compositorPlacedDocked = mutableStateOf>(emptyMap()) + val compositorPlacedFloating = mutableStateOf>(emptyMap()) + + /** Bounds of each satellite's `floatingCaption` slot, in its own window px; absent while not composed. */ + val captionBounds = mutableStateOf>(emptyMap()) + + /** The floating window of each satellite while it floats. */ + val floatingWindows = mutableStateOf>(emptyMap()) + + /** How many times each satellite's body was built, and how many are live right now. */ + val incarnations = mutableStateOf>(emptyMap()) + val liveBodies = mutableStateOf>(emptyMap()) + val contentIncarnations = mutableIntStateOf(0) + + private var nextMarker = 0 + + fun incarnationsOf(id: String): Int = incarnations.value[id] ?: 0 + + fun liveBodiesOf(id: String): Int = liveBodies.value[id] ?: 0 + + /** The `splitterBounds` key of a splitter: the panel it resizes, or the side it drags. */ + fun splitterKey(scope: DockSplitterScope): String = scope.panel?.let { "panel:${it.id}" } ?: "side:${scope.side}" + + fun splitterOf(id: String): Rect? = splitterBounds.value["panel:$id"] + + fun sideSplitterOf(side: DockSide): Rect? = splitterBounds.value["side:$side"] + + /** Window content: join the workspace, host the dock around a plain body. */ + @Composable + fun Body() { + JoinSatelliteWorkspace(workspace) + CompositionLocalProvider(LocalLayoutDirection provides direction.value) { + DockLayout( + workspace = workspace, + modifier = Modifier.fillMaxSize(), + sideOrder = sideOrder.value, + layeredSides = layeredSides.value, + splitter = { Splitter(this) }, + panel = { body -> + val id = satellite.id + DisposableEffect(id) { + onDispose { panelBounds.value = panelBounds.value - id } + } + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { + panelBounds.value = panelBounds.value + (id to it.boundsInWindow()) + }, + ) { body() } + }, + ) { + remember { contentIncarnations.value++ } + val here = LocalLayoutDirection.current + SideEffect { contentDirection.value = here } + Box( + Modifier + .fillMaxSize() + .background(Color.DarkGray) + .onGloballyPositioned { contentBounds.value = it.boundsInWindow() }, + ) + } + } + } + + @Composable + private fun Splitter(scope: DockSplitterScope) { + val key = splitterKey(scope) + DisposableEffect(key) { + onDispose { splitterBounds.value = splitterBounds.value - key } + } + val record = + Modifier.onGloballyPositioned { + splitterBounds.value = + splitterBounds.value + (key to it.boundsInWindow()) + } + with(scope) { + if (gripOverflow) { + val horizontal = orientation == Orientation.Horizontal + val line = + if (horizontal) { + Modifier.fillMaxHeight().width( + 1.dp, + ) + } else { + Modifier.fillMaxWidth().height(1.dp) + } + Box(line.background(Color.Red), contentAlignment = Alignment.Center) { + val grip = + if (horizontal) { + Modifier.requiredWidth(GRIP_OVERFLOW_DP.dp).fillMaxHeight() + } else { + Modifier.requiredHeight(GRIP_OVERFLOW_DP.dp).fillMaxWidth() + } + Box(grip.then(record).dockSplitterHandle()) + } + } else { + Box(record) { DefaultDockSplitter() } + } + } + } + + @Composable + fun ApplicationScope.Satellites() { + for (spec in specs) { + key(spec.id) { + Satellite( + workspace = workspace, + id = spec.id, + title = "Panel ${spec.id}", + initialPlacement = spec.placement, + initiallyOpen = spec.open, + floatingCaption = { + DisposableEffect(spec.id) { + onDispose { captionBounds.value = captionBounds.value - spec.id } + } + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { + captionBounds.value = captionBounds.value + (spec.id to it.boundsInWindow()) + }, + ) + }, + dockSides = spec.dockSides, + floatable = spec.floatable, + reorderable = spec.reorderable, + ) { PanelBody(spec.id) } + } + } + } + + /** + * A body that tells the case whether it is the same one as before: the + * marker is a plain `remember`, so it survives exactly as long as the + * subtree does. + */ + @Composable + private fun SatelliteScope.PanelBody(id: String) { + val marker = remember { nextMarker++ } + val window = LocalTaoWindow.current + val docked = isDocked + val here = LocalLayoutDirection.current + val placed = isCompositorPlaced + SideEffect { + if (docked) { + compositorPlacedDocked.value = compositorPlacedDocked.value + (id to placed) + } else { + compositorPlacedFloating.value = compositorPlacedFloating.value + (id to placed) + } + bodyDirections.value = bodyDirections.value + (id to here) + if (!docked && window != null) floatingWindows.value = floatingWindows.value + (id to window) + } + DisposableEffect(marker) { + incarnations.value = incarnations.value + (id to (incarnations.value[id] ?: 0) + 1) + liveBodies.value = liveBodies.value + (id to (liveBodies.value[id] ?: 0) + 1) + onDispose { + liveBodies.value = liveBodies.value + (id to (liveBodies.value[id] ?: 0) - 1) + if (!docked && floatingWindows.value[id] === window) floatingWindows.value = floatingWindows.value - id + if (docked) bodyBounds.value = bodyBounds.value - id + } + } + Box( + Modifier + .fillMaxSize() + .background(PANEL_COLORS[abs(id.hashCode()) % PANEL_COLORS.size]) + .onGloballyPositioned { if (docked) bodyBounds.value = bodyBounds.value + (id to it.boundsInWindow()) }, + ) + } +} + +/** Waits until every satellite in [ids] has a docked body with a real size in the case window. */ +internal suspend fun TaoWindowTestScope.awaitDockedBodies( + fixture: DockLayoutFixture, + vararg ids: String, +) { + awaitUntil("owner window mapped") { bounds() != null } + awaitUntil("panels ${ids.toList()} are docked with a size — have ${fixture.bodyBounds.value.keys}") { + ids.all { id -> + val rect = fixture.bodyBounds.value[id] + rect != null && rect.width > 0f && rect.height > 0f + } + } + awaitDockLayout(fixture.workspace, window) + settle() +} + +/** + * [awaitDockedBodies] without the screen half: waits for the bodies and for + * the layout's bounds *in the window*, which is all a native Wayland host can + * publish. + */ +internal suspend fun TaoWindowTestScope.awaitDockedBodiesInWindow( + fixture: DockLayoutFixture, + vararg ids: String, +) { + awaitUntil("owner window mapped") { bounds() != null } + awaitUntil("panels ${ids.toList()} are docked with a size — have ${fixture.bodyBounds.value.keys}") { + ids.all { id -> + val rect = fixture.bodyBounds.value[id] + rect != null && rect.width > 0f && rect.height > 0f + } + } + awaitUntil("dock layout of the host is measured in its window") { + fixture.workspace + .dockHostGeometry(window) + ?.layoutBoundsInWindowPx + ?.isEmpty == false + } + settle() +} + +/** Screen position (physical px) of a point given in the case window's content coordinates. */ +internal fun TaoWindowTestScope.toScreen( + fixture: DockLayoutFixture, + inWindowPx: Offset, +): Offset { + val client = requireNotNull(fixture.workspace.dockHostGeometry(window)?.clientOriginPx()) { "no client origin" } + return client + inWindowPx +} + +/** `true` when [a] and [b] share any area beyond a rounding line. */ +internal fun overlaps( + a: Rect, + b: Rect, +): Boolean = + a.left < b.right - LAYOUT_TOLERANCE_PX && + b.left < a.right - LAYOUT_TOLERANCE_PX && + a.top < b.bottom - LAYOUT_TOLERANCE_PX && + b.top < a.bottom - LAYOUT_TOLERANCE_PX + +internal fun near( + a: Float, + b: Float, + tolerance: Float = LAYOUT_TOLERANCE_PX, +): Boolean = abs(a - b) <= tolerance + +/** The grip's width around the 1 dp line, in dp. */ +internal const val GRIP_OVERFLOW_DP = 7 + +private val PANEL_COLORS = + listOf( + Color(0xFF2D6CDF), + Color(0xFF7A5CD6), + Color(0xFF2E9E6B), + Color(0xFFD97B2B), + Color(0xFFC94C6A), + ) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutHeadfulCases.kt new file mode 100644 index 000000000..aef6ebb87 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutHeadfulCases.kt @@ -0,0 +1,1587 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DefaultDockSideOrder +import dev.nucleusframework.window.tao.DockPanelHeaderHeight +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.DockTarget +import dev.nucleusframework.window.tao.SatelliteDragKind +import dev.nucleusframework.window.tao.SatelliteDragOrigin +import dev.nucleusframework.window.tao.SatelliteDragSession +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.hintedSides +import kotlin.math.abs + +/** + * Real-window coverage for the `DockLayout` arrangements: layered sides where + * every panel is a column of its own width, split sides where panels share a + * side by weight, the side order that decides who owns the corners, and the + * right-to-left layout that keeps its sides physical. + * + * 1. three panels on a layered right side sit side by side, each at its own + * width, with the default header strip sizing itself; + * 2. a layered panel's splitter, dragged with a real mouse, resizes that + * panel alone and the new extent lands in the snapshot; + * 3. two panels on a split side share it by weight, and the divider between + * them moves the weight from one to the other; + * 4. with the right side first in the order it runs the full height, the + * bottom panel stops at it and still runs under the left panel; + * 5. under a right-to-left direction the left side is the physical left, its + * splitter grows it rightwards, and the content and the panels see RTL; + * 6. no change of the layout — extents, weights, order, side, a restore, a + * new side order, a layered toggle, a direction flip — rebuilds a panel + * body or the content, and a floating satellite keeps its window through + * every restore; + * 7. a custom 1 dp splitter with a wider grip takes the drag aimed off the line; + * 8. a floating satellite dropped on a layered side becomes a layer of its + * window's width, next to the panel already there; + * 9. undocking a layer lifts the window off exactly where the layer was; + * 10. the drop preview follows the palette's own edge, not the pointer; + * 11. a layer floated and docked again without a rank comes back between the + * neighbours it left, on a layered and on a split side alike; + * 12. a layer dragged by its header over the outer half of the outermost + * layer previews the first rank and lands there, nothing rebuilt; + * 16. chrome is told how its window is placed, no caption strip is reserved + * where the app places its own windows, and a drag says how it is carried; + * 15. a fixed panel is never torn out — no ghost, no window, nothing + * rebuilt — nor displaced by a neighbour docking in front of it, while + * that neighbour is still torn out by the same gesture; + * 14. a palette declared for three sides is never offered the fourth: the + * top strip is neither hinted nor published, a release there leaves it + * floating, and a direct dock on that side is refused; + * 13. on a split side a panel dropped on its own rank stays, dropped on the + * first half of the first panel becomes the first, and the closed one in + * the middle keeps its rank. + * + * Every drag is a real mouse (AWT Robot) where the host can inject input, + * else the same change through the workspace — the geometry the layout then + * shows is asserted either way. Native Wayland is skipped as for every + * satellite case: no client-side screen placement to aim a pointer with. + */ +@Suppress("LargeClass") // one method per real-window case, by design +internal object DockLayoutHeadfulCases { + fun all(): List = + listOf( + layeredPanelsSitSideBySideWithTheirOwnWidths(), + aLayeredSplitterResizesItsPanelAlone(), + splitPanelsShareBySideWeightAndTheDividerMovesIt(), + theOuterSideOwnsTheCorners(), + rtlKeepsPhysicalSidesAndHandsTheDirectionBack(), + layoutChangesNeverRebuildAPanelOrTheContent(), + aOneDpSplitterWithAWiderGripTakesTheDrag(), + aDropOnALayeredSideAddsALayerOfTheWindowsWidth(), + undockingALayerLiftsTheWindowOffThePanel(), + thePaletteEdgeDecidesTheZoneNotThePointer(), + aPanelDockedAgainReturnsToTheRankItLeft(), + aLayerDraggedOverTheOutermostOneBecomesTheFirst(), + aSplitPanelDroppedOnItsStackTakesTheRankUnderThePointer(), + aPaletteIsNeverOfferedASideItWasNotDeclaredFor(), + aFixedPanelIsNeverTornOut(), + chromeIsToldHowTheWindowIsPlaced(), + ) + + // ── 16. what chrome is told about the two gestures ─────────────────── + + /** + * What chrome is told about the two gestures, where the app places its own + * windows: [SatelliteScope.isCompositorPlaced] is `false` for the panel and + * for the floating palette alike, the `floatingCaption` slot is not + * composed at all — the whole bar drags the satellite — and a pointer drag + * reports itself as [SatelliteDragKind.Window] with a ghost to match. + * + * The other half of the contract, on a compositor-placed window, is + * `WaylandWorkspaceHeadfulCases`. + */ + private fun chromeIsToldHowTheWindowIsPlaced(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = TREE_W_DP.dp)), + DockPanelSpec( + INSPECTOR, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ), + ), + ) + return TaoWindowTestCase( + name = "dock layout chrome is told the window places itself, and no caption strip is reserved", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodies(fixture, TREE) + awaitUntil("the inspector floats") { + fixture.floatingWindows.value[INSPECTOR]?.hasRealFramePx() == true + } + settle(SETTLE_AFTER_MAP_MILLIS) + val floating = requireNotNull(fixture.floatingWindows.value[INSPECTOR]) + + check(window.canPlaceOnScreen) { "the case window should place itself on this leg" } + check(floating.canPlaceOnScreen) { "the satellite window should place itself on this leg" } + check(fixture.compositorPlacedDocked.value[TREE] == false) { + "the panel was told the compositor places it: ${fixture.compositorPlacedDocked.value}" + } + check(fixture.compositorPlacedFloating.value[INSPECTOR] == false) { + "the palette was told the compositor places it: ${fixture.compositorPlacedFloating.value}" + } + check(fixture.captionBounds.value.isEmpty()) { + "a caption strip is reserved where nothing needs one: ${fixture.captionBounds.value}" + } + + // The drag reports how it is carried, and the ghost matches. + check(workspace.dragKind == null) { "a drag is reported before one starts" } + val outer = requireNotNull(floating.outerBoundsPx()) + val grab = Offset(outer[0] + outer[2] / 2f, outer[1] + HEADER_GRAB_Y_DP * floating.scaleFactor) + val palette = + requireNotNull(workspace.beginDrag(INSPECTOR, SatelliteDragOrigin.FloatingWindow(floating), grab)) + check(workspace.dragKind == SatelliteDragKind.Window) { + "the palette's own window carries the drag, but the kind is ${workspace.dragKind}" + } + palette.cancel() + check(workspace.dragKind == null) { "the kind outlived the drag" } + + val treeBounds = panel(fixture, TREE) + val panelGrab = + toScreen( + fixture, + Offset( + treeBounds.center.x, + treeBounds.top + DockPanelHeaderHeight.value * window.scaleFactor / 2f, + ), + ) + val panelDrag = beginDockedDrag(workspace, TREE, panelGrab) + panelDrag.update(panelGrab + Offset(0f, PANEL_DRAG_STEP_PX)) + check(workspace.dragKind == SatelliteDragKind.Window) { "the torn-out panel's ghost is a window" } + check(workspace.dragGhost?.satellite?.id == TREE) { "no ghost for a window-carried drag" } + panelDrag.cancel() + check(workspace.dragGhost == null && workspace.dragKind == null) { "feedback left behind" } + }, + ) + } + + // ── 15. a fixed panel ──────────────────────────────────────────────── + + /** + * A fixed panel ([floatable] `false`): dragged into the middle of the + * content and released, it is still the panel it was — no ghost followed + * the pointer, no window appeared, its subtree was never rebuilt — while + * the panel next to it, an ordinary one, is torn out by the same gesture. + */ + private fun aFixedPanelIsNeverTornOut(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec( + TREE, + SatellitePlacement.Docked(DockSide.Right, order = 0, extent = TREE_W_DP.dp), + dockSides = setOf(DockSide.Right), + floatable = false, + reorderable = false, + ), + DockPanelSpec(TOC, SatellitePlacement.Docked(DockSide.Right, order = 1, extent = TOC_W_DP.dp)), + ), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout a fixed panel is never torn out nor displaced, its neighbour still is", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodies(fixture, TREE, TOC) + val scale = window.scaleFactor + val layout = awaitDockLayout(workspace, window) + val client = requireNotNull(workspace.dockHostGeometry(window)?.clientOriginPx()) + val treeBefore = panel(fixture, TREE) + val tree = requireNotNull(workspace.satellite(TREE)) + check(!tree.isFloatable) { "the fixture did not declare the tree fixed" } + + // Into the middle of the content — a tear-out for any other panel. + val grab = + toScreen( + fixture, + Offset(treeBefore.center.x, treeBefore.top + DockPanelHeaderHeight.value * scale / 2f), + ) + // Deep in the content: clear of the left strip and well clear + // of the right side's ranks, which reach in behind its layers. + val middle = Offset(layout.left + CONTENT_AIM_DP * scale, layout.center.y) + // No wait for zones here: a pinned panel fixed to one side is + // offered none, which is the first thing to check. + val session = + requireNotNull(workspace.beginDrag(TREE, SatelliteDragOrigin.DockedPanel(window), grab)) + settle() + check(workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx.isNullOrEmpty()) { + "a zone is offered to a panel that can go nowhere: " + + "${workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx}" + } + session.update(middle) + check(workspace.dragGhost == null) { "a fixed panel published a tear-out ghost" } + check(workspace.dockPreview == null) { "the content previewed a zone: ${workspace.dockPreview}" } + session.end(middle) + settle(SETTLE_AFTER_MAP_MILLIS) + check( + tree.placement is SatellitePlacement.Docked, + ) { "the fixed panel left the dock: ${tree.placement}" } + check(fixture.floatingWindows.value[TREE] == null) { "the fixed panel opened a window of its own" } + check(fixture.incarnationsOf(TREE) == 1) { "the refused tear-out rebuilt the panel" } + check(near(panel(fixture, TREE).width, treeBefore.width)) { "the fixed panel changed width" } + + // A direct undock is refused as well. + workspace.undock(TREE) + settle() + check(tree.placement is SatellitePlacement.Docked) { "undock() tore out a fixed panel" } + + // Its rank is pinned: nothing offers it another one, and the + // panel next to it cannot be dropped in front of it. + check(hintedSides(tree, window, workspace.satellites).isEmpty()) { + "a pinned panel with one side is offered somewhere to go: " + + "${hintedSides(tree, window, workspace.satellites)}" + } + workspace.dock(TOC, DockSide.Right, order = 0) + settle() + check((workspace.satellite(TOC)?.placement as SatellitePlacement.Docked).order == 1) { + "the neighbour took the pinned panel's rank: ${workspace.satellite(TOC)?.placement}" + } + check((tree.placement as SatellitePlacement.Docked).order == 0) { + "the pinned panel lost its rank: ${tree.placement}" + } + awaitDockedBodies(fixture, TREE, TOC) + check(near(panel(fixture, TREE).right, layoutInWindowRight(layout, client), LAYOUT_TOLERANCE_PX * 2)) { + "the pinned panel is not still the outermost layer: ${panel(fixture, TREE)}" + } + + // The ordinary neighbour is torn out by the very same gesture. + val tocBefore = panel(fixture, TOC) + // Grabbed near its left edge, so its ghost hangs to the right + // of the pointer and stays clear of the left strip: the + // release is a tear-out, not a dock on the left. + val tocGrab = + toScreen( + fixture, + Offset( + tocBefore.left + GRAB_EDGE_INSET_DP * scale, + tocBefore.top + DockPanelHeaderHeight.value * scale / 2f, + ), + ) + val tocSession = beginDockedDrag(workspace, TOC, tocGrab) + tocSession.update(middle) + check(workspace.dragGhost?.satellite?.id == TOC) { "no ghost for the ordinary panel" } + check(workspace.dockPreview == null) { + "the ordinary panel is over a zone, so the release would not tear it out: ${workspace.dockPreview}" + } + tocSession.end(middle) + awaitUntil("the toc floats") { fixture.floatingWindows.value[TOC]?.hasRealFramePx() == true } + check(near(panel(fixture, TREE).right, layoutInWindowRight(layout, client), LAYOUT_TOLERANCE_PX * 2)) { + "the fixed panel is not still at the edge: ${panel(fixture, TREE)}" + } + }, + ) + } + + // ── 14. dockSides ──────────────────────────────────────────────────── + + /** + * A palette declared for three sides only: the top is neither hinted nor + * published as a zone, a direct `dock(Top)` is refused, a release with the + * palette's top edge in the top strip leaves it floating — and the left + * side, which it *was* declared for, still takes it. + * + * Nothing else is docked, so the only thing that could light up is an + * edge of the layout itself. + */ + private fun aPaletteIsNeverOfferedASideItWasNotDeclaredFor(): TaoWindowTestCase { + val notTop = setOf(DockSide.Left, DockSide.Right, DockSide.Bottom) + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec( + INSPECTOR, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + dockSides = notTop, + ), + ), + ) + return TaoWindowTestCase( + name = "dock layout a palette is never offered a side it was not declared for", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitUntil("owner window mapped") { bounds() != null } + awaitUntil("the inspector floats") { + fixture.floatingWindows.value[INSPECTOR]?.hasRealFramePx() == true + } + settle(SETTLE_AFTER_MAP_MILLIS) + val workspace = fixture.workspace + val inspector = requireNotNull(workspace.satellite(INSPECTOR)) + val floating = requireNotNull(fixture.floatingWindows.value[INSPECTOR]) + val layout = awaitDockLayout(workspace, window) + val zonePx = SatelliteWorkspace.DockZoneWidth.value * window.scaleFactor + check( + hintedSides(inspector, window, workspace.satellites) == + listOf(DockSide.Left, DockSide.Right, DockSide.Bottom), + ) { "the top is offered: ${hintedSides(inspector, window, workspace.satellites)}" } + + // A direct dock on the top is refused outright. + workspace.dock(INSPECTOR, DockSide.Top) + settle() + check(inspector.placement is SatellitePlacement.Floating) { + "dock(Top) was not refused: ${inspector.placement}" + } + + // Read live: the first release moves the window, so the second + // grab has to be taken where the palette is by then. + fun grabNow(): Pair { + val frame = requireNotNull(floating.outerBoundsPx()) + val inset = Offset(frame[2] / 2f, HEADER_GRAB_Y_DP * floating.scaleFactor) + return Offset(frame[0].toFloat(), frame[1].toFloat()) + inset to inset + } + val outer = requireNotNull(floating.outerBoundsPx()) + val paletteSize = Size(outer[2].toFloat(), outer[3].toFloat()) + val (grab, grabInset) = grabNow() + + // Top edge inside the top strip, the palette clear of the + // three sides it *may* dock on, so the top is the only edge it + // has reached and a preview could only come from there. + val paletteTopLeft = Offset(layout.center.x - paletteSize.width / 2f, layout.top + EDGE_INSET_PX) + val atTop = paletteTopLeft + grabInset + val session = + requireNotNull(workspace.beginDrag(INSPECTOR, SatelliteDragOrigin.FloatingWindow(floating), grab)) + awaitUntil("the layout published its drop zones") { + workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx?.isNotEmpty() == true + } + val zones = requireNotNull(workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx) + check(!zones.containsKey(DockSide.Top)) { "the top zone is published: $zones" } + check( + paletteTopLeft.x - layout.left > zonePx && + layout.right - (paletteTopLeft.x + paletteSize.width) > zonePx && + layout.bottom - (paletteTopLeft.y + paletteSize.height) > zonePx, + ) { "the palette also reaches a side it may dock on: layout=$layout palette=$paletteSize" } + session.update(atTop) + check(workspace.dockPreview == null) { + "a zone is previewed for a palette aimed at the top: ${workspace.dockPreview} — " + + "layout=$layout paletteTopLeft=$paletteTopLeft pointer=$atTop zones=$zones" + } + session.end(atTop) + settle() + check(inspector.placement is SatellitePlacement.Floating) { + "released on the top strip, the palette docked: ${inspector.placement}" + } + check(fixture.floatingWindows.value[INSPECTOR] != null) { "the floating window is gone" } + + // The left side, which it was declared for, still works. + val (grabAgain, insetAgain) = grabNow() + val atLeft = + Offset(layout.left + EDGE_INSET_PX, layout.center.y - paletteSize.height / 2f) + insetAgain + val second = + requireNotNull( + workspace.beginDrag(INSPECTOR, SatelliteDragOrigin.FloatingWindow(floating), grabAgain), + ) + // A new session starts with the zones of the last one cleared. + awaitUntil("the layout published its drop zones again") { + workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx?.isNotEmpty() == true + } + second.update(atLeft) + check(workspace.dockPreview == DockTarget(window, DockSide.Left)) { + "the left zone is not previewed: ${workspace.dockPreview} — " + + "layout=$layout pointer=$atLeft grab=$grabAgain " + + "frame=${floating.outerBoundsPx()?.toList()}" + } + second.end(atLeft) + awaitDockedBodies(fixture, INSPECTOR) + check(near(panel(fixture, INSPECTOR).left, 0f, LAYOUT_TOLERANCE_PX * 2)) { + "not docked on the left: ${panel(fixture, INSPECTOR)}" + } + }, + ) + } + + // ── 12. reorder a layered side by dragging ─────────────────────────── + + /** + * The innermost of three layers is dragged by its header — a real mouse + * where the host injects one, else the drag session it drives — until the + * pointer is over the outer half of the outermost layer. The first rank + * is previewed; released, the layer is the outermost column, at its own + * width, and no panel was rebuilt on the way. + */ + private fun aLayerDraggedOverTheOutermostOneBecomesTheFirst(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = layeredRightSpecs(), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout a layer dragged over the outermost one becomes the first, nothing rebuilt", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val scale = window.scaleFactor + val layout = awaitDockLayout(workspace, window) + // The panels are in window px, the layout rect in screen px. + val client = requireNotNull(workspace.dockHostGeometry(window)?.clientOriginPx()) + val layoutInWindow = layout.translate(-client) + val tree = panel(fixture, TREE) + val notesBefore = panel(fixture, NOTES) + // The header strip is the grip; a docked panel of another + // rank is offered its own side. + check( + hintedSides( + requireNotNull(workspace.satellite(NOTES)), + window, + workspace.satellites, + ).contains(DockSide.Right), + ) { + "a layer with neighbours is not offered its own side" + } + val grab = + toScreen( + fixture, + Offset( + notesBefore.center.x, + notesBefore.top + DockPanelHeaderHeight.value * scale / 2f, + ), + ) + // The outer half of the outermost layer: rank 0. + val target = toScreen(fixture, Offset(tree.left + tree.width * OUTER_HALF, layoutInWindow.center.y)) + val expected = DockTarget(window, DockSide.Right, 0) + + if (robotPressAndDrag(grab, target, scale) != null) { + awaitUntil("the first rank previews under the pointer — ${robotAim()}") { + workspace.dockPreview == + expected + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[dock-layout] robot unavailable, driving the drag session directly") + val session = beginDockedDrag(workspace, NOTES, grab) + session.update(target) + check( + workspace.dockPreview == expected, + ) { "expected $expected, previewed ${workspace.dockPreview}" } + session.end(target) + } + awaitUntil("the notes are the first rank") { + (workspace.satellite(NOTES)?.placement as? SatellitePlacement.Docked)?.order == 0 + } + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val notes = panel(fixture, NOTES) + val treeAfter = panel(fixture, TREE) + val toc = panel(fixture, TOC) + check( + near(notes.right, layoutInWindow.right, LAYOUT_TOLERANCE_PX * 2), + ) { "the notes are not at the edge: $notes vs $layoutInWindow" } + check( + near(treeAfter.right, notes.left, SPLITTER_TOLERANCE_PX) && + near(toc.right, treeAfter.left, SPLITTER_TOLERANCE_PX), + ) { + "the columns are not notes, tree, toc from the edge: notes=$notes tree=$treeAfter toc=$toc" + } + check( + near(notes.width, notesBefore.width), + ) { "the notes changed width: ${notesBefore.width} -> ${notes.width}" } + check( + fixture.incarnationsOf(TREE) == 1 && + fixture.incarnationsOf(TOC) == 1 && + fixture.incarnationsOf(NOTES) == 1, + ) { "a reorder rebuilt a panel: ${fixture.incarnations.value}" } + }, + ) + } + + // ── 13. reorder a split side, and stay on its own rank ────────────── + + private fun aSplitPanelDroppedOnItsStackTakesTheRankUnderThePointer(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TARGUM, SatellitePlacement.Docked(DockSide.Bottom, order = 0)), + DockPanelSpec(COMMENTS, SatellitePlacement.Docked(DockSide.Bottom, order = 1)), + DockPanelSpec(INSPECTOR, SatellitePlacement.Docked(DockSide.Bottom, order = 2)), + ), + ) + return TaoWindowTestCase( + name = "dock layout a split panel dropped on its stack takes the rank under the pointer or stays put", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodies(fixture, TARGUM, COMMENTS, INSPECTOR) + val scale = window.scaleFactor + val inspectorBefore = panel(fixture, INSPECTOR) + val targum = panel(fixture, TARGUM) + val grab = + toScreen( + fixture, + Offset( + inspectorBefore.center.x, + inspectorBefore.top + DockPanelHeaderHeight.value * scale / 2f, + ), + ) + + // Nudged within its own panel: its own rank is no target, and the release changes nothing. + var session = beginDockedDrag(workspace, INSPECTOR, grab) + val nudge = grab + Offset(OWN_NUDGE_PX, OWN_NUDGE_PX) + session.update(nudge) + check(workspace.dockPreview == null) { "its own rank is previewed: ${workspace.dockPreview}" } + session.end(nudge) + settle() + check((workspace.satellite(INSPECTOR)?.placement as SatellitePlacement.Docked).order == 2) { + "a release on its own rank moved the panel: ${workspace.satellite(INSPECTOR)?.placement}" + } + check( + fixture.floatingWindows.value[INSPECTOR] == null, + ) { "a release on its own rank undocked the panel" } + + // The left half of the first panel: the first rank. + val target = toScreen(fixture, Offset(targum.left + targum.width * (1f - OUTER_HALF), targum.center.y)) + session = beginDockedDrag(workspace, INSPECTOR, grab) + session.update(target) + check(workspace.dockPreview == DockTarget(window, DockSide.Bottom, 0)) { + "the first rank is not previewed: ${workspace.dockPreview}" + } + session.end(target) + awaitUntil("the inspector is the first rank") { + (workspace.satellite(INSPECTOR)?.placement as? SatellitePlacement.Docked)?.order == 0 + } + awaitDockedBodies(fixture, TARGUM, COMMENTS, INSPECTOR) + val inspector = panel(fixture, INSPECTOR) + val targumAfter = panel(fixture, TARGUM) + val comments = panel(fixture, COMMENTS) + check( + inspector.right <= targumAfter.left + LAYOUT_TOLERANCE_PX && + targumAfter.right <= comments.left + LAYOUT_TOLERANCE_PX, + ) { + "the row is not inspector, targum, comments: " + + "inspector=$inspector targum=$targumAfter comments=$comments" + } + check( + fixture.incarnationsOf(TARGUM) == 1 && fixture.incarnationsOf(COMMENTS) == 1, + ) { "a reorder rebuilt a neighbour" } + + // With the middle one closed, a drop on the shown neighbour's far half goes behind the closed one too. + workspace.close(TARGUM) + awaitDockedBodies(fixture, INSPECTOR, COMMENTS) + val commentsShown = panel(fixture, COMMENTS) + val farHalf = + toScreen( + fixture, + Offset(commentsShown.left + commentsShown.width * OUTER_HALF, commentsShown.center.y), + ) + session = beginDockedDrag(workspace, INSPECTOR, grab) + session.update(farHalf) + check(workspace.dockPreview == DockTarget(window, DockSide.Bottom, 1)) { + "the rank after the comments is not previewed: ${workspace.dockPreview}" + } + session.end(farHalf) + awaitUntil("the inspector is last") { + (workspace.satellite(INSPECTOR)?.placement as? SatellitePlacement.Docked)?.order == 2 + } + check((workspace.satellite(TARGUM)?.placement as SatellitePlacement.Docked).order == 0) { + "the closed targum lost its rank: ${workspace.satellite(TARGUM)?.placement}" + } + workspace.open(TARGUM) + awaitDockedBodies(fixture, TARGUM, COMMENTS, INSPECTOR) + val reopened = panel(fixture, TARGUM) + check(reopened.right <= panel(fixture, COMMENTS).left + LAYOUT_TOLERANCE_PX) { + "the reopened targum is not first: $reopened vs ${panel(fixture, COMMENTS)}" + } + }, + ) + } + + // ── 11. a re-dock returns to the rank ──────────────────────────────── + + /** + * The middle layer of three is floated, then docked again through the + * path a header button takes — a side and no rank. It comes back between + * the two it left, at its own width, and neither neighbour is rebuilt. + * The same on the bottom side, split: the panel that left the middle + * of the row is back in the middle of the row. + */ + private fun aPanelDockedAgainReturnsToTheRankItLeft(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + layeredRightSpecs() + + listOf( + DockPanelSpec(TARGUM, SatellitePlacement.Docked(DockSide.Bottom, order = 0)), + DockPanelSpec(COMMENTS, SatellitePlacement.Docked(DockSide.Bottom, order = 1)), + DockPanelSpec(INSPECTOR, SatellitePlacement.Docked(DockSide.Bottom, order = 2)), + ), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout a panel docked again without a rank returns between the neighbours it left", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodies(fixture, TREE, TOC, NOTES, TARGUM, COMMENTS, INSPECTOR) + val tocBefore = panel(fixture, TOC) + val commentsBefore = panel(fixture, COMMENTS) + + // Layered right side: the toc is the middle column. + workspace.undock(TOC) + awaitUntil("the toc floats") { fixture.floatingWindows.value[TOC]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + check(panel(fixture, NOTES).right > tocBefore.left + LAYOUT_TOLERANCE_PX) { + "the inner layer did not slide out while the toc floated: ${panel(fixture, NOTES)}" + } + workspace.dock(TOC, DockSide.Right) + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val tree = panel(fixture, TREE) + val toc = panel(fixture, TOC) + val notes = panel(fixture, NOTES) + // Between its neighbours, a splitter's width from each. + check( + near(toc.right, tree.left, SPLITTER_TOLERANCE_PX) && + near(notes.right, toc.left, SPLITTER_TOLERANCE_PX), + ) { + "the toc is not back between the tree and the notes: tree=$tree toc=$toc notes=$notes" + } + check( + near(toc.width, tocBefore.width), + ) { "the toc came back at ${toc.width} px, was ${tocBefore.width}" } + check((workspace.satellite(TOC)?.placement as SatellitePlacement.Docked).order == 1) { + "the toc's rank is not 1: ${workspace.satellite(TOC)?.placement}" + } + check(fixture.incarnationsOf(TREE) == 1 && fixture.incarnationsOf(NOTES) == 1) { + "a neighbour was rebuilt by the toc leaving and returning" + } + + // Split bottom side: the comments are the middle of the row. + workspace.undock(COMMENTS) + awaitUntil( + "the comments float", + ) { fixture.floatingWindows.value[COMMENTS]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + workspace.dock(COMMENTS, DockSide.Bottom) + awaitDockedBodies(fixture, TARGUM, COMMENTS, INSPECTOR) + val targum = panel(fixture, TARGUM) + val comments = panel(fixture, COMMENTS) + val inspector = panel(fixture, INSPECTOR) + check( + targum.right <= comments.left + LAYOUT_TOLERANCE_PX && + comments.right <= inspector.left + LAYOUT_TOLERANCE_PX, + ) { + "the comments are not back in the middle of the row: " + + "targum=$targum comments=$comments inspector=$inspector" + } + check(near(comments.width, commentsBefore.width, SPLITTER_TOLERANCE_PX)) { + "the comments came back at ${comments.width} px, were ${commentsBefore.width}" + } + }, + ) + } + + // ── 10. the preview follows the palette, not the pointer ───────────── + + /** + * The zone lights up when the *palette* reaches it, with the pointer still + * in the middle of the palette and nowhere near the layout's edge — and + * the side the panel already occupies is never offered. + * + * Driven with a real mouse where the host allows it: the palette follows + * the pointer, so grabbing its centre keeps the pointer far from every + * edge for the whole gesture while the palette's own edge enters the zone. + */ + private fun thePaletteEdgeDecidesTheZoneNotThePointer(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Bottom, extent = BOTTOM_H_DP.dp)), + DockPanelSpec( + INSPECTOR, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ), + ), + ) + return TaoWindowTestCase( + name = "dock layout the palette's own edge decides the zone, not the pointer", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE) + awaitUntil( + "the inspector floats", + ) { fixture.floatingWindows.value[INSPECTOR]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + val workspace = fixture.workspace + val floating = requireNotNull(fixture.floatingWindows.value[INSPECTOR]) + val layout = awaitDockLayout(workspace, window) + val scale = window.scaleFactor + val outer = requireNotNull(floating.outerBoundsPx()) + val paletteWidth = outer[2].toFloat() + + // The panel already on the bottom is not offered that side. + val tree = requireNotNull(workspace.satellite(TREE)) + check(!hintedSides(tree, window, workspace.satellites).contains(DockSide.Bottom)) { + "the bottom panel is offered the side it is already on: ${hintedSides( + tree, + window, + workspace.satellites, + )}" + } + check( + hintedSides(requireNotNull(workspace.satellite(INSPECTOR)), window, workspace.satellites).size == + DockSide.entries.size, + ) { + "a floating palette must be offered every side" + } + + // Aim so the palette's left edge lands just inside the left + // zone while the pointer stays at its centre — well past the + // zone, over the content — and the palette itself stays clear + // of the top and bottom zones, so the left one is the only + // edge in reach and the assertion is unambiguous. + val paletteHeight = outer[3].toFloat() + val grab = Offset(outer[0] + paletteWidth / 2f, outer[1] + HEADER_GRAB_Y_DP * floating.scaleFactor) + val grabInset = grab - Offset(outer[0].toFloat(), outer[1].toFloat()) + val target = Offset(layout.left + EDGE_INSET_PX, layout.center.y - paletteHeight / 2f) + grabInset + val zonePx = SatelliteWorkspace.DockZoneWidth.value * scale + check(target.x - layout.left > zonePx) { + "the pointer would land inside the left zone itself: this case would prove nothing" + } + val paletteTop = target.y - grabInset.y + check(paletteTop - layout.top > zonePx && layout.bottom - (paletteTop + paletteHeight) > zonePx) { + "the palette also reaches the top or bottom zone (layout=$layout palette height=$paletteHeight): " + + "the case would be ambiguous" + } + + val robot = robotPressAndDrag(grab, target, scale) != null + if (robot) { + awaitUntil("the left zone previews while the pointer is over the content — ${robotAim()}") { + workspace.dockPreview == DockTarget(window, DockSide.Left) + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[dock-layout] robot unavailable, driving the drag session directly") + val session = + requireNotNull( + workspace.beginDrag(INSPECTOR, SatelliteDragOrigin.FloatingWindow(floating), grab), + ) + session.update(target) + check(workspace.dockPreview == DockTarget(window, DockSide.Left)) { + "the palette's edge reached the left zone but ${workspace.dockPreview} is previewed" + } + session.end(target) + } + awaitUntil("the palette docked on the left") { + (workspace.satellite(INSPECTOR)?.placement as? SatellitePlacement.Docked)?.side == DockSide.Left + } + awaitDockedBodies(fixture, TREE, INSPECTOR) + check(near(panel(fixture, INSPECTOR).left, 0f, LAYOUT_TOLERANCE_PX * 2)) { + "the new panel is not at the left edge: ${panel(fixture, INSPECTOR)}" + } + }, + ) + } + + // ── 1. layered geometry ────────────────────────────────────────────── + + private fun layeredPanelsSitSideBySideWithTheirOwnWidths(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = layeredRightSpecs(), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout three layered panels on the right are three columns of their own width", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val scale = window.scaleFactor + val layout = requireNotNull(fixture.workspace.dockHostGeometry(window)).layoutBoundsInWindowPx + val tree = panel(fixture, TREE) + val toc = panel(fixture, TOC) + val notes = panel(fixture, NOTES) + val content = requireNotNull(fixture.contentBounds.value) + + // Order 0 is at the edge; each layer runs the full height. + check(near(tree.right, layout.right)) { "the first layer is not at the right edge: $tree in $layout" } + check(toc.right <= tree.left + LAYOUT_TOLERANCE_PX && notes.right <= toc.left + LAYOUT_TOLERANCE_PX) { + "layers are not side by side from the edge inwards: tree=$tree toc=$toc notes=$notes" + } + check( + content.right <= notes.left + LAYOUT_TOLERANCE_PX, + ) { "the content runs under a layer: $content vs $notes" } + for ((id, rect) in listOf(TREE to tree, TOC to toc, NOTES to notes)) { + check(near(rect.top, layout.top) && near(rect.bottom, layout.bottom)) { + "$id does not run the full height: $rect in $layout" + } + } + // Each at its own width. + check(near(tree.width, TREE_W_DP * scale)) { "tree width ${tree.width} != ${TREE_W_DP * scale}" } + check(near(toc.width, TOC_W_DP * scale)) { "toc width ${toc.width} != ${TOC_W_DP * scale}" } + check(near(notes.width, NOTES_W_DP * scale)) { "notes width ${notes.width} != ${NOTES_W_DP * scale}" } + // Nothing overlaps anything. + val all = listOf(tree, toc, notes, content) + for (i in all.indices) { + for (j in i + 1 until all.size) { + check(!overlaps(all[i], all[j])) { "panels overlap: ${all[i]} and ${all[j]}" } + } + } + // The default header strip sizes itself in the dock. + val body = requireNotNull(fixture.bodyBounds.value[TREE]) + check(near(body.top - tree.top, DockPanelHeaderHeight.value * scale)) { + "the header strip is ${body.top - tree.top} px, expected ${DockPanelHeaderHeight.value * scale}" + } + }, + ) + } + + // ── 2. layered splitter ────────────────────────────────────────────── + + private fun aLayeredSplitterResizesItsPanelAlone(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = layeredRightSpecs(), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout a layered panel's splitter resizes that panel alone", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val scale = window.scaleFactor + val treeBefore = panel(fixture, TREE) + val tocBefore = panel(fixture, TOC) + val notesBefore = panel(fixture, NOTES) + val contentBefore = requireNotNull(fixture.contentBounds.value) + val grip = requireNotNull(fixture.splitterOf(TOC)) { "no splitter published for $TOC" } + check( + grip.left <= tocBefore.left + LAYOUT_TOLERANCE_PX && + grip.right >= notesBefore.right - LAYOUT_TOLERANCE_PX, + ) { + "the toc splitter is not between toc and notes: grip=$grip toc=$tocBefore notes=$notesBefore" + } + + // On the right side, towards the content is leftwards. + val deltaPx = -(SPLITTER_DRAG_DP * scale) + val from = toScreen(fixture, grip.center) + val robot = robotPressAndDrag(from, from + Offset(deltaPx, 0f), scale) != null + if (robot) { + awaitUntil("the toc layer grew under the drag — ${robotAim()}") { + panelOrNull(fixture, TOC)?.let { it.width > tocBefore.width + abs(deltaPx) / 2 } == true + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[dock-layout] robot unavailable, resizing through the workspace") + fixture.workspace.setDockedExtent(TOC, (TOC_W_DP + SPLITTER_DRAG_DP).dp) + } + awaitUntil("the layout settled at the new width") { + panelOrNull( + fixture, + TOC, + )?.let { near(it.width, tocBefore.width + abs(deltaPx), SPLITTER_TOLERANCE_PX) } == + true + } + settle() + + val toc = panel(fixture, TOC) + check(near(panel(fixture, TREE).width, treeBefore.width)) { "the tree layer changed width" } + check(near(panel(fixture, NOTES).width, notesBefore.width)) { "the notes layer changed width" } + check(near(toc.right, tocBefore.right)) { "the toc layer moved instead of growing towards the content" } + val content = requireNotNull(fixture.contentBounds.value) + check(near(content.width, contentBefore.width - (toc.width - tocBefore.width), SPLITTER_TOLERANCE_PX)) { + "the content did not give up what the layer took: $contentBefore -> $content" + } + // The extent is the panel's own, and it is in the snapshot. + val saved = requireNotNull(fixture.workspace.snapshot().satellites[TOC]).placement + val docked = saved as SatellitePlacement.Docked + val extent = requireNotNull(docked.extent) + check(abs(extent.value * scale - toc.width) <= SPLITTER_TOLERANCE_PX) { + "the snapshot carries $extent, the layer is ${toc.width / scale} dp wide" + } + check( + ( + fixture.workspace + .snapshot() + .satellites[TREE] + ?.placement as SatellitePlacement.Docked + ).extent == + TREE_W_DP.dp, + ) { + "the tree's extent changed in the snapshot" + } + }, + ) + } + + // ── 3. split weights ───────────────────────────────────────────────── + + private fun splitPanelsShareBySideWeightAndTheDividerMovesIt(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Left, order = 0, weight = 1f)), + DockPanelSpec(TOC, SatellitePlacement.Docked(DockSide.Left, order = 1, weight = 3f)), + ), + ) + return TaoWindowTestCase( + name = "dock layout split panels share the side by weight and the divider moves it", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TOC) + val scale = window.scaleFactor + val tree = panel(fixture, TREE) + val toc = panel(fixture, TOC) + check( + near(tree.left, toc.left) && near(tree.width, toc.width), + ) { "split panels do not share the side's width" } + check(tree.bottom <= toc.top + LAYOUT_TOLERANCE_PX) { "order 0 is not above order 1: $tree / $toc" } + // 1 : 3, minus the divider between them. + check(abs(toc.height - 3f * tree.height) <= SPLITTER_TOLERANCE_PX * 3) { + "heights are not 1:3 — tree ${tree.height}, toc ${toc.height}" + } + val extentPx = fixture.workspace.dockExtent(DockSide.Left).value * scale + check(near(tree.width, extentPx)) { "the stack is ${tree.width} px wide, extent says $extentPx" } + + val divider = requireNotNull(fixture.splitterOf(TREE)) { "no divider between the two panels" } + check( + divider.top >= tree.bottom - LAYOUT_TOLERANCE_PX && divider.bottom <= toc.top + LAYOUT_TOLERANCE_PX, + ) { + "the divider is not between the panels: $divider between $tree and $toc" + } + val deltaPx = SPLITTER_DRAG_DP * scale + val from = toScreen(fixture, divider.center) + val robot = robotPressAndDrag(from, from + Offset(0f, deltaPx), scale) != null + if (robot) { + awaitUntil("the tree panel grew under the drag — ${robotAim()}") { + panelOrNull(fixture, TREE)?.let { it.height > tree.height + deltaPx / 2 } == true + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[dock-layout] robot unavailable, moving weight through the workspace") + val total = tree.height + toc.height + val moved = deltaPx / total * 4f + fixture.workspace.setDockedWeight(TREE, 1f + moved) + fixture.workspace.setDockedWeight(TOC, 3f - moved) + } + awaitUntil("the divider settled where it was dropped") { + panelOrNull(fixture, TREE)?.let { near(it.height, tree.height + deltaPx, SPLITTER_TOLERANCE_PX) } == + true + } + settle() + val treeAfter = panel(fixture, TREE) + val tocAfter = panel(fixture, TOC) + check(near(tocAfter.height, toc.height - deltaPx, SPLITTER_TOLERANCE_PX)) { + "the toc panel did not shrink by what the tree took: ${toc.height} -> ${tocAfter.height}" + } + check(near(treeAfter.width, tree.width)) { "the side's width changed under a weight drag" } + val weights = + fixture.workspace.satellites.associate { + it.id to (it.placement as SatellitePlacement.Docked).weight + } + check(weights.getValue(TREE) > 1f && weights.getValue(TOC) < 3f) { "weights did not move: $weights" } + check(abs(weights.getValue(TREE) + weights.getValue(TOC) - 4f) < WEIGHT_SUM_TOLERANCE) { + "the divider changed the total weight: $weights" + } + // The side's own splitter still drags the shared width. + val sideGrip = requireNotNull(fixture.sideSplitterOf(DockSide.Left)) + check( + near(sideGrip.left, tree.right, LAYOUT_TOLERANCE_PX + 1f), + ) { "the side splitter is not at the stack's edge" } + }, + ) + } + + // ── 4. side order ──────────────────────────────────────────────────── + + private fun theOuterSideOwnsTheCorners(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = TREE_W_DP.dp)), + DockPanelSpec(TARGUM, SatellitePlacement.Docked(DockSide.Left, extent = TREE_W_DP.dp)), + DockPanelSpec(COMMENTS, SatellitePlacement.Docked(DockSide.Bottom, extent = BOTTOM_H_DP.dp)), + ), + sideOrder = listOf(DockSide.Right, DockSide.Bottom, DockSide.Left, DockSide.Top), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout the first side in the order runs the full length and owns the corners", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TARGUM, COMMENTS) + val layout = requireNotNull(fixture.workspace.dockHostGeometry(window)).layoutBoundsInWindowPx + val tree = panel(fixture, TREE) + val targum = panel(fixture, TARGUM) + val comments = panel(fixture, COMMENTS) + val content = requireNotNull(fixture.contentBounds.value) + + check(near(tree.top, layout.top) && near(tree.bottom, layout.bottom)) { + "the right side does not run the full height: $tree" + } + check(comments.right <= tree.left + LAYOUT_TOLERANCE_PX) { + "the bottom panel runs under the right side: $comments vs $tree" + } + check(near(comments.left, layout.left)) { + "the bottom panel does not reach the left edge under the left panel: $comments" + } + check(targum.bottom <= comments.top + LAYOUT_TOLERANCE_PX) { + "the left panel runs beside the bottom one: $targum vs $comments" + } + check(near(targum.left, layout.left)) { "the left panel is not at the left edge: $targum" } + check( + content.left >= targum.right - LAYOUT_TOLERANCE_PX && + content.bottom <= comments.top + LAYOUT_TOLERANCE_PX, + ) { + "the content is not boxed in by left and bottom: $content" + } + check(near(comments.bottom, layout.bottom)) { "the bottom panel is not at the bottom edge" } + + // Now the classic order: bottom runs the full width under everything. + fixture.sideOrder.value = DefaultDockSideOrder + awaitUntil("the bottom panel took the full width — ${fixture.panelBounds.value}") { + panelOrNull( + fixture, + COMMENTS, + )?.let { near(it.right, layout.right) && near(it.left, layout.left) } == + true + } + settle() + val treeAfter = panel(fixture, TREE) + check(treeAfter.bottom <= panel(fixture, COMMENTS).top + LAYOUT_TOLERANCE_PX) { + "the right side still runs beside the bottom" + } + check( + fixture.incarnationsOf(TREE) == 1 && + fixture.incarnationsOf(COMMENTS) == 1 && + fixture.incarnationsOf(TARGUM) == 1, + ) { + "a side-order change rebuilt a panel: ${fixture.incarnations.value}" + } + check(fixture.contentIncarnations.value == 1) { "a side-order change rebuilt the content" } + }, + ) + } + + // ── 5. right-to-left ───────────────────────────────────────────────── + + private fun rtlKeepsPhysicalSidesAndHandsTheDirectionBack(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TARGUM, SatellitePlacement.Docked(DockSide.Left, extent = TREE_W_DP.dp)), + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = TREE_W_DP.dp)), + ), + layeredSides = setOf(DockSide.Left, DockSide.Right), + direction = LayoutDirection.Rtl, + ) + return TaoWindowTestCase( + name = "dock layout under RTL the left side is the physical left and its splitter grows it rightwards", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TARGUM, TREE) + val scale = window.scaleFactor + val layout = requireNotNull(fixture.workspace.dockHostGeometry(window)).layoutBoundsInWindowPx + val left = panel(fixture, TARGUM) + val right = panel(fixture, TREE) + check( + near(left.left, layout.left), + ) { "DockSide.Left is not at the physical left under RTL: $left in $layout" } + check( + near(right.right, layout.right), + ) { "DockSide.Right is not at the physical right under RTL: $right in $layout" } + check(fixture.contentDirection.value == LayoutDirection.Rtl) { "the content lost its RTL direction" } + check( + fixture.bodyDirections.value[TARGUM] == LayoutDirection.Rtl, + ) { "the panel body lost its RTL direction" } + + // Dragging the left panel's splitter to the right grows it. + val grip = requireNotNull(fixture.splitterOf(TARGUM)) + check(grip.left >= left.right - LAYOUT_TOLERANCE_PX) { + "the left panel's splitter is not on its content side: $grip vs $left" + } + val deltaPx = SPLITTER_DRAG_DP * scale + val from = toScreen(fixture, grip.center) + val robot = robotPressAndDrag(from, from + Offset(deltaPx, 0f), scale) != null + if (robot) { + awaitUntil("the left panel grew rightwards — ${robotAim()}") { + panelOrNull(fixture, TARGUM)?.let { it.width > left.width + deltaPx / 2 } == true + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[dock-layout] robot unavailable, resizing through the workspace") + fixture.workspace.setDockedExtent(TARGUM, (TREE_W_DP + SPLITTER_DRAG_DP).dp) + } + awaitUntil("the left panel settled at its new width") { + panelOrNull(fixture, TARGUM)?.let { near(it.width, left.width + deltaPx, SPLITTER_TOLERANCE_PX) } == + true + } + check(near(panel(fixture, TARGUM).left, layout.left)) { "the left panel left the edge while growing" } + check(near(panel(fixture, TREE).width, right.width)) { "the right panel changed under a left drag" } + + // Flipping the direction changes nothing about where the sides are. + fixture.direction.value = LayoutDirection.Ltr + settle(SETTLE_AFTER_MAP_MILLIS) + check( + near(panel(fixture, TARGUM).left, layout.left) && near(panel(fixture, TREE).right, layout.right), + ) { + "a direction flip moved the sides" + } + check( + fixture.contentDirection.value == LayoutDirection.Ltr, + ) { "the content did not follow the direction flip" } + check(fixture.incarnationsOf(TARGUM) == 1 && fixture.contentIncarnations.value == 1) { + "a direction flip rebuilt the panel or the content" + } + }, + ) + } + + // ── 6. nothing is rebuilt ──────────────────────────────────────────── + + private fun layoutChangesNeverRebuildAPanelOrTheContent(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + layeredRightSpecs() + + DockPanelSpec(COMMENTS, SatellitePlacement.Docked(DockSide.Bottom, extent = BOTTOM_H_DP.dp)) + + DockPanelSpec( + INSPECTOR, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout no layout change rebuilds a panel or the content and restores keep the floating window", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TOC, NOTES, COMMENTS) + awaitUntil( + "the inspector floats", + ) { fixture.floatingWindows.value[INSPECTOR]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + val workspace = fixture.workspace + val floating = requireNotNull(fixture.floatingWindows.value[INSPECTOR]) + val docked = listOf(TREE, TOC, NOTES, COMMENTS) + val initial = workspace.snapshot() + + suspend fun step(what: String) { + settle(SETTLE_AFTER_MAP_MILLIS) + for (id in docked) { + check( + fixture.incarnationsOf(id) == 1, + ) { "$what rebuilt $id: built ${fixture.incarnationsOf(id)} times" } + check( + fixture.liveBodiesOf(id) == 1, + ) { "$what left $id composed ${fixture.liveBodiesOf(id)} times" } + } + check(fixture.contentIncarnations.value == 1) { "$what rebuilt the content" } + check( + fixture.floatingWindows.value[INSPECTOR] === floating, + ) { "$what recreated the inspector's window" } + check(fixture.incarnationsOf(INSPECTOR) == 1) { "$what rebuilt the inspector's body" } + } + + workspace.setDockedExtent(TOC, (TOC_W_DP + SPLITTER_DRAG_DP).dp) + step("a layered extent change") + workspace.setDockExtent(DockSide.Bottom, (BOTTOM_H_DP + SPLITTER_DRAG_DP).dp) + step("a side extent change") + workspace.dock(TREE, DockSide.Right, order = 5) + step("a reorder on the same side") + awaitUntil("tree moved to the inner end") { + panelOrNull(fixture, TREE)?.let { + it.left < + panel(fixture, TOC).left + } == + true + } + workspace.dock(NOTES, DockSide.Left) + awaitUntil("notes moved to the left side") { + panelOrNull(fixture, NOTES)?.let { + near( + it.left, + 0f, + LAYOUT_TOLERANCE_PX * 2, + ) + } == + true + } + step("a move to another side") + workspace.dock(NOTES, DockSide.Bottom) + awaitUntil("notes shares the bottom") { + panelOrNull(fixture, NOTES)?.let { + it.top > + panel(fixture, TOC).top + } == + true + } + step("a move to a split side") + workspace.setDockedWeight(NOTES, 2f) + step("a weight change") + fixture.layeredSides.value = setOf(DockSide.Right, DockSide.Bottom) + step("a side turning layered") + fixture.layeredSides.value = setOf(DockSide.Right) + step("a side turning split again") + fixture.sideOrder.value = listOf(DockSide.Right, DockSide.Bottom, DockSide.Left, DockSide.Top) + step("a new side order") + fixture.direction.value = LayoutDirection.Rtl + step("a direction flip") + repeat(RESTORE_ROUNDS) { + workspace.restore(initial) + step("a restore of the initial layout") + workspace.restore(workspace.snapshot()) + step("a restore of the current layout") + } + awaitUntil("the initial layout is back") { + panelOrNull(fixture, NOTES)?.let { near(it.width, NOTES_W_DP * window.scaleFactor) } == true + } + // A resize of the window re-lays everything out and rebuilds nothing. + window.setInnerSize(RESIZED_W_DP, RESIZED_H_DP) + awaitUntil("the window resized") { (bounds()?.get(2) ?: 0L) > PARENT_W_DP * window.scaleFactor + 1 } + step("a window resize") + }, + ) + } + + // ── 7. custom splitter ─────────────────────────────────────────────── + + private fun aOneDpSplitterWithAWiderGripTakesTheDrag(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = listOf(DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = TREE_W_DP.dp))), + layeredSides = setOf(DockSide.Right), + gripOverflow = true, + ) + return TaoWindowTestCase( + name = "dock layout a 1 dp splitter with a wider grip takes a drag aimed off the line", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE) + val scale = window.scaleFactor + val before = panel(fixture, TREE) + val grip = requireNotNull(fixture.splitterOf(TREE)) + check(near(grip.width, GRIP_OVERFLOW_DP * scale, LAYOUT_TOLERANCE_PX)) { + "the grip is ${grip.width} px wide, expected ${GRIP_OVERFLOW_DP * scale}: " + + "requiredWidth did not overflow" + } + // The layout itself only gave the splitter one dp. + check( + near(before.left - requireNotNull(fixture.contentBounds.value).right, scale, LAYOUT_TOLERANCE_PX), + ) { + "the layout reserved more than 1 dp for the splitter" + } + // Aim two dp off the line, inside the grip but outside the 1 dp of layout. + val aim = Offset(grip.center.x - 2f * scale, grip.center.y) + val deltaPx = -(SPLITTER_DRAG_DP * scale) + val from = toScreen(fixture, aim) + if (robotPressAndDrag(from, from + Offset(deltaPx, 0f), scale) == null) { + System.err.println("[dock-layout] robot unavailable, the overflowing grip cannot be exercised") + return@TaoWindowTestCase + } + awaitUntil("the panel grew under a drag aimed beside the line — ${robotAim()}") { + panelOrNull(fixture, TREE)?.let { it.width > before.width + abs(deltaPx) / 2 } == true + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + awaitUntil("the panel settled") { + panelOrNull( + fixture, + TREE, + )?.let { near(it.width, before.width + abs(deltaPx), SPLITTER_TOLERANCE_PX) } == + true + } + }, + ) + } + + // ── 8. drop on a layered side ──────────────────────────────────────── + + private fun aDropOnALayeredSideAddsALayerOfTheWindowsWidth(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = TREE_W_DP.dp)), + DockPanelSpec( + INSPECTOR, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ), + ), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout a floating satellite dropped on a layered side becomes a layer of its window's width", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE) + awaitUntil( + "the inspector floats", + ) { fixture.floatingWindows.value[INSPECTOR]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + val workspace = fixture.workspace + val floating = requireNotNull(fixture.floatingWindows.value[INSPECTOR]) + val scale = window.scaleFactor + val layout = awaitDockLayout(workspace, window) + val treeBefore = panel(fixture, TREE) + val outer = requireNotNull(floating.outerBoundsPx()) + val grab = Offset(outer[0] + outer[2] / 2f, outer[1] + HEADER_GRAB_Y_DP * floating.scaleFactor) + val dropIn = Offset(layout.right - DROP_INSET_PX, layout.center.y) + + val session = + requireNotNull(workspace.beginDrag(INSPECTOR, SatelliteDragOrigin.FloatingWindow(floating), grab)) + session.update(dropIn) + check(workspace.dockPreview == DockTarget(window, DockSide.Right)) { + "the right zone is not previewed: ${workspace.dockPreview}" + } + session.end(dropIn) + awaitDockedBodies(fixture, TREE, INSPECTOR) + + val inspector = panel(fixture, INSPECTOR) + val tree = panel(fixture, TREE) + val placement = workspace.satellite(INSPECTOR)?.placement as SatellitePlacement.Docked + check( + placement.side == DockSide.Right && placement.order > 0, + ) { "not appended on the right: $placement" } + check( + placement.extent == workspaceSatelliteSize().width, + ) { "the layer's extent is not the window's width: $placement" } + check(near(inspector.width, SATELLITE_W_DP * scale)) { + "the layer is ${inspector.width} px, the window was ${SATELLITE_W_DP * scale}" + } + check(inspector.right <= tree.left + LAYOUT_TOLERANCE_PX) { + "the new layer is not inside the existing one: $inspector vs $tree" + } + check(near(tree.width, treeBefore.width) && near(tree.right, treeBefore.right)) { + "the existing layer moved or resized: $treeBefore -> $tree" + } + }, + ) + } + + // ── 9. lift-off from a layer ───────────────────────────────────────── + + private fun undockingALayerLiftsTheWindowOffThePanel(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = layeredRightSpecs(), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout undocking a middle layer lifts its window off where the layer was", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val client = requireNotNull(fixture.workspace.dockHostGeometry(window)?.clientOriginPx()) + val tocBefore = panel(fixture, TOC) + val expected = tocBefore.translate(client) + val treeBefore = panel(fixture, TREE) + val notesBefore = panel(fixture, NOTES) + + fixture.workspace.undock(TOC) + awaitUntil( + "the toc floats with a frame", + ) { fixture.floatingWindows.value[TOC]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + val outer = requireNotNull(requireNotNull(fixture.floatingWindows.value[TOC]).outerBoundsPx()) + check( + abs(outer[0] - expected.left) <= LIFT_OFF_TOLERANCE_PX && + abs(outer[1] - expected.top) <= LIFT_OFF_TOLERANCE_PX, + ) { + "the window lifted off at (${outer[0]}, ${outer[1]}), " + + "the layer was at (${expected.left}, ${expected.top})" + } + check(abs(outer[2] - expected.width) <= LIFT_OFF_TOLERANCE_PX) { + "the window is ${outer[2]} px wide, the layer was ${expected.width}" + } + // The neighbours close the gap: the tree stays at the edge, the notes slide out to meet it. + val tree = panel(fixture, TREE) + val notes = panel(fixture, NOTES) + check( + near(tree.right, treeBefore.right) && near(tree.width, treeBefore.width), + ) { "the outer layer moved" } + check(near(notes.width, notesBefore.width) && notes.right > notesBefore.right + tocBefore.width / 2) { + "the inner layer did not slide out to fill the gap: $notesBefore -> $notes" + } + check( + fixture.incarnationsOf(TREE) == 1 && fixture.incarnationsOf(NOTES) == 1, + ) { "undocking one layer rebuilt another" } + }, + ) + } + + // ── helpers ────────────────────────────────────────────────────────── + + /** + * Starts a drag of the docked panel [id] and waits for the layout to + * publish the zones the drop is resolved against. A pointer gesture gives + * the hints a frame to compose before the slop is passed; a session driven + * by hand has to wait for it, or the first sample is resolved against the + * bare edges. + */ + private suspend fun TaoWindowTestScope.beginDockedDrag( + workspace: SatelliteWorkspace, + id: String, + grab: Offset, + ): SatelliteDragSession { + val session = requireNotNull(workspace.beginDrag(id, SatelliteDragOrigin.DockedPanel(window), grab)) + awaitUntil("the layout published its drop zones") { + workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx?.isNotEmpty() == true + } + return session + } + + private fun layeredRightSpecs(): List = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, order = 0, extent = TREE_W_DP.dp)), + DockPanelSpec(TOC, SatellitePlacement.Docked(DockSide.Right, order = 1, extent = TOC_W_DP.dp)), + DockPanelSpec(NOTES, SatellitePlacement.Docked(DockSide.Right, order = 2, extent = NOTES_W_DP.dp)), + ) + + private fun panel( + fixture: DockLayoutFixture, + id: String, + ): Rect = + requireNotNull(fixture.panelBounds.value[id]) { "no panel bounds for $id: ${fixture.panelBounds.value.keys}" } + + /** The layout's right edge in window px: the panels are measured there, the layout rect on screen. */ + private fun layoutInWindowRight( + layoutScreenPx: Rect, + clientOriginPx: Offset, + ): Float = layoutScreenPx.right - clientOriginPx.x + + private fun panelOrNull( + fixture: DockLayoutFixture, + id: String, + ): Rect? = fixture.panelBounds.value[id] + + private const val TREE = "tree" + private const val TOC = "toc" + private const val NOTES = "notes" + private const val TARGUM = "targum" + private const val COMMENTS = "comments" + private const val INSPECTOR = "inspector" + + private const val TREE_W_DP = 100f + private const val TOC_W_DP = 120f + private const val NOTES_W_DP = 90f + private const val BOTTOM_H_DP = 90f + private const val SPLITTER_DRAG_DP = 40f + private const val SPLITTER_TOLERANCE_PX = 6f + private const val WEIGHT_SUM_TOLERANCE = 0.01f + private const val RESTORE_ROUNDS = 3 + + /** How far inside the layout's edge the dragged palette's own edge is aimed. */ + private const val EDGE_INSET_PX = 8f + + /** Where in a neighbour a drop aims to land ahead of it: well inside its outer half. */ + private const val OUTER_HALF = 0.8f + + /** A drag that stays on the panel it started from. */ + private const val OWN_NUDGE_PX = 6f + + /** Into the content, in dp from the layout's left edge: past the strip, short of the right ranks. */ + private const val CONTENT_AIM_DP = 120f + + /** Enough to pass the touch slop and publish a ghost. */ + private const val PANEL_DRAG_STEP_PX = 24f + + /** How far inside a panel's leading edge a grab is taken. */ + private const val GRAB_EDGE_INSET_DP = 8f +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutMonkeyHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutMonkeyHeadfulCases.kt new file mode 100644 index 000000000..d13d65733 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutMonkeyHeadfulCases.kt @@ -0,0 +1,582 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DefaultDockSideOrder +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatelliteLayoutSnapshot +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.TaoApplication +import dev.nucleusframework.window.tao.TaoEventCode +import kotlin.math.roundToInt +import kotlin.random.Random + +/** + * The dock-layout monkeys: random layout mutations on one `DockLayout`, one + * case per (layout profile, seed). + * + * Where [SatelliteWorkspaceMonkeyHeadfulCases] shakes the *workspace* — hosts + * coming and going, drags across windows — these shake the *layout*: layered + * and split sides, per-panel extents and weights, splitters dragged with a real + * mouse, side orders shuffled, sides flipping between layered and split, the + * direction flipping between LTR and RTL, and snapshots restored on top of + * whatever the previous steps left. Each profile is a layout an app would + * actually declare — the reader layout of a right-to-left book app among them — + * and each is run under several seeds, because the interleavings are the point. + * + * What a run asserts, after every action and at checkpoints: + * + * - **geometry**: no two visible panels overlap, none overlaps the content, + * and every one is inside the layout — whatever the extents, weights, order + * and direction happen to be; + * - **identity**: a panel body is built once per *hosting change* (docked to + * floating, closed to open, hidden to shown) and never by a change of the + * layout alone — a splitter, a reorder, a side change, a restore, a new + * side order or direction must move a subtree, not rebuild it. The content + * is never rebuilt at all; + * - **composition**: no panel composes in two hosts once a step has settled; + * - **liveness**: `Dispatchers.Main` keeps answering ([MainLoopWatchdog]), + * no action wedges, and native windows do not accumulate; + * - **convergence**: the closing phase docks everything back into one + * layered configuration and it has to lay out cleanly. + * + * Every failure carries the profile, the seed and the last actions; + * `-Dnucleus.tao.headful.monkeySeed=` replays the action sequence and + * `-Dnucleus.tao.headful.monkeyScript=A,B,C` replays a journal verbatim. + */ +internal object DockLayoutMonkeyHeadfulCases { + fun all(): List = + PROFILES.flatMap { profile -> + SEEDS.map { seed -> randomLayoutChangesLeaveACleanLayout(profile, seed, MONKEY_ACTIONS) } + } + randomLayoutChangesLeaveACleanLayout(PROFILES[READER_PROFILE], LONG_RUN_SEED, LONG_RUN_ACTIONS) + + private fun randomLayoutChangesLeaveACleanLayout( + profile: LayoutProfile, + seed: Long, + actions: Int, + ): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = profile.specs, + sideOrder = profile.sideOrder, + layeredSides = profile.layeredSides, + direction = profile.direction, + ) + return TaoWindowTestCase( + name = "dock layout monkey ${profile.name} seed $seed: $actions random layout changes leave a clean layout", + timeoutMillis = MONKEY_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitUntil("the case window is mapped") { bounds() != null } + awaitUntil("the layout published its geometry") { + fixture.workspace.dockHostGeometry(window)?.layoutScreenRectPx() != null + } + awaitUntil("every satellite is declared") { + profile.specs.all { + fixture.workspace.satellite(it.id) != + null + } + } + settle(SETTLE_AFTER_MAP_MILLIS) + val monkey = DockMonkey(this, fixture, profile, monkeySeedOr(seed), actions) + monkey.run() + monkey.quiesceAndAssert() + }, + ) + } + + /** The seed property overrides every case's own seed, so a red one replays. */ + private fun monkeySeedOr(default: Long): Long = System.getProperty(MONKEY_SEED_PROPERTY)?.toLongOrNull() ?: default + + private val SEEDS = longArrayOf(20_260_907L, 42L, 7L) + private const val READER_PROFILE = 1 + private const val LONG_RUN_SEED = 1_000_003L +} + +/** A layout an app would declare, with the satellites that start in it. */ +private class LayoutProfile( + val name: String, + val sideOrder: List, + val layeredSides: Set, + val direction: LayoutDirection, + val specs: List, +) + +private val FLOATING = + SatellitePlacement.Floating(positioner = workspaceRightEdgePositioner(), size = workspaceSatelliteSize()) + +private val PROFILES = + listOf( + LayoutProfile( + name = "border", + sideOrder = DefaultDockSideOrder, + layeredSides = emptySet(), + direction = LayoutDirection.Ltr, + specs = + listOf( + DockPanelSpec("tree", SatellitePlacement.Docked(DockSide.Left)), + DockPanelSpec("toc", SatellitePlacement.Docked(DockSide.Left, order = 1)), + DockPanelSpec("notes", SatellitePlacement.Docked(DockSide.Bottom)), + DockPanelSpec("targum", FLOATING), + DockPanelSpec("comments", FLOATING), + ), + ), + // The reader: a right-to-left book app with its navigation layered on the + // right, the translation on the left and the commentaries under both. + LayoutProfile( + name = "reader", + sideOrder = listOf(DockSide.Right, DockSide.Bottom, DockSide.Left, DockSide.Top), + layeredSides = setOf(DockSide.Right), + direction = LayoutDirection.Rtl, + specs = + listOf( + DockPanelSpec("tree", SatellitePlacement.Docked(DockSide.Right, order = 0, extent = 90.dp)), + DockPanelSpec("toc", SatellitePlacement.Docked(DockSide.Right, order = 1, extent = 80.dp)), + DockPanelSpec("notes", SatellitePlacement.Docked(DockSide.Right, order = 2, extent = 80.dp)), + DockPanelSpec("targum", SatellitePlacement.Docked(DockSide.Left, extent = 90.dp)), + DockPanelSpec("comments", SatellitePlacement.Docked(DockSide.Bottom, extent = 80.dp)), + ), + ), + LayoutProfile( + name = "all layered", + sideOrder = listOf(DockSide.Left, DockSide.Right, DockSide.Top, DockSide.Bottom), + layeredSides = DockSide.entries.toSet(), + direction = LayoutDirection.Ltr, + specs = + listOf( + DockPanelSpec("tree", SatellitePlacement.Docked(DockSide.Left, extent = 90.dp)), + DockPanelSpec("toc", SatellitePlacement.Docked(DockSide.Top, extent = 80.dp)), + DockPanelSpec("notes", SatellitePlacement.Docked(DockSide.Right, extent = 90.dp)), + DockPanelSpec("targum", SatellitePlacement.Docked(DockSide.Bottom, extent = 80.dp)), + DockPanelSpec("comments", FLOATING), + ), + ), + LayoutProfile( + name = "rows rtl", + sideOrder = listOf(DockSide.Top, DockSide.Bottom, DockSide.Right, DockSide.Left), + layeredSides = setOf(DockSide.Top, DockSide.Bottom), + direction = LayoutDirection.Rtl, + specs = + listOf( + DockPanelSpec("tree", SatellitePlacement.Docked(DockSide.Top, extent = 80.dp)), + DockPanelSpec("toc", SatellitePlacement.Docked(DockSide.Top, order = 1, extent = 80.dp)), + DockPanelSpec("notes", SatellitePlacement.Docked(DockSide.Right, weight = 2f)), + DockPanelSpec("targum", SatellitePlacement.Docked(DockSide.Right, order = 1)), + DockPanelSpec("comments", SatellitePlacement.Docked(DockSide.Bottom, extent = 80.dp)), + ), + ), + ) + +/** One atomic layout change the monkey can make. Drawn uniformly. */ +private enum class DockAction { + /** Docks a satellite on a random side, at a random or appended order. */ + Dock, + + /** Lifts a docked satellite into a floating window. */ + Undock, + + /** Shows a closed satellite. */ + Open, + + /** Hides a satellite, keeping its placement. */ + Close, + + /** Sets a layered panel's own extent to a random value, tiny to huge. */ + SetExtent, + + /** Sets a split panel's weight to a random value, including a degenerate one. */ + SetWeight, + + /** Drags a random splitter with the real mouse, a random distance along its axis. */ + DragSplitter, + + /** Records the current layout for a later restore. */ + Snapshot, + + /** Restores a recorded layout — or the current one — on top of what is there. */ + Restore, + + /** Shuffles the side order. */ + ShuffleSides, + + /** Flips one side between layered and split. */ + ToggleLayered, + + /** Flips the layout direction. */ + FlipDirection, + + /** Resizes the window to a random inner size. */ + Resize, + + /** Flips the workspace-wide visibility sweep. */ + ToggleVisible, + + /** Injects a scale-factor change. */ + ChangeDpi, +} + +/** How a satellite is hosted at a given instant, the thing whose changes justify a rebuild. */ +private enum class Hosting { Docked, Floating, None } + +private class DockMonkey( + private val scope: TaoWindowTestScope, + private val fixture: DockLayoutFixture, + private val profile: LayoutProfile, + seed: Long, + private val actions: Int, +) { + private val random = Random(seed) + private val journal = MonkeyJournal("dock-monkey[${profile.name}]", seed) + private val script = monkeyScript() + private val workspace get() = fixture.workspace + private val ids = profile.specs.map { it.id } + private val snapshots = ArrayList() + private var worstStallMillis = 0L + + /** Hosting changes seen per satellite: the only thing that may rebuild a body. */ + private val hostingChanges = HashMap() + private var lastHosting: Map = emptyMap() + + suspend fun run() { + System.err.println("[dock-monkey] profile=${profile.name} seed=${journal.seed} actions=$actions") + lastHosting = currentHosting() + val watchdog = MainLoopWatchdog("dock-monkey", journal::report).start() + try { + while (journal.step < actions) { + val action = nextAction() ?: break + journal.record(action) + monkeyAction({ journal.failure("$action never returned", describe()) }) { apply(action) } + scope.settle(STEP_SETTLE_MILLIS) + noteHosting() + checkStepInvariants() + if ((journal.step + 1) % CHECKPOINT_EVERY == 0) checkpoint() + journal.step++ + } + } finally { + worstStallMillis = watchdog.stop() + } + } + + private fun nextAction(): DockAction? { + val scripted = script ?: return DockAction.entries[random.nextInt(DockAction.entries.size)] + val name = scripted.getOrNull(journal.step) ?: return null + return DockAction.valueOf(name) + } + + /** + * Docks everything back into the profile's own layout and requires a clean + * result: one body per panel, no overlap, no leftover window. + */ + suspend fun quiesceAndAssert() { + workspace.visible = true + scope.window.dispatch( + TaoEventCode.SCALE_FACTOR_CHANGED, + (scope.window.scaleFactor * SCALE_MILLI).roundToInt(), + 0, + ) + scope.window.setInnerSize(PARENT_W_DP.toDouble(), PARENT_H_DP.toDouble()) + fixture.sideOrder.value = profile.sideOrder + fixture.layeredSides.value = profile.layeredSides + fixture.direction.value = profile.direction + for ((index, id) in ids.withIndex()) { + workspace.open(id) + workspace.dock(id, DockSide.entries[index % DockSide.entries.size], order = index) + workspace.setDockedExtent(id, QUIESCE_EXTENT_DP.dp) + workspace.setDockedWeight(id, 1f) + } + scope.settle(SETTLE_AFTER_MAP_MILLIS) + + awaitConverges("every panel is docked with exactly one live body") { + ids.all { fixture.liveBodiesOf(it) == 1 && fixture.bodyBounds.value[it] != null } + } + awaitConverges("the docked layout is clean") { geometryProblem() == null } + awaitConverges("no floating window is left") { fixture.floatingWindows.value.isEmpty() } + awaitConverges("the run leaked no window") { TaoApplication.liveWindowCount() <= 1 + TEARDOWN_SLACK } + check(fixture.contentIncarnations.value == 1) { journal.failure("the content was rebuilt", describe()) } + + System.err.println( + "[dock-monkey] profile=${profile.name} seed=${journal.seed} survived $actions actions; " + + "worst main-dispatcher round trip ${worstStallMillis}ms; reached ${journal.reachedSummary()}", + ) + check(worstStallMillis <= MONKEY_MAX_STALL_MILLIS) { + journal.failure("the main dispatcher took ${worstStallMillis}ms to answer a heartbeat", describe()) + } + if (script == null) { + check(journal.reachedCount("splitterDragged") + journal.reachedCount("splitterSet") > 0) { + journal.failure("no splitter was ever moved", describe()) + } + check(journal.reachedCount("restored") > 0) { journal.failure("no snapshot was ever restored", describe()) } + } + } + + // ── applying one action ────────────────────────────────────────────── + + private suspend fun apply(action: DockAction) { + when (action) { + DockAction.Dock -> { + val order = if (random.nextBoolean()) null else random.nextInt(MAX_ORDER) + workspace.dock(randomId(), randomSide(), order = order) + } + DockAction.Undock -> workspace.undock(randomId()) + DockAction.Open -> workspace.open(randomId()) + DockAction.Close -> workspace.close(randomId()) + DockAction.SetExtent -> { + workspace.setDockedExtent(randomId(), (random.nextFloat() * EXTENT_SPAN_DP).dp) + journal.reach("splitterSet") + } + DockAction.SetWeight -> workspace.setDockedWeight(randomId(), random.nextFloat() * WEIGHT_SPAN - 1f) + DockAction.DragSplitter -> dragSplitter() + DockAction.Snapshot -> { + snapshots += workspace.snapshot() + if (snapshots.size > MAX_SNAPSHOTS) snapshots.removeAt(0) + } + DockAction.Restore -> { + val snapshot = snapshots.randomOrNull(random) ?: workspace.snapshot() + workspace.restore(snapshot) + journal.reach("restored") + } + DockAction.ShuffleSides, + DockAction.ToggleLayered, + DockAction.FlipDirection, + DockAction.Resize, + DockAction.ToggleVisible, + DockAction.ChangeDpi, + -> applyToTheLayout(action) + } + } + + /** The actions that change the layout's shape or its window rather than a satellite. */ + private fun applyToTheLayout(action: DockAction) { + when (action) { + DockAction.ShuffleSides -> fixture.sideOrder.value = DockSide.entries.shuffled(random) + DockAction.ToggleLayered -> { + val side = randomSide() + val current = fixture.layeredSides.value + fixture.layeredSides.value = if (side in current) current - side else current + side + } + DockAction.FlipDirection -> + fixture.direction.value = + if (fixture.direction.value == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr + DockAction.Resize -> + scope.window.setInnerSize( + MIN_INNER_W_DP + random.nextDouble(INNER_W_SPAN_DP), + MIN_INNER_H_DP + random.nextDouble(INNER_H_SPAN_DP), + ) + DockAction.ToggleVisible -> workspace.visible = !workspace.visible + DockAction.ChangeDpi -> { + val scale = SCALE_HOPS[random.nextInt(SCALE_HOPS.size)] + scope.window.dispatch(TaoEventCode.SCALE_FACTOR_CHANGED, (scale * SCALE_MILLI).roundToInt(), 0) + } + else -> error("not a layout action: $action") + } + } + + /** + * A real mouse drag on a random splitter: a press on its grip and a move + * along its axis, a flick or a deliberate drag. Falls back to the + * workspace call when the host cannot inject input. + */ + private suspend fun dragSplitter() { + val (key, grip) = + fixture.splitterBounds.value.entries + .randomOrNull(random) + ?: return journal.reach("noSplitter") + if (grip.width <= 0f || grip.height <= 0f) return journal.reach("emptySplitter") + val horizontal = grip.height > grip.width + val deltaPx = (random.nextFloat() * 2f - 1f) * DRAG_SPAN_PX + val delta = if (horizontal) Offset(deltaPx, 0f) else Offset(0f, deltaPx) + val client = + workspace.dockHostGeometry(scope.window)?.clientOriginPx() ?: return journal.reach("noClientOrigin") + val from = client + grip.center + val steps = if (random.nextBoolean()) FLICK_STEPS else ROBOT_DRAG_STEPS + val pressed = + robotPressAndDrag(from, from + delta, scope.window.scaleFactor, steps = steps, stepDelayMillis = 0L) + if (pressed == null) { + // Same change, no mouse: the panel the splitter would have moved. + val id = key.removePrefix("panel:") + if (key.startsWith("panel:")) workspace.setDockedExtent(id, (random.nextFloat() * EXTENT_SPAN_DP).dp) + journal.reach("splitterSet") + return + } + robotRelease() + journal.reach("splitterDragged") + } + + // ── invariants ─────────────────────────────────────────────────────── + + private fun currentHosting(): Map = + ids.associateWith { id -> + val entry = workspace.satellite(id) + when { + entry == null || !entry.isOpen || !workspace.visible -> Hosting.None + entry.isDocked -> Hosting.Docked + else -> Hosting.Floating + } + } + + private fun noteHosting() { + val now = currentHosting() + for (id in ids) { + if (now[id] != lastHosting[id]) hostingChanges[id] = (hostingChanges[id] ?: 0) + 1 + } + lastHosting = now + } + + /** Holds at every instant, whatever is in flight. */ + private fun checkStepInvariants() { + for (id in ids) { + val live = fixture.liveBodiesOf(id) + check(live in 0..MAX_LIVE_BODIES) { journal.failure("$id has $live live bodies", describe()) } + // One build for the first hosting plus one per hosting change; a + // layout change on its own is never one of them. + val allowed = 1 + (hostingChanges[id] ?: 0) + REBUILD_SLACK + check(fixture.incarnationsOf(id) <= allowed) { + journal.failure( + "$id was built ${fixture.incarnationsOf(id)} times for ${hostingChanges[id] ?: 0} hosting changes", + describe(), + ) + } + } + check(fixture.contentIncarnations.value == 1) { journal.failure("the content was rebuilt", describe()) } + val live = TaoApplication.liveWindowCount() + check(live <= 1 + ids.size + TEARDOWN_SLACK) { journal.failure("$live native windows are alive", describe()) } + } + + /** Holds once the dust of a step has settled. */ + private suspend fun checkpoint() { + awaitConverges("every open satellite has exactly one body") { + ids.all { id -> + val entry = workspace.satellite(id) + val expected = if (entry != null && entry.isOpen && workspace.visible) 1 else 0 + fixture.liveBodiesOf(id) == expected + } + } + awaitConverges("the layout is clean: ${geometryProblem()}") { geometryProblem() == null } + } + + /** + * What is wrong with the visible geometry, or `null`: a panel outside the + * layout, two panels overlapping, or one overlapping the content. Panels + * whose bounds have not been published yet are not judged. + */ + private fun geometryProblem(): String? { + val layout = workspace.dockHostGeometry(scope.window)?.layoutBoundsInWindowPx ?: return "no layout geometry" + val visible = + ids.filter { id -> + val entry = workspace.satellite(id) + entry != null && entry.isOpen && workspace.visible && entry.isDocked + } + val rects = visible.mapNotNull { id -> fixture.panelBounds.value[id]?.let { id to it } } + val outer = layout.inflate(LAYOUT_TOLERANCE_PX) + for ((id, rect) in rects) { + if (rect.left < outer.left || + rect.top < outer.top || + rect.right > outer.right || + rect.bottom > outer.bottom + ) { + return "$id at $rect is outside the layout $layout" + } + } + for (i in rects.indices) { + for (j in i + 1 until rects.size) { + if (overlaps(rects[i].second, rects[j].second)) { + return "${rects[i].first} ${rects[i].second} overlaps ${rects[j].first} ${rects[j].second}" + } + } + } + val content = fixture.contentBounds.value + if (content != null && content.width > 0f && content.height > 0f) { + for ((id, rect) in rects) { + if (overlaps(rect, content)) return "$id $rect overlaps the content $content" + } + } + return null + } + + private suspend fun awaitConverges( + description: String, + predicate: () -> Boolean, + ) { + val deadline = System.currentTimeMillis() + CONVERGE_MILLIS + while (!predicate()) { + check(System.currentTimeMillis() < deadline) { + journal.failure("$description did not hold within ${CONVERGE_MILLIS}ms", describe()) + } + scope.settle(CONVERGE_POLL_MILLIS) + } + } + + private fun randomId(): String = ids[random.nextInt(ids.size)] + + private fun randomSide(): DockSide = DockSide.entries[random.nextInt(DockSide.entries.size)] + + private fun describe(): String = + "profile=${profile.name} sides=${fixture.sideOrder.value} layered=${fixture.layeredSides.value} " + + "direction=${fixture.direction.value} visible=${workspace.visible} " + + "live=${TaoApplication.liveWindowCount()} content=${fixture.contentBounds.value} " + + workspace.satellites.joinToString(prefix = "satellites=[", postfix = "]") { entry -> + val placement = entry.placement + val where = + if (placement is SatellitePlacement.Docked) { + "docked(${placement.side}#${placement.order} " + + "extent=${placement.extent} weight=${placement.weight})" + } else { + "floating" + } + "${entry.id}:${if (entry.isOpen) "open" else "closed"}/$where" + + "/bounds=${fixture.panelBounds.value[entry.id]?.let(::short)}" + + "/bodies=${fixture.liveBodiesOf(entry.id)}/built=${fixture.incarnationsOf(entry.id)}" + } + + private fun short(rect: Rect): String = + "(${rect.left.roundToInt()},${rect.top.roundToInt()} ${rect.width.roundToInt()}x${rect.height.roundToInt()})" +} + +/** Enough to interleave every pair of actions a few times, short enough to run a dozen profiles. */ +private const val MONKEY_ACTIONS = 120 + +/** The reader profile once more, for longer: the layout SeforimApp would declare. */ +private const val LONG_RUN_ACTIONS = 400 + +private const val MONKEY_CASE_TIMEOUT_MILLIS = 300_000L +private const val STEP_SETTLE_MILLIS = 25L +private const val CHECKPOINT_EVERY = 10 +private const val CONVERGE_MILLIS = 5_000L +private const val CONVERGE_POLL_MILLIS = 50L + +/** Two bodies overlap for the frame in which a dock or an undock hands a panel over. */ +private const val MAX_LIVE_BODIES = 2 + +/** + * A hosting change is counted after the step settled; a panel that went + * docked → floating → docked inside one restore shows as no change and two + * builds. One step of slack absorbs that without hiding a layout rebuild, + * which happens on every splitter drag and would run away at once. + */ +private const val REBUILD_SLACK = 2 + +/** Windows dropped from composition are counted until the platform confirms the destroy. */ +private const val TEARDOWN_SLACK = 3 + +private const val MAX_ORDER = 6 +private const val MAX_SNAPSHOTS = 6 +private const val EXTENT_SPAN_DP = 500f +private const val WEIGHT_SPAN = 6f +private const val DRAG_SPAN_PX = 240f +private const val QUIESCE_EXTENT_DP = 70f + +private const val MIN_INNER_W_DP = 300.0 +private const val INNER_W_SPAN_DP = 400.0 +private const val MIN_INNER_H_DP = 220.0 +private const val INNER_H_SPAN_DP = 300.0 + +private val SCALE_HOPS = floatArrayOf(1f, 1.25f, 1.5f, 2f) +private const val SCALE_MILLI = 1000 diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceFixture.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceFixture.kt index 3d296aa2c..a61e799f0 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceFixture.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceFixture.kt @@ -224,6 +224,22 @@ internal suspend fun robotDragTo( true } +/** + * Moves the pointer to [to] (physical screen px) with **no button held**: a + * hover, not a drag. + * + * Interpolated like [robotDragTo], so the window under it gets the enter and + * move events a real pointer delivers rather than one teleport — which is + * what anything driven by hover, a tab's card among them, actually reacts to. + * `null` when the host cannot inject input. + */ +internal suspend fun robotMoveTo( + to: Offset, + scale: Float, + steps: Int = ROBOT_DRAG_STEPS, + stepDelayMillis: Long = ROBOT_DRAG_STEP_MILLIS, +): Boolean? = robotDragTo(to, scale, steps, stepDelayMillis) + /** * Where the last robot gesture aimed and where the pointer landed — worth * putting in the description of anything a robot-driven case waits for, so a diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabStripMotionHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabStripMotionHeadfulCases.kt new file mode 100644 index 000000000..d91e2f197 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabStripMotionHeadfulCases.kt @@ -0,0 +1,369 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.LayoutDirection +import kotlin.math.abs + +/** + * Real-window coverage for the tab strip's *motion*: what a tab does on its + * way to a new place, and what the strip's published geometry does while it + * happens. + * + * 1. a reorder animates the drawing only — the slots a drop resolves against + * are the settled layout from the first frame; + * 2. a tab dragged along its own strip stays in the strip's hands: no ghost + * window, and the release reorders it; + * 3. a right-to-left strip runs from the right and carries a tab the same way; + * 4. the close button plays the tab out before the workspace drops it, and a + * new tab arrives to be opened rather than already open; + * 5. the numbers behind the motion: the carried tab is drawn at the pointer's + * travel, a crossed neighbour stands exactly one tab aside, the rest are at + * rest, and the release slides home before the order changes. + * + * Native Wayland is skipped: the drag there rides the platform's + * drag-and-drop session, which tells the source nothing about the pointer. + */ +internal object TabStripMotionHeadfulCases { + fun all(): List = + listOf( + aReorderAnimatesTheDrawingNotTheGeometry(), + aTabDraggedInItsOwnStripStaysInIt(), + aRightToLeftStripRunsFromTheRight(), + theCloseButtonPlaysTheTabOut(), + theCarriedTabAndItsNeighboursMoveByTheNumbers(), + ) + + /** + * A reorder moves the tabs at once as far as the workspace is concerned — + * only the drawing travels ([dev.nucleusframework.window.tao.TabReorderAnimation]). + * + * Sampled one frame after the reorder, well inside the animation: the slot + * rects have already swapped, and a drop resolved from a pointer over the + * first slot answers with the first index. Were the geometry animated, the + * strip would promise for a fifth of a second a drop it does not do. + */ + private fun aReorderAnimatesTheDrawingNotTheGeometry(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab workspace animates a reorder without moving the geometry a drop resolves against", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + val firstSlot = requireNotNull(fixture.tabSlotInWindowPx("Alpha")) + val gamma = fixture.tabId("Gamma") + + workspace.reorder(gamma, 0) + awaitUntil("Gamma is the first tab of the strip") { group.ids.first() == gamma } + // One frame, deep inside the 180 ms the drawing takes. + settle(ONE_FRAME_MILLIS) + val gammaSlot = requireNotNull(fixture.tabSlotInWindowPx("Gamma")) + check(abs(gammaSlot.left - firstSlot.left) <= LAYOUT_TOLERANCE_PX) { + "the slot a drop resolves against is still travelling: $gammaSlot vs $firstSlot" + } + check(abs(gammaSlot.width - firstSlot.width) <= LAYOUT_TOLERANCE_PX) { + "the first slot changed width on a reorder: $gammaSlot vs $firstSlot" + } + + // What the workspace answers a pointer, mid-animation: the + // left edge of the first slot is the first index. + val client = requireNotNull(workspace.stripGeometry(group)?.clientOriginPx()) + val atStart = client + Offset(firstSlot.left + EDGE_PROBE_PX, firstSlot.center.y) + val target = requireNotNull(workspace.dropTargetAt(atStart)) { "no drop target over the first slot" } + check(target.group === group && target.index == 0) { + "a drop over the first slot resolved to ${target.index}, not the first place" + } + + // And it settles where it was put. + settle(REORDER_SETTLE_MILLIS) + check(group.ids == listOf(gamma, fixture.tabId("Alpha"), fixture.tabId("Beta"))) { + "the strip order drifted after the animation: ${group.ids}" + } + check( + abs( + requireNotNull(fixture.tabSlotInWindowPx("Gamma")).left - firstSlot.left, + ) <= LAYOUT_TOLERANCE_PX, + ) { + "the settled slot moved" + } + }, + ) + } + + /** + * The browser gesture: a tab dragged along its own strip never leaves it. + * No ghost window is published while the pointer is over the strip — the + * strip draws the tab under the pointer and its neighbours make room — and + * the release is a reorder. Leave the strip and the ghost appears, which is + * what says the tab is being taken out; come back and it is put away again. + */ + private fun aTabDraggedInItsOwnStripStaysInIt(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab workspace a tab dragged along its own strip is held by the strip, not by a ghost", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + val gamma = fixture.tabId("Gamma") + val onGamma = requireNotNull(fixture.tabCenterPx("Gamma")) + // The leading edge of the first tab, where the insertion index + // is the first place — its centre would already be "after it". + val client = requireNotNull(workspace.stripGeometry(group)?.clientOriginPx()) + val alphaSlot = requireNotNull(fixture.tabSlotInWindowPx("Alpha")) + val onAlpha = client + Offset(alphaSlot.left + EDGE_PROBE_PX, alphaSlot.center.y) + val strip = requireNotNull(fixture.stripRectPx(group)) + + val session = requireNotNull(workspace.beginDrag(gamma, stripOrigin(first), onGamma)) + session.update(onGamma) + // Along the strip, over the first tab: in hand, still home. + session.update(onAlpha) + settle() + check(workspace.dragGhost == null) { "a ghost window for a tab still in its strip" } + check(workspace.draggedTab?.id == gamma) { "the drag lost its tab" } + check(workspace.dropPreview?.group === group && workspace.dropPreview?.index == 0) { + "the strip does not show the tab landing first: ${workspace.dropPreview}" + } + check(workspace.dragPointerScreenPx == onAlpha) { + "the strip was not told where the pointer is: ${workspace.dragPointerScreenPx}" + } + + // Out of the strip: now it really is leaving, so the ghost takes it. + val below = Offset(onAlpha.x, strip.bottom + OUT_OF_STRIP_PX) + session.update(below) + settle() + check(workspace.dragGhost?.tab?.id == gamma) { "no ghost once the tab left the strip" } + + // Back on the strip: the strip takes it in hand again. + session.update(onAlpha) + settle() + check(workspace.dragGhost == null) { "the ghost outlived the tab's return to the strip" } + + session.end(onAlpha) + awaitUntil("the tab was reordered rather than torn out") { + workspace.groups.size == 1 && group.ids.first() == gamma + } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + check(workspace.dragPointerScreenPx == null) { "the pointer outlived the drag" } + }, + ) + } + + /** + * A strip composed right to left — a Hebrew or Arabic app: the first tab is + * the *rightmost*, and a tab carried along it resolves the same insertion + * indices, since the strip's own geometry is what a drop is measured + * against whichever way the tabs run. + */ + private fun aRightToLeftStripRunsFromTheRight(): TaoWindowTestCase { + val fixture = + TabWorkspaceFixture( + initialTitles = listOf("Alpha", "Beta", "Gamma"), + layoutDirection = LayoutDirection.Rtl, + ) + return TaoWindowTestCase( + name = "tab workspace a right-to-left strip runs from the right and carries a tab the same way", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + val alpha = requireNotNull(fixture.tabSlotInWindowPx("Alpha")) + val beta = requireNotNull(fixture.tabSlotInWindowPx("Beta")) + val gammaSlot = requireNotNull(fixture.tabSlotInWindowPx("Gamma")) + + // The first tab is the rightmost, the last the leftmost. + check(alpha.left > beta.left && beta.left > gammaSlot.left) { + "the strip does not run from the right: alpha=$alpha beta=$beta gamma=$gammaSlot" + } + + // Carried from the last place to the first: the pointer aims at + // the trailing edge of the first tab, which in this direction is + // its right edge. + val client = requireNotNull(workspace.stripGeometry(group)?.clientOriginPx()) + val gamma = fixture.tabId("Gamma") + val onGamma = requireNotNull(fixture.tabCenterPx("Gamma")) + val atFirst = client + Offset(alpha.right - EDGE_PROBE_PX, alpha.center.y) + val session = requireNotNull(workspace.beginDrag(gamma, stripOrigin(first), onGamma)) + session.update(onGamma) + session.update(atFirst) + settle() + check(workspace.dragGhost == null) { "a ghost for a tab still in its own strip" } + check(workspace.dropPreview?.group === group && workspace.dropPreview?.index == 0) { + "the right edge of the first tab is not the first place: ${workspace.dropPreview}" + } + session.end(atFirst) + awaitUntil("the tab took the first place") { group.ids.first() == gamma } + settle(REORDER_SETTLE_MILLIS) + // And it is the rightmost tab now, geometry included. + val settled = requireNotNull(fixture.tabSlotInWindowPx("Gamma")) + check(abs(settled.right - alpha.right) <= LAYOUT_TOLERANCE_PX) { + "the reordered tab is not where the first slot is: $settled vs $alpha" + } + }, + ) + } + + /** + * The strip's close button shuts the tab's width before the workspace hears + * about it, which is what makes a close a motion rather than a jump: right + * after the click the tab is still there, and it is gone once the animation + * has had its time. + * + * The other half of the same contract: a tab the strip has not shown yet is + * marked as arriving, so it opens by width instead of appearing at its full + * one — see `TabEntry.isEntering`. + */ + private fun theCloseButtonPlaysTheTabOut(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab workspace the close button plays the tab out before the workspace drops it", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val slot = requireNotNull(fixture.tabSlotInWindowPx("Beta")) + val driver = SyntheticPointerDriver(first) + + // The close button of the stock tab sits at its trailing edge. + val closeButton = Offset(slot.right - CLOSE_BUTTON_INSET_PX, slot.center.y) + driver.click(closeButton) + settle(ONE_FRAME_MILLIS) + check(workspace.tab(beta) != null) { + "the workspace dropped the tab before the strip could play it out" + } + awaitUntil("the tab is gone once its width has shut") { workspace.tab(beta) == null } + check(requireNotNull(fixture.groupOf("Alpha")).ids.size == 2) { + "the strip did not settle on two tabs: ${fixture.groupOf("Alpha")?.ids}" + } + + // A tab declared now has not been shown yet: it is marked as arriving. + fixture.titles += "Delta" + awaitUntil("Delta is declared") { workspace.tab(fixture.tabId("Delta")) != null } + awaitUntil("and the strip has taken it in hand") { + workspace.tab(fixture.tabId("Delta"))?.isEntering == false + } + settle() + check(requireNotNull(fixture.groupOf("Alpha")).ids.size == 3) { "Delta did not join the strip" } + }, + ) + } + + /** + * What the strip's motion actually is, asserted rather than looked at: the + * tab in hand is drawn at exactly the pointer's travel since the grab, a + * neighbour whose centre that tab's leading edge has crossed comes to rest + * exactly one tab-width aside, a neighbour it has not reached stays at + * zero, and the release slides the carried tab into the crossed + * neighbour's slot *before* the order changes — every offset back to zero + * once it has. + */ + private fun theCarriedTabAndItsNeighboursMoveByTheNumbers(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab workspace the carried tab and its neighbours move by the numbers", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + val motion = requireNotNull(workspace.motionOf(group)) { "the strip published no motion" } + val gamma = fixture.tabId("Gamma") + val beta = fixture.tabId("Beta") + val alpha = fixture.tabId("Alpha") + val gammaSlot = requireNotNull(motion.slotOf(gamma)) { "no slot for the tab to be carried" } + val betaSlot = requireNotNull(motion.slotOf(beta)) + val width = gammaSlot.width + check(width > MIN_TAB_WIDTH_PX) { "a tab of $width px is too narrow to carry meaningfully" } + + // Grabbed in the middle of the last tab, then carried far + // enough left that its leading edge passes the middle tab's + // centre — the library's rule, and ours. + // The workspace's own drag: where the app places its windows, + // that is what the strip animates from. The local gesture of a + // compositor-placed window is covered on the Wayland leg. + val grab = requireNotNull(fixture.tabCenterPx("Gamma")) + val session = requireNotNull(workspace.beginDrag(gamma, stripOrigin(first), grab)) + session.update(grab) + val travel = -(width * CARRY_SLOTS) + val carriedTo = grab + Offset(travel, 0f) + session.update(carriedTo) + + awaitUntil("the middle tab has stepped aside by one tab: ${motion.drawnOffsetOf(beta)}") { + abs(motion.drawnOffsetOf(beta) - width) <= MOTION_TOLERANCE_PX + } + check(abs(motion.drawnOffsetOf(gamma) - travel) <= MOTION_TOLERANCE_PX) { + "the carried tab is drawn at ${motion.drawnOffsetOf(gamma)} px, the pointer travelled $travel" + } + check(abs(motion.drawnOffsetOf(alpha)) <= MOTION_TOLERANCE_PX) { + "a tab the carried one never reached moved: ${motion.drawnOffsetOf(alpha)}" + } + check(motion.slotOf(gamma) == gammaSlot && motion.slotOf(beta) == betaSlot) { + "the motion moved the layout: the slots a drop resolves against must not budge" + } + + // Released: it slides into the middle tab's slot, and only then + // is the order changed — with every offset back to zero. + session.end(carriedTo) + awaitUntil("the reorder is applied once the slide is over") { + group.ids == listOf(alpha, gamma, beta) + } + check(abs(motion.drawnOffsetOf(gamma)) <= MOTION_TOLERANCE_PX) { + "the tab kept an offset after the order changed: ${motion.drawnOffsetOf(gamma)}" + } + check(abs(motion.drawnOffsetOf(beta)) <= MOTION_TOLERANCE_PX) { + "a neighbour kept an offset after the order changed: ${motion.drawnOffsetOf(beta)}" + } + check(workspace.pendingReorder == null) { "the settle was never cleared" } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + }, + ) + } + + /** One frame at 60 Hz: long enough for the reorder to be laid out, far from the animation's end. */ + private const val ONE_FRAME_MILLIS = 24L + + /** Comfortably past the slide home. */ + private const val REORDER_SETTLE_MILLIS = 400L + + /** Just inside a slot's leading edge: the index before that tab. */ + private const val EDGE_PROBE_PX = 4f + + /** Below the strip: the window's body, where a dragged tab is out of the strip's hands. */ + private const val OUT_OF_STRIP_PX = 60f + + /** Inside a tab's trailing edge, where the stock strip puts its close button. */ + private const val CLOSE_BUTTON_INSET_PX = 12f + + /** Far enough for the carried tab's leading edge to pass one neighbour's centre. */ + private const val CARRY_SLOTS = 0.8f + + /** A spring settles within a pixel; anything larger is a wrong number, not a rounding. */ + private const val MOTION_TOLERANCE_PX = 2f + + /** Below this a tab is too narrow for the case to mean anything. */ + private const val MIN_TAB_WIDTH_PX = 40f +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceFixture.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceFixture.kt index 6f84dab1c..6c9f131fc 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceFixture.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceFixture.kt @@ -4,9 +4,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.SideEffect @@ -19,7 +23,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState @@ -27,10 +35,13 @@ import dev.nucleusframework.window.tao.ApplicationScope import dev.nucleusframework.window.tao.LocalTaoWindow import dev.nucleusframework.window.tao.Tab import dev.nucleusframework.window.tao.TabDragOrigin +import dev.nucleusframework.window.tao.TabHoverPreview +import dev.nucleusframework.window.tao.TabStrip import dev.nucleusframework.window.tao.TabWindowGroup import dev.nucleusframework.window.tao.TabWindows import dev.nucleusframework.window.tao.TabWorkspace import dev.nucleusframework.window.tao.TaoWindow +import kotlin.time.Duration.Companion.milliseconds /** * Everything one tab case observes; fresh per case, so cases never share @@ -51,6 +62,14 @@ internal class TabWorkspaceFixture( * drops should have to reason about. */ private val fileDropTargets: Boolean = false, + /** The direction the strip is composed in: a right-to-left app lays its tabs out from the right. */ + private val layoutDirection: LayoutDirection = LayoutDirection.Ltr, + /** + * When `true`, the strip is given a hover card that records itself in + * [shownHoverCard]. Off by default: it puts a popup over the window, which + * no case that is not about hovering should have to reason about. + */ + private val hoverPreview: Boolean = false, ) { val workspace = TabWorkspace(defaultWindowSize = windowSize) @@ -62,6 +81,12 @@ internal class TabWorkspaceFixture( /** Ids in declaration order; a case may add to this to open a tab mid-run. */ val titles = mutableStateListOf(*initialTitles.toTypedArray()) + /** Bounds of the window-chrome strip the body wrapper draws, per group, in window px. */ + val bodyWrapperBounds = mutableStateOf>(emptyMap()) + + /** How many times a body wrapper was built, over every window of the run. */ + val bodyWrapperBuilds = mutableIntStateOf(0) + /** * The windows each tab's body is composed in, by tab id, oldest host first. * @@ -90,6 +115,35 @@ internal class TabWorkspaceFixture( */ val bodyIncarnations = mutableStateOf>(emptyMap()) + /** The tab whose hover card is composed right now, or `null` while none is. */ + val shownHoverCard = mutableStateOf(null) + + /** How many hover cards have been composed over the run. */ + val hoverCardBuilds = mutableIntStateOf(0) + + /** + * The card the strip is given when the fixture was built with + * `hoverPreview`: a plain square that reports which tab it belongs to for + * as long as it is composed. + * + * A short delay rather than the stock one, so a case does not spend most + * of its time waiting; the delay itself is not asserted — a wall-clock + * threshold is exactly what makes a case flaky on a loaded runner. + */ + private val hoverCard: TabHoverPreview? = + if (!hoverPreview) { + null + } else { + TabHoverPreview(delay = HOVER_CARD_DELAY_MILLIS.milliseconds) { + DisposableEffect(tab.id) { + shownHoverCard.value = tab.id + hoverCardBuilds.value++ + onDispose { if (shownHoverCard.value == tab.id) shownHoverCard.value = null } + } + Box(Modifier.size(HOVER_CARD_W_DP.dp, HOVER_CARD_H_DP.dp).background(Color(0xFF3AA76D))) + } + } + /** Set once [TabWindows] reports the last window gone. */ val lastWindowClosed = mutableStateOf(false) @@ -188,6 +242,38 @@ internal class TabWorkspaceFixture( lastWindowClosed.value = true lastWindowClosedCount.value++ }, + strip = { + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + TabStrip(hoverPreview = hoverCard) + } + }, + // The app's window-level chrome: a strip of its own above the tab + // body, recording where it landed and how many times it was built, + // so a case can tell "moved" from "rebuilt". + windowBodyWrapper = { body -> + val id = workspace.groupOf(window)?.id + val incarnation = remember { Any() } + DisposableEffect(incarnation) { + bodyWrapperBuilds.value++ + onDispose { if (id != null) bodyWrapperBounds.value = bodyWrapperBounds.value - id } + } + Column(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxWidth() + .height(BODY_CHROME_H_DP.dp) + .onGloballyPositioned { + if (id != + null + ) { + bodyWrapperBounds.value = + bodyWrapperBounds.value + (id to it.boundsInWindow()) + } + }, + ) + Box(Modifier.fillMaxWidth().weight(1f)) { body() } + } + }, ) for (title in titles) { val id = tabId(title) @@ -253,6 +339,11 @@ internal const val TAB_SAVED_CLICKS = 5 /** Vertical grab point inside a tab strip, in dp from the strip's top. */ internal const val TAB_GRAB_Y_DP = 10f +/** The fixture's hover card: quick to appear, and big enough to be seen on a screenshot. */ +private const val HOVER_CARD_DELAY_MILLIS = 120 +private const val HOVER_CARD_W_DP = 180 +private const val HOVER_CARD_H_DP = 90 + /** Far enough from every window that a drop there can only mean "tear off". */ internal const val TAB_DROP_FAR_PX = 340f @@ -317,6 +408,37 @@ internal suspend fun TaoWindowTestScope.awaitTabWindows( ) } +/** + * [awaitTabWindows] without the screen half: waits for the window, the body + * and the strip's slots *in the window*, which is all a compositor-placed + * surface publishes. + */ +internal suspend fun TaoWindowTestScope.awaitTabWindowsInWindow( + fixture: TabWorkspaceFixture, + vararg titles: String, +): TaoWindow { + awaitUntil("case window mapped") { bounds() != null } + awaitUntil("every tab declared") { titles.all { fixture.workspace.tab(fixture.tabId(it)) != null } } + awaitUntil("a tab window is mapped with a real size") { + fixture.workspace.groups + .firstOrNull() + ?.window + ?.hasRealFramePx() == true + } + awaitUntil("the selected tab's body is composed") { fixture.composedBodies.value > 0 } + awaitUntil("the strip published its slots in the window") { + val group = fixture.workspace.groups.firstOrNull() ?: return@awaitUntil false + val strip = fixture.workspace.stripGeometry(group)?.layoutBoundsInWindowPx + strip?.isEmpty == false && group.slotsInWindowPx.size >= group.ids.size + } + settle(SETTLE_AFTER_MAP_MILLIS) + return requireNotNull( + fixture.workspace.groups + .first() + .window, + ) +} + /** Waits until [group]'s window is mapped with a laid-out strip, and returns it. */ internal suspend fun TaoWindowTestScope.awaitMappedStrip( fixture: TabWorkspaceFixture, @@ -410,3 +532,6 @@ internal suspend fun TaoWindowTestScope.awaitTabSlots( .window, ) } + +/** Height of the window-chrome strip the fixture's body wrapper draws above the tab body. */ +internal const val BODY_CHROME_H_DP = 24 diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceHeadfulCases.kt index f2fe3acf7..257661343 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceHeadfulCases.kt @@ -16,7 +16,13 @@ import kotlin.math.abs * reorder inside one window rebuilds nothing; * 3. a snapshot restores the windows it described, tabs declared afterwards * included; - * 4. selection: closing the selected tab picks a neighbour, in real windows. + * 4. selection: closing the selected tab picks a neighbour, in real windows; + * 5. the app's `windowBodyWrapper` is composed once per window, under the + * strip and above the tab body, and neither a selection change nor a + * tear-off rebuilds it. + * + * The strip's motion — carrying a tab, the neighbours stepping aside, tabs + * opening and closing — lives in [TabStripMotionHeadfulCases]. * * The edge cases — abrupt pointer jumps, a backing-scale change, minimize, * maximize, interrupted gestures — live in [TabWorkspaceStressHeadfulCases]. @@ -31,7 +37,59 @@ internal object TabWorkspaceHeadfulCases { stateSurvivesMovesAndReordersDoNotRebuild(), snapshotRestoresWindows(), closingTheSelectedTabPicksANeighbour(), + theWindowBodyWrapperHoldsTheWindowsOwnChrome(), + ) + + private fun theWindowBodyWrapperHoldsTheWindowsOwnChrome(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab workspace hosts the window's own chrome under the strip, built once per window", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + awaitUntil("the window chrome is measured") { fixture.bodyWrapperBounds.value[group.id] != null } + val chrome = requireNotNull(fixture.bodyWrapperBounds.value[group.id]) + val strip = requireNotNull(workspace.stripGeometry(group)).layoutBoundsInWindowPx + val scale = first.scaleFactor + check(chrome.top >= strip.bottom - LAYOUT_TOLERANCE_PX) { + "the window chrome is not under the strip: chrome=$chrome strip=$strip" + } + check(abs(chrome.height - BODY_CHROME_H_DP * scale) <= LAYOUT_TOLERANCE_PX) { + "the chrome is ${chrome.height} px tall, asked for ${BODY_CHROME_H_DP * scale}" + } + check(chrome.width > 0f) { "the chrome has no width" } + val builtOnce = fixture.bodyWrapperBuilds.value + check(builtOnce == 1) { "the body wrapper was built $builtOnce times for one window" } + + // A selection change is a tab change: the window's chrome is not part of it. + workspace.select(fixture.tabId("Beta")) + awaitUntil("Beta is composed") { fixture.windowOf("Beta") != null } + settle() + check(fixture.bodyWrapperBuilds.value == builtOnce) { + "a selection change rebuilt the window chrome: ${fixture.bodyWrapperBuilds.value}" + } + check(fixture.bodyWrapperBounds.value[group.id] == chrome) { "the chrome moved on a tab change" } + + // A tear-off adds a window, and with it one chrome of its own. + workspace.tearOff(fixture.tabId("Beta"), tearOffRectPx(first), scale) + awaitUntil("a second window is mapped") { + workspace.groups.size == 2 && workspace.groups.all { it.window?.hasRealFramePx() == true } + } + awaitUntil("the second window's chrome is measured") { fixture.bodyWrapperBounds.value.size == 2 } + settle() + check(fixture.bodyWrapperBuilds.value == builtOnce + 1) { + "the second window did not get exactly one chrome: ${fixture.bodyWrapperBuilds.value}" + } + check(fixture.bodyWrapperBounds.value[group.id] == chrome) { "the first window's chrome was rebuilt" } + }, ) + } /** * The gesture an app is judged on: pull a tab out into its own window with @@ -370,4 +428,28 @@ internal object TabWorkspaceHeadfulCases { }, ) } + + /** One frame at 60 Hz: long enough for the reorder to be laid out, far from the animation's end. */ + private const val ONE_FRAME_MILLIS = 24L + + /** Comfortably past [dev.nucleusframework.window.tao.TabReorderAnimation]. */ + private const val REORDER_SETTLE_MILLIS = 400L + + /** Just inside a slot's leading edge: the index before that tab. */ + private const val EDGE_PROBE_PX = 4f + + /** Below the strip: the window's body, where a dragged tab is out of the strip's hands. */ + private const val OUT_OF_STRIP_PX = 60f + + /** Inside a tab's trailing edge, where the stock strip puts its close button. */ + private const val CLOSE_BUTTON_INSET_PX = 12f + + /** Far enough for the carried tab's leading edge to pass one neighbour's centre. */ + private const val CARRY_SLOTS = 0.8f + + /** A spring settles within a pixel; anything larger is a wrong number, not a rounding. */ + private const val MOTION_TOLERANCE_PX = 2f + + /** Below this a tab is too narrow for the case to mean anything. */ + private const val MIN_TAB_WIDTH_PX = 40f } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMotionHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMotionHeadfulCases.kt index 04edda388..99420505c 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMotionHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMotionHeadfulCases.kt @@ -75,6 +75,10 @@ internal object TabWorkspaceMotionHeadfulCases { check(workspace.dropPreview?.group === second) { "round $round: the other strip did not answer a teleport: ${workspace.dropPreview}" } + // Another window's strip is a move, not a reorder: the tab + // is leaving this window, so the ghost carries it there. + val ghost = requireNotNull(workspace.dragGhost) { "round $round: the ghost was lost" } + check(ghost.screenRectPx.width > 0f) { "round $round: the ghost has no size" } session.update(nowhere) settle(JUMP_SETTLE_MILLIS) check(workspace.dropPreview == null) { "round $round: empty space previewed a drop" } @@ -83,8 +87,15 @@ internal object TabWorkspaceMotionHeadfulCases { check(workspace.dropPreview?.group === home) { "round $round: its own strip did not answer a teleport: ${workspace.dropPreview}" } - val ghost = requireNotNull(workspace.dragGhost) { "round $round: the ghost was lost" } - check(ghost.screenRectPx.width > 0f) { "round $round: the ghost has no size" } + // Back over its own strip the tab is in the strip's hands, + // which draws it under the pointer: no ghost window, and + // the pointer published for the strip to follow. + check(workspace.dragGhost == null) { + "round $round: a ghost over its own strip: ${workspace.dragGhost}" + } + check(workspace.dragPointerScreenPx == onHome) { + "round $round: the strip was not told the pointer: ${workspace.dragPointerScreenPx}" + } } // The last sample is the one that decides. @@ -177,7 +188,11 @@ internal object TabWorkspaceMotionHeadfulCases { session.update(grab) val onTheStrip = Offset(strip.left + strip.width * STRIP_MID_FRACTION, strip.center.y) - session.update(onTheStrip) + // Clear of its own strip, where the ghost is what carries the + // tab: over the strip itself there is none to compare against, + // since the strip holds the tab under the pointer instead. + val offTheStrip = Offset(onTheStrip.x, strip.bottom + OFF_STRIP_PX) + session.update(offTheStrip) val ghostAtStrip = requireNotNull(workspace.dragGhost).screenRectPx val garbage = @@ -193,7 +208,7 @@ internal object TabWorkspaceMotionHeadfulCases { check(ghost.screenRectPx == ghostAtStrip) { "an unusable sample ($sample) moved the ghost to ${ghost.screenRectPx}" } - check(workspace.dropPreview?.group === home) { "an unusable sample dropped the preview" } + check(workspace.dropPreview == null) { "an unusable sample invented a drop target" } } // Far outside every display, then the same sample twice. @@ -211,8 +226,12 @@ internal object TabWorkspaceMotionHeadfulCases { "the source window was resized by the excursion" } - // And the gesture still works: back on the strip, release. + // And the gesture still works: back on the strip — where the + // strip takes the tab back in hand — and released. session.update(onTheStrip) + check(workspace.dragGhost == null && workspace.dropPreview?.group === home) { + "its own strip did not take the tab back: ${workspace.dragGhost} ${workspace.dropPreview}" + } session.end(onTheStrip) awaitUntil("the tab is still in its window") { workspace.groups.size == 1 && fixture.groupOf("Beta") === home @@ -495,4 +514,7 @@ internal object TabWorkspaceMotionHeadfulCases { /** Both sides come from the same live geometry: rounding only. */ private const val STRIP_FOLLOW_TOLERANCE_PX = 8f + + /** Just under the strip: the body, where a dragged tab is out of the strip's hands. */ + private const val OFF_STRIP_PX = 40f } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMouseHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMouseHeadfulCases.kt index b7ae4d8f1..f41927141 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMouseHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMouseHeadfulCases.kt @@ -15,7 +15,9 @@ import androidx.compose.ui.geometry.Offset * target, not a patchwork of a grip and a selector; * 4. **a hover across two strips and back**, where the preview follows the * pointer from window to window and the drop acts on where it ended; - * 5. **a flick**, delivering as few samples as the OS will give. + * 5. **a flick**, delivering as few samples as the OS will give; + * 6. **a pointer resting on a tab**, which offers that tab's hover card — + * and every case where the card has to stay away. * * Native Wayland is skipped along with the rest of the tab suite; so is a host * that cannot inject input. @@ -28,6 +30,7 @@ internal object TabWorkspaceMouseHeadfulCases { robotClicksAnywhereInATabSelectIt(), robotHoverCrossesTwoStripsAndComesBack(), robotFlickBetweenStripsMerges(), + robotRestingOnATabOffersItsCard(), ) /** @@ -323,4 +326,92 @@ internal object TabWorkspaceMouseHeadfulCases { private const val SLOT_NEAR_Y = 0.2f private const val SLOT_MID_Y = 0.5f private const val SLOT_FAR_Y = 0.88f + + /** + * The hover card, under a real pointer: resting on a tab offers *that* + * tab's card, and the three places it has to stay away from — the tab + * already on screen, anywhere off the strip, and a tab that has just been + * clicked. + * + * The delay itself is not asserted. A wall-clock threshold on a loaded + * runner is exactly what makes a case flaky; what matters here is that a + * real pointer reaches the strip's slots at all, and that the popup opens + * over a real window — neither of which a headless case can tell. + */ + private fun robotRestingOnATabOffersItsCard(): TaoWindowTestCase { + val fixture = + TabWorkspaceFixture( + initialTitles = listOf("Alpha", "Beta", "Gamma"), + hoverPreview = true, + ) + return TaoWindowTestCase( + name = "tab mouse resting on a tab offers its hover card", + skip = { workspaceSkipReason() ?: robotSkipReason() }, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val alpha = fixture.tabId("Alpha") + val beta = fixture.tabId("Beta") + workspace.select(alpha) + awaitUntil("Alpha is the composed body") { fixture.windowOf("Alpha") === first } + first.focus() + awaitUntil("first window is focused") { first.isFocused } + + val onAlpha = requireNotNull(fixture.tabCenterPx("Alpha")) + val onBeta = requireNotNull(fixture.tabCenterPx("Beta")) + val strip = requireNotNull(fixture.stripRectPx(requireNotNull(fixture.groupOf("Alpha")))) + val inTheBody = Offset(strip.center.x, strip.bottom + BELOW_STRIP_PX) + + // Resting on a tab that is not the one being read: its card. + if (robotMoveTo(onBeta, first.scaleFactor) == null) { + System.err.println("[tab-mouse] robot became unavailable, nothing to assert") + return@TaoWindowTestCase + } + awaitUntil( + "the strip offers Beta's card — ${robotAim()}; ${fixture.geometryReport("Beta")}", + ) { fixture.shownHoverCard.value == beta } + + // The tab already on screen gets none: its body is right there. + checkNotNull(robotMoveTo(onAlpha, first.scaleFactor)) { "robot became unavailable mid-case" } + awaitUntil("the card goes away over the selected tab — ${robotAim()}") { + fixture.shownHoverCard.value == null + } + settle(HOVER_HOLD_MILLIS) + check(fixture.shownHoverCard.value == null) { "a card was offered for the tab on screen" } + + // Back on Beta, and it comes back. + checkNotNull(robotMoveTo(onBeta, first.scaleFactor)) { "robot became unavailable mid-case" } + awaitUntil("Beta's card comes back — ${robotAim()}") { fixture.shownHoverCard.value == beta } + + // Off the strip entirely: nothing is being pointed at. + checkNotNull(robotMoveTo(inTheBody, first.scaleFactor)) { "robot became unavailable mid-case" } + awaitUntil("the card goes away below the strip — ${robotAim()}") { + fixture.shownHoverCard.value == null + } + + // A click leaves no card under the pointer, however long it + // rests there: the tab it selected is now the one on screen. + checkNotNull( + robotPressAndDrag(onBeta, onBeta, first.scaleFactor, steps = 1, stepDelayMillis = 0), + ) { "robot became unavailable mid-case" } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + awaitUntil("the click selected Beta — ${robotAim()}") { + requireNotNull(fixture.groupOf("Beta")).selectedId == beta + } + settle(HOVER_HOLD_MILLIS) + check(fixture.shownHoverCard.value == null) { "a card sat under the tab that was just clicked" } + check(workspace.draggedTab == null && workspace.dragGhost == null) { "the click became a drag" } + }, + ) + } } + +/** How far below a strip a case reaches to leave it: well inside the body. */ +private const val BELOW_STRIP_PX = 80f + +/** Long enough for a card that should not be there to have shown up. */ +private const val HOVER_HOLD_MILLIS = 400L diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceStressHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceStressHeadfulCases.kt index 61ba25f32..f28104a5a 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceStressHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceStressHeadfulCases.kt @@ -78,9 +78,17 @@ internal object TabWorkspaceStressHeadfulCases { for (jump in jumps) { session.update(jump) settle(JUMP_SETTLE_MILLIS) - val ghost = requireNotNull(workspace.dragGhost) { "the ghost was lost at $jump" } - check(ghost.screenRectPx.width > 0f && ghost.screenRectPx.height > 0f) { - "the ghost has no size after jumping to $jump: ${ghost.screenRectPx}" + // Over its own strip the strip holds the tab under the + // pointer, so there is no ghost to check — anywhere else + // the ghost is what the user is dragging. + val ownStrip = workspace.dropPreview?.group === fixture.groupOf("Beta") + if (ownStrip) { + check(workspace.dragGhost == null) { "a ghost over its own strip at $jump" } + } else { + val ghost = requireNotNull(workspace.dragGhost) { "the ghost was lost at $jump" } + check(ghost.screenRectPx.width > 0f && ghost.screenRectPx.height > 0f) { + "the ghost has no size after jumping to $jump: ${ghost.screenRectPx}" + } } val bounds = requireNotNull(first.outerBoundsPx()) { "the source window was lost at $jump" } check(bounds[2] > 0 && bounds[3] > 0) { "the source window has no size after $jump" } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt index f06c4ef3b..7e27e86cd 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt @@ -386,7 +386,10 @@ public object TaoHeadfulTestSuiteMain { SatelliteWorkspaceHeadfulCases.all() + SatelliteWorkspaceStressHeadfulCases.all() + SatelliteWorkspaceMonkeyHeadfulCases.all() + + DockLayoutHeadfulCases.all() + + DockLayoutMonkeyHeadfulCases.all() + TabWorkspaceHeadfulCases.all() + + TabStripMotionHeadfulCases.all() + TabWorkspaceLifecycleHeadfulCases.all() + TabWorkspaceMotionHeadfulCases.all() + TabWorkspaceMouseHeadfulCases.all() + diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceHeadfulCases.kt index fd2079a3f..72f83a21f 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceHeadfulCases.kt @@ -5,6 +5,10 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import dev.nucleusframework.window.tao.DockSide import dev.nucleusframework.window.tao.DockTarget +import dev.nucleusframework.window.tao.DockTransferTarget +import dev.nucleusframework.window.tao.SatelliteCaptionStripWidth +import dev.nucleusframework.window.tao.SatelliteDragKind +import dev.nucleusframework.window.tao.SatellitePlacement import dev.nucleusframework.window.tao.TabDropTarget import dev.nucleusframework.window.tao.TransferDrop import kotlin.math.abs @@ -30,7 +34,17 @@ import kotlin.math.abs * 5. the ownership half is untouched: a floating satellite still hides while * its owner is maximized, and never publishes an owner offset it cannot * know; - * 6. tabs the same way: no record tears off, a record merges back. + * 6. tabs the same way: no record tears off, a record merges back; + * 9. the strip's own gesture: a tab carried along its strip reorders with no + * screen coordinate at all, and leaving the strip hands the drag to the + * platform's session, which is what lets another window preview the drop; + * 8. chrome is told the compositor places its window, the title bar reserves + * the caption strip for the compositor's move and the app's slot is + * composed inside it, and a satellite drag reports itself as carried by + * the platform session with no ghost window; + * 7. a drop over a stack resolves the rank under the pointer from window + * coordinates — its own rank being no move — and the record reorders the + * layers without rebuilding one. * * The adversarial half — lifecycle, concurrency, bursts, edge cases — lives in * [WaylandWorkspaceStressHeadfulCases]. Skipped everywhere that has @@ -44,8 +58,240 @@ internal object WaylandWorkspaceHeadfulCases { recordedZoneDocksAndNoRecordUndocks(), everyZoneResolvesFromAWindowCoordinate(), tabTransferDragTearsOffAndMergesBack(), + aTransferDropResolvesARankAndReorders(), + chromeIsToldTheCompositorPlacesTheWindow(), + theStripReordersAndDefersItsDrops(), ) + /** + * The gesture a compositor-placed window *can* carry, on real windows. + * + * Reordering asks nothing of the screen: the strip is handed the travel in + * its own coordinates and answers with the place the tab would take. A + * release clear of the strip cannot be hit-tested — every toplevel reports + * a fake origin here — so the drop is deferred, and the window the + * compositor hands the pointer to next is the one that resolves it: into + * its strip, or into a window of its own. Nothing claims it and the tab is + * torn off, which is what a release over the desktop has always done. + */ + private fun theStripReordersAndDefersItsDrops(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "native Wayland: the strip reorders without the screen and lets go when the tab leaves it", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindowsInWindow(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + check(!first.canPlaceOnScreen) { "case premise: the window must be compositor-placed" } + check(workspace.beginDrag(fixture.tabId("Gamma"), stripOrigin(first), Offset.Zero) == null) { + "the screen-space drag started on a window the app cannot place" + } + + // ── a reorder, with nothing but window coordinates ── + val motion = requireNotNull(workspace.motionOf(group)) { "the strip published no motion" } + val gamma = fixture.tabId("Gamma") + val beta = fixture.tabId("Beta") + val alpha = fixture.tabId("Alpha") + val slot = requireNotNull(motion.slotOf(gamma)) + val driver = SyntheticPointerDriver(first) + driver.moveTo(slot.center) + driver.press() + driver.moveTo(slot.center + Offset(-SLOP_PX, 0f)) + driver.moveTo(slot.center + Offset(-slot.width * CARRY_FRACTION, 0f)) + awaitUntil("the strip shows the tab landing before its neighbour") { + workspace.dropPreview?.let { it.group === group && it.index == 1 } == true + } + check(workspace.dragGhost == null) { "a ghost window on a compositor-placed surface" } + driver.release() + awaitUntil("the reorder is applied once the tab has slid home") { + group.ids == listOf(alpha, gamma, beta) + } + + // ── leaving the strip hands the gesture to the platform ── + // + // The strip lets go the moment the pointer is out of it: from + // there the drag is the platform's own session, which is what + // gives every *other* window the pointer in its coordinates — + // the only way a compositor-placed client can preview a drop + // it does not own. The session itself is the compositor's to + // start, so what is asserted here is the strip's half: it + // stops carrying, and the tab is where it was. + val gammaSlot = requireNotNull(motion.slotOf(gamma)) + driver.moveTo(gammaSlot.center) + driver.press() + driver.moveTo(gammaSlot.center + Offset(0f, SLOP_PX)) + driver.moveTo(gammaSlot.center + Offset(0f, OUT_OF_STRIP_PX)) + awaitUntil("the strip let go of the tab it was carrying") { motion.held == null } + driver.release() + // Released with nothing under it, the platform session leaves + // the tab a window of its own — the tear-out a void release has + // always been, now reached through the session that also gives + // another window the drop preview. + awaitUntil("the tab left the strip it was dragged out of") { + !group.ids.contains(gamma) && workspace.tab(gamma) != null + } + check(workspace.dragGhost == null) { "a ghost window on a compositor-placed surface" } + check(group.ids == listOf(alpha, beta)) { "the tabs left behind are not in order: ${group.ids}" } + }, + ) + } + + /** + * The other half of the X11 case in `DockLayoutHeadfulCases`: here the + * compositor places the window, so [SatelliteScope.isCompositorPlaced] is + * `true` for the floating palette, its title bar reserves + * [SatelliteCaptionStripWidth] for the compositor's move with the app's + * `floatingCaption` composed inside it, and a satellite drag is a + * [SatelliteDragKind.Transfer] that publishes no ghost window. + * + * The docked panel reads its host, which is compositor-placed too. + */ + private fun chromeIsToldTheCompositorPlacesTheWindow(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = 120.dp)), + DockPanelSpec( + NOTES, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ), + ), + ) + return TaoWindowTestCase( + name = "native Wayland: chrome is told the compositor places the window, and the caption strip is reserved", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodiesInWindow(fixture, TREE) + awaitUntil("the palette floats") { + fixture.floatingWindows.value[NOTES]?.hasRealFramePx() == true + } + settle(SETTLE_AFTER_MAP_MILLIS) + val floating = requireNotNull(fixture.floatingWindows.value[NOTES]) + check(!floating.canPlaceOnScreen) { "case premise: the palette must be compositor-placed" } + + awaitUntil("the palette's chrome learned how its window is placed") { + fixture.compositorPlacedFloating.value[NOTES] == true + } + check(fixture.compositorPlacedDocked.value[TREE] == true) { + "the panel was told its host places itself: ${fixture.compositorPlacedDocked.value}" + } + awaitUntil("the caption strip is composed") { fixture.captionBounds.value[NOTES] != null } + val caption = requireNotNull(fixture.captionBounds.value[NOTES]) + val expectedPx = SatelliteCaptionStripWidth.value * floating.scaleFactor + check(abs(caption.width - expectedPx) <= LAYOUT_TOLERANCE_PX) { + "the reserved strip is ${caption.width} px, SatelliteCaptionStripWidth is $expectedPx" + } + check(caption.height > 0f) { "the strip has no height, so nothing can be aimed at it" } + + // The drag says how it is carried, and no ghost window follows. + check(workspace.dragKind == null) { "a drag is reported before one starts" } + val session = requireNotNull(workspace.beginTransferDrag(NOTES, floatingOrigin(floating))) + check(workspace.dragKind == SatelliteDragKind.Transfer) { + "the platform session carries it, but the kind is ${workspace.dragKind}" + } + check(workspace.dragGhost == null) { "a ghost window followed a transfer drag" } + check(workspace.draggedSatellite?.id == NOTES) { "the dragged satellite is not published" } + session.cancel() + check(workspace.dragKind == null && workspace.publishesNoDragFeedback()) { "feedback left behind" } + + // The screen-space API is still refused here, which is why the + // split exists in the first place. + check(workspace.beginDrag(NOTES, floatingOrigin(floating), Offset.Zero) == null) { + "a screen drag started on a window the app cannot place" + } + }, + ) + } + + private fun aTransferDropResolvesARankAndReorders(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, order = 0, extent = 100.dp)), + DockPanelSpec(TOC, SatellitePlacement.Docked(DockSide.Right, order = 1, extent = 120.dp)), + DockPanelSpec(NOTES, SatellitePlacement.Docked(DockSide.Right, order = 2, extent = 90.dp)), + ), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "native Wayland: a transfer drop over a stack resolves the rank under the pointer and reorders", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodiesInWindow(fixture, TREE, TOC, NOTES) + val geometry = requireNotNull(workspace.dockHostGeometry(window)) + val layout = geometry.layoutBoundsInWindowPx + val tree = requireNotNull(fixture.panelBounds.value[TREE]) + val notesBefore = requireNotNull(fixture.panelBounds.value[NOTES]) + + val session = requireNotNull(workspace.beginTransferDrag(NOTES, panelOrigin(window))) + awaitUntil("the layout published its drop zones") { geometry.zoneBoundsInWindowPx.isNotEmpty() } + val target = DockTransferTarget(workspace, window, geometry) + // Window coordinates, the only ones an inbound event carries. + val overTreeOuterHalf = Offset(tree.left + tree.width * OUTER_HALF, layout.center.y) + check(target.zoneAt(overTreeOuterHalf) == DockTarget(window, DockSide.Right, 0)) { + "the outer half of the first layer did not resolve to rank 0: ${target.zoneAt(overTreeOuterHalf)}" + } + check(target.zoneAt(notesBefore.center) == DockTarget(window, DockSide.Right, 2)) { + "the panel's own area did not resolve to its own rank: ${target.zoneAt(notesBefore.center)}" + } + check( + target.zoneAt(notesBefore.center) == session.own, + ) { "its own rank is not what the session calls its own" } + // Clear of the left strip and short of the layers: content. + val content = Offset(layout.left + CONTENT_PROBE_DP * window.scaleFactor, layout.center.y) + check(target.zoneAt(content) == null) { "the content is no zone: ${target.zoneAt(content)}" } + + session.drop = TransferDrop.Dock(requireNotNull(target.zoneAt(overTreeOuterHalf))) + session.end() + awaitUntil("the notes are the first rank") { + (workspace.satellite(NOTES)?.placement as? SatellitePlacement.Docked)?.order == 0 + } + awaitDockedBodiesInWindow(fixture, TREE, TOC, NOTES) + val notes = requireNotNull(fixture.panelBounds.value[NOTES]) + val treeAfter = requireNotNull(fixture.panelBounds.value[TREE]) + check( + near(notes.right, layout.right, LAYOUT_TOLERANCE_PX * 2) && + treeAfter.right <= notes.left + LAYOUT_TOLERANCE_PX, + ) { + "the notes are not the outermost layer: notes=$notes tree=$treeAfter" + } + check( + near(notes.width, notesBefore.width), + ) { "the notes changed width: ${notesBefore.width} -> ${notes.width}" } + check( + fixture.incarnationsOf(TREE) == 1 && + fixture.incarnationsOf(TOC) == 1 && + fixture.incarnationsOf(NOTES) == 1, + ) { + "a reorder rebuilt a panel: ${fixture.incarnations.value}" + } + check(workspace.publishesNoDragFeedback()) { "feedback left behind after the session ended" } + }, + ) + } + private fun screenApiRefusedTransferSessionStarts(): TaoWindowTestCase { val fixture = SatelliteWorkspaceFixture() return TaoWindowTestCase( @@ -288,4 +534,23 @@ internal object WaylandWorkspaceHeadfulCases { /** Any finite point: neither the refusal nor a zone probe may depend on where it is. */ private const val PROBE_PX = 100f + + private const val TREE = "tree" + private const val TOC = "toc" + private const val NOTES = "notes" + + /** Well inside the outer half of a layer: the rank ahead of it. */ + private const val OUTER_HALF = 0.8f + + /** A point past the left strip and well short of the 310 dp of layers on the right, in a 520 dp layout. */ + private const val CONTENT_PROBE_DP = 100f + + /** Past Compose's touch slop, so the gesture is a drag and not a click. */ + private const val SLOP_PX = 24f + + /** Far enough along the strip for the carried tab's edge to cross its neighbour's centre. */ + private const val CARRY_FRACTION = 0.8f + + /** Below the strip: the window's body, where a released tab is out of the strip's hands. */ + private const val OUT_OF_STRIP_PX = 120f } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceChaosSupport.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceChaosSupport.kt index 9a3cf1d49..9637ea87b 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceChaosSupport.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceChaosSupport.kt @@ -355,6 +355,10 @@ internal class TabSatellitesFixture( /** The floating window of each group's palette, by group id. */ val floatingPalette = mutableStateOf>(emptyMap()) + /** Which palette body wrote [panelHost] / [floatingPalette] last, so only it may clear the entry. */ + private val publishedPanel = HashMap() + private val publishedFloating = HashMap() + /** The tab title each group's palette is currently drawing, by group id. */ val paletteShows = mutableStateOf>(emptyMap()) @@ -459,9 +463,13 @@ internal class TabSatellitesFixture( paletteCounters.value = paletteCounters.value + (group.id to clicks) paletteShows.value = paletteShows.value + (group.id to shown) if (docked) { - if (window != null) panelHost.value = panelHost.value + (group.id to window) + if (window != null) { + panelHost.value = panelHost.value + (group.id to window) + publishedPanel[group.id] = incarnation + } } else if (window != null) { floatingPalette.value = floatingPalette.value + (group.id to window) + publishedFloating[group.id] = incarnation } } DisposableEffect(incarnation) { @@ -470,10 +478,19 @@ internal class TabSatellitesFixture( paletteIncarnations.value + (group.id to (paletteIncarnations.value[group.id] ?: 0) + 1) onDispose { composedPalettes.value-- + // Only the body that published the entry may withdraw it. + // A panel moving from one tab body's DockLayout to the + // next is disposed *after* its successor composed — movable + // content is released at the end of the frame — so the + // leaving body must not erase what the arriving one wrote. if (docked) { - if (panelHost.value[group.id] === window) panelHost.value = panelHost.value - group.id - } else if (floatingPalette.value[group.id] === window) { + if (publishedPanel[group.id] === incarnation) { + panelHost.value = panelHost.value - group.id + publishedPanel.remove(group.id) + } + } else if (publishedFloating[group.id] === incarnation) { floatingPalette.value = floatingPalette.value - group.id + publishedFloating.remove(group.id) } } } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceFileDropHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceFileDropHeadfulCases.kt index 207bd808a..529e8060f 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceFileDropHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceFileDropHeadfulCases.kt @@ -480,7 +480,12 @@ internal object WorkspaceFileDropHeadfulCases { session.update(away) check(workspace.dragGhost != null) { "the tab tear-out must be previewed" } + // Lifting a tab selects it, so the body under the pointer is the + // dragged tab's from the grab onwards. Waited for rather than + // assumed: the files would otherwise land in whichever body was + // still composed a frame ago. val selected = requireNotNull(workspace.selectedTab(group)).title + awaitUntil("the lifted tab's body is the one composed") { fixture.windowOf(selected) === first } val point = contentPointPx(first, HALF, BOTTOM_QUARTER) check(first.fileDragAndDrop(point, files)) { "the file drop was refused mid tab drag" } awaitUntil("the files reached the selected tab") { fixture.dropLog(selected).drops.value == 1 } diff --git a/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/DemoState.kt b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/DemoState.kt index 0e94080a6..8f5f64163 100644 --- a/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/DemoState.kt +++ b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/DemoState.kt @@ -30,7 +30,16 @@ class Document( * user closes has to be dropped from it ([forget]) or it would be declared again. */ class DemoState { - val workspace = TabWorkspace(defaultWindowSize = DpSize(WINDOW_WIDTH_DP.dp, WINDOW_HEIGHT_DP.dp)) + // `captureThumbnails` keeps a reduced picture of each tab's editor for the + // hover card to draw. Off by default — a layer and a readback per tab. + val workspace = + TabWorkspace( + defaultWindowSize = DpSize(WINDOW_WIDTH_DP.dp, WINDOW_HEIGHT_DP.dp), + captureThumbnails = true, + ) + + /** The file behind a tab id, for chrome that draws more than a title. */ + fun document(id: String): Document? = documents.firstOrNull { it.id == id } /** The open files, in declaration order. One tab each. */ val documents = diff --git a/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/JewelTabStrip.kt b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/JewelTabStrip.kt index ee4e8a59d..d0b5f62d5 100644 --- a/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/JewelTabStrip.kt +++ b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/JewelTabStrip.kt @@ -1,18 +1,36 @@ package dev.nucleusframework.jeweltabsdemo +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.TabDropGhost +import dev.nucleusframework.window.tao.TabDropGhostCard +import dev.nucleusframework.window.tao.TabEntry +import dev.nucleusframework.window.tao.TabHoverPreview +import dev.nucleusframework.window.tao.TabHoverPreviewPopup +import dev.nucleusframework.window.tao.TabHoverPreviewScope import dev.nucleusframework.window.tao.TabStripScope +import dev.nucleusframework.window.tao.dropGhost import dev.nucleusframework.window.tao.tabDragHandle import dev.nucleusframework.window.tao.tabSlot import dev.nucleusframework.window.tao.tabStripGeometry @@ -48,56 +66,128 @@ import org.jetbrains.jewel.ui.theme.editorTabStyle * * A `Modifier` on [TabData] would remove the need for any of this: the slot, * the grip and the click would go on the tab itself. + * + * The hover card is the other half of that contract: [TabHoverPreviewPopup] + * needs nothing but the slots this strip already marks, and the card itself is + * drawn here, in Jewel's own colours ([JewelTabHoverCard]) — the workspace + * neither knows nor imposes what a preview looks like. */ @Composable -fun TabStripScope.JewelEditorTabStrip(onNewTab: () -> Unit) { +fun TabStripScope.JewelEditorTabStrip( + demo: DemoState, + onNewTab: () -> Unit, +) { val entries = tabs + // A tab dragged over this strip from another window is shown taking its + // place: the same card it travels under, as wide as it is, opened among + // the tabs where the release would put it. + val ghost = dropGhost Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { + val tabData = entries.mapIndexed { index, entry -> editorTab(index, entry) }.toMutableList() + if (ghost != null) tabData.add(ghost.index, ghostTab(ghost)) TabStrip( - tabs = - entries.mapIndexed { index, entry -> - TabData.Editor( - selected = entry.id == group.selectedId, - closable = true, - onClose = { workspace.close(entry.id) }, - onClick = { workspace.select(entry.id) }, - content = { tabState -> - // One element for the whole gesture surface, filling - // the tab: the slot the strip publishes, the grip a - // drag starts from and the click that selects are - // the same box, so there is no part of a tab that - // reacts to one and not the others. Putting them on - // the label alone leaves selection to the padding - // around it — a sliver at the edges — while the - // label drags, which is exactly as odd as it sounds. - Box( - modifier = - Modifier - .fillMaxSize() - .tabSlot(group, index) - .tabDragHandle(workspace, entry) - .clickable { workspace.select(entry.id) }, - contentAlignment = Alignment.CenterStart, - ) { - // `tabContentAlpha` is Jewel's own: the label - // dims exactly as it does in the IDE when the - // tab is unselected or its window loses focus. - Text(entry.title, modifier = Modifier.tabContentAlpha(state = tabState)) - } - }, - ) - }, + tabs = tabData, style = JewelTheme.editorTabStyle, modifier = Modifier.weight(1f).tabStripGeometry(workspace, group), ) NewTabButton(onNewTab) } + // Anchored on the slots marked above, so it follows the pointer from tab to + // tab without this strip tracking anything itself. + val preview = remember(demo) { TabHoverPreview { JewelTabHoverCard(demo) } } + TabHoverPreviewPopup(preview) +} + +/** + * The hover card of one tab, drawn by the demo from end to end: the file name, + * its first line, and the picture the workspace kept of its editor. + * + * Nothing of the stock card is used — [TabHoverPreview] takes the whole + * composable, so an app's preview looks like the rest of its design system + * rather than like the window chrome. + */ +@Composable +private fun TabHoverPreviewScope.JewelTabHoverCard(demo: DemoState) { + val document = demo.document(tab.id) + Column( + modifier = + Modifier + .width(CARD_WIDTH_DP.dp) + .background(JewelTheme.globalColors.panelBackground) + .border(1.dp, JewelTheme.globalColors.borders.normal) + .padding(CARD_PADDING_DP.dp), + ) { + Text(tab.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + val draft = document?.draft ?: "" + val firstLine = draft.substringBefore('\n') + if (firstLine.isNotBlank()) { + Spacer(Modifier.height(CARD_GAP_DP.dp)) + Text( + text = firstLine, + color = JewelTheme.globalColors.text.info, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + thumbnail?.let { picture -> + Spacer(Modifier.height(CARD_GAP_DP.dp)) + Image( + bitmap = picture, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(picture.width.toFloat() / picture.height.toFloat()), + contentScale = ContentScale.Crop, + ) + } + } } +/** The slot a tab from another window would take, as a Jewel tab that is nothing but the card. */ +private fun ghostTab(ghost: TabDropGhost): TabData = + TabData.Editor(selected = false, closable = false, content = { TabDropGhostCard(ghost) }) + +/** One document as a Jewel editor tab, its whole surface the slot, the grip and the click. */ +private fun TabStripScope.editorTab( + index: Int, + entry: TabEntry, +): TabData = + TabData.Editor( + selected = entry.id == group.selectedId, + closable = true, + onClose = { workspace.close(entry.id) }, + onClick = { workspace.select(entry.id) }, + content = { tabState -> + // One element for the whole gesture surface, filling + // the tab: the slot the strip publishes, the grip a + // drag starts from and the click that selects are + // the same box, so there is no part of a tab that + // reacts to one and not the others. Putting them on + // the label alone leaves selection to the padding + // around it — a sliver at the edges — while the + // label drags, which is exactly as odd as it sounds. + Box( + modifier = + Modifier + .fillMaxSize() + .tabSlot(group, index) + .tabDragHandle(workspace, entry) + .clickable { workspace.select(entry.id) }, + contentAlignment = Alignment.CenterStart, + ) { + // `tabContentAlpha` is Jewel's own: the label + // dims exactly as it does in the IDE when the + // tab is unselected or its window loses focus. + Text(entry.title, modifier = Modifier.tabContentAlpha(state = tabState)) + } + }, + ) + /** The "+" of a browser, as an IntelliJ icon button. */ @Composable private fun NewTabButton(onClick: () -> Unit) { @@ -105,3 +195,7 @@ private fun NewTabButton(onClick: () -> Unit) { Text("+") } } + +private const val CARD_WIDTH_DP = 260 +private const val CARD_PADDING_DP = 8 +private const val CARD_GAP_DP = 6 diff --git a/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/Main.kt b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/Main.kt index b163579f9..3502aff9e 100644 --- a/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/Main.kt +++ b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/Main.kt @@ -64,7 +64,7 @@ fun main() = val panel = JewelTheme.globalColors.panelBackground TabWindows( workspace = demo.workspace, - strip = { JewelEditorTabStrip(onNewTab = demo::open) }, + strip = { JewelEditorTabStrip(demo, onNewTab = demo::open) }, // Per-window chrome, since the app opens no window itself. windowWrapper = { content -> WindowBackground(panel) diff --git a/examples/reader-dock-demo/build.gradle.kts b/examples/reader-dock-demo/build.gradle.kts new file mode 100644 index 000000000..9f42a3c2d --- /dev/null +++ b/examples/reader-dock-demo/build.gradle.kts @@ -0,0 +1,51 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +// A right-to-left book reader whose every pane is a satellite: the navigation +// panels layered on the right, each with its own width and splitter, the +// translation on the left, the commentaries under the text — the pane tree of +// a split-pane reader, drawn by one DockLayout with the reader's own 1 dp +// dividers and hover headers, every pane undockable into its own window. + +plugins { + kotlin("jvm") + alias(libs.plugins.kotlinComposePlugin) + alias(libs.plugins.jetbrainsCompose) + id("dev.nucleusframework") +} + +dependencies { + implementation(project(":decorated-window-tao")) + implementation(project(":decorated-window-material3")) + implementation(project(":nucleus-application")) + implementation(project(":core-runtime")) + implementation(project(":darkmode-detector")) + implementation(project(":graalvm-runtime")) + implementation(compose.desktop.currentOs) + implementation("org.jetbrains.compose.material3:material3:1.9.0") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +nucleus.application { + mainClass = "dev.nucleusframework.readerdockdemo.MainKt" + + nativeDistributions { + packageName = "reader-dock-demo" + packageVersion = "1.0.0" + } + + graalvm { + isEnabled = true + javaLanguageVersion = 25 + imageName = "reader-dock-demo" + } +} diff --git a/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/Main.kt b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/Main.kt new file mode 100644 index 000000000..23df3225d --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/Main.kt @@ -0,0 +1,487 @@ +package dev.nucleusframework.readerdockdemo + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.application.Satellite +import dev.nucleusframework.application.Tab +import dev.nucleusframework.application.TabWindows +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.darkmodedetector.isSystemInDarkMode +import dev.nucleusframework.window.WindowAppearance +import dev.nucleusframework.window.WindowAppearanceMode +import dev.nucleusframework.window.WindowBackground +import dev.nucleusframework.window.material.rememberMaterialTitleBarStyle +import dev.nucleusframework.window.material.rememberMaterialWindowStyle +import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.TabWindowGroup +import dev.nucleusframework.window.tao.TabWorkspace + +private val DarkColors = + darkColorScheme( + primary = Color(0xFF8AA4FF), + surface = Color(0xFF1B1D22), + surfaceContainer = Color(0xFF23262D), + surfaceContainerHigh = Color(0xFF2B2F38), + background = Color(0xFF14161A), + outlineVariant = Color(0xFF3A3F4A), + ) + +private val LightColors = + lightColorScheme( + primary = Color(0xFF3F5DDB), + surface = Color(0xFFFFFFFF), + surfaceContainer = Color(0xFFF2F3F7), + surfaceContainerHigh = Color(0xFFE6E8EF), + background = Color(0xFFEDEFF4), + outlineVariant = Color(0xFFD5D8E0), + ) + +/** + * A right-to-left book reader whose seforim are tabs and whose every pane is a + * satellite — the two multi-window archetypes composed. + * + * The pane tree of a classic split-pane reader — books | contents | notes on + * the right, the text in the middle with the translation beside it, the + * commentaries under both — is one `DockLayout`: the right side is *layered*, + * so its three panes are three columns each with its own width and splitter, + * and the side order puts the right side first so the commentaries stop at it + * and run under the translation. The dividers are the reader's own 1 dp lines + * with a 5 dp grip; the headers are the reader's own hover strips; the + * *Islands* style turns every pane into a rounded card. + * + * On top of that, `TabWindows` owns the windows and each sefer is a `Tab`. The + * strip is the top of the window; everything below it — the two activity bars + * and the dock — is the reader's own chrome, hung on `windowBodyWrapper`. The + * **dock belongs to the window** and the tabs change what it holds: the text + * in the middle is the selected sefer, and every pane draws that same sefer. + * Tear a tab out and the new window arrives with a dock of its own, so two + * seforim are read side by side, each with its own pane widths, its own + * commentaries, its own layout to save and restore. Nothing about a tab change + * creates or destroys a panel — see [ReaderState]. + * + * The books pane is the other half of the tie: clicking a sefer there selects + * its tab, and the tab strip's "+" opens another. + */ +fun main() = + nucleusApplication { + val reader = remember { ReaderState() } + val dark = isSystemInDarkMode() + val colors = if (dark) DarkColors else LightColors + + ReaderTheme(colors) { + TabWindows( + workspace = reader.tabs, + // Right to left, like the rest of the reader: the first sefer + // is the rightmost tab and the "+" follows the last one + // leftwards, and the strip animates the same way. + strip = { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + ReaderTabStrip(reader, onNewBook = reader::openBook) + } + }, + windowWrapper = { content -> + WindowBackground(colors.background) + WindowAppearance(if (dark) WindowAppearanceMode.Dark else WindowAppearanceMode.Light) + // This window joins its own pane workspace, once, for as + // long as it lives: that is what keeps a tab change from + // touching the dock at all. + reader.tabs.groupOf(nucleusWindow.unsafe.taoWindow)?.let { + JoinSatelliteWorkspace(reader.panesOfWindow(it.id)) + } + Surface(Modifier.fillMaxSize(), color = colors.background) { content() } + }, + // Under the tab strip, which stays at the very top of the + // window: the dock and the activity bars belong to the window, + // the text between them is whichever sefer the strip selected. + windowBodyWrapper = { body -> + val group = reader.tabs.groupOf(nucleusWindow.unsafe.taoWindow) + if (group == null) body() else ReaderBody(reader, group) { body() } + }, + onLastWindowClosed = ::exitApplication, + ) + + // Every sefer, declared once: the workspace decides which window + // shows it, and the panes of that window draw it. + for (book in reader.books) { + key(book.id) { + Tab(reader.tabs, id = book.id, title = book.title) { BookText(reader, book) } + DropClosedTab(reader, book.id) + } + } + + // One dock of panes per reader window, declared at application + // scope so they are not tied to whichever sefer is showing. + for (group in rememberTabGroups(reader.tabs)) { + key(group.id) { WindowPanes(reader, group, colors) } + } + } + } + +/** + * The tab windows, mirrored out of the workspace through an effect: the groups + * are created by `Tab`, declared after this list is read, and Compose drops an + * invalidation aimed at a scope it has just composed. + */ +@Composable +private fun rememberTabGroups(workspace: TabWorkspace): List { + var groups by remember(workspace) { mutableStateOf(workspace.groups.toList()) } + LaunchedEffect(workspace) { + snapshotFlow { workspace.groups.toList() }.collect { groups = it } + } + return groups +} + +/** + * The panes of one reader window: one satellite per [Pane], declared against + * that window's workspace and drawing whichever sefer the window is showing. + * + * The entries are per window so a tab change creates and destroys nothing — + * only the content changes, and what the reader remembers per book lives in + * [ReaderState.stateOf]. + */ +@Composable +private fun WindowPanes( + reader: ReaderState, + group: TabWindowGroup, + colors: ColorScheme, +) { + val workspace = reader.panesOfWindow(group.id) + DisposableEffect(reader, group.id) { + onDispose { reader.forgetWindow(group.id) } + } + // The selected tab of *this* window, resolved back to the sefer. The tab id + // is the book id, which is what ties the two archetypes together without + // either knowing about the other. + val book = reader.tabs.selectedTab(group)?.let { reader.book(it.id) } + + ReaderTheme(colors) { + for (pane in Pane.entries) { + Satellite( + workspace = workspace, + id = pane.idIn(group.id), + title = pane.title, + initialPlacement = pane.home, + initiallyOpen = pane.openAtStart, + dockSides = if (pane.fixed) ReaderFixedDockSides else ReaderDockSides, + floatable = !pane.fixed, + reorderable = !pane.fixed, + header = { PaneHeader(reader.style) }, + // Only reserved where the compositor owns the window move; + // elsewhere the whole bar drags the pane and this is not composed. + floatingCaption = { PaneMoveAffordance() }, + ) { + Surface(Modifier.fillMaxSize(), color = colors.surface) { + PaneContent(reader, pane, book) + } + } + } + } +} + +/** + * One reader window: its two activity bars around the dock layout, all + * right-to-left, with the selected sefer's text as the dock's content. + */ +@Composable +private fun ReaderBody( + reader: ReaderState, + group: TabWindowGroup, + text: @Composable () -> Unit, +) { + val workspace = reader.panesOfWindow(group.id) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Row(Modifier.fillMaxSize()) { + // Start bar: at the right edge in RTL, toggling the navigation panes. + ActivityBar { + for (pane in listOf(Pane.Tree, Pane.Toc, Pane.Notes)) { + BarButton(pane.title.take(1), selected = reader.isOpen(group.id, pane)) { + reader.toggle(group.id, pane) + } + } + } + VerticalDivider() + DockLayout( + workspace = workspace, + modifier = Modifier.weight(1f).fillMaxHeight(), + // The navigation runs the full height on the right; the + // commentaries run under the text and the translation, not + // under the navigation. + sideOrder = listOf(DockSide.Right, DockSide.Bottom, DockSide.Left, DockSide.Top), + // Books | contents | notes are three columns, not a stack. + layeredSides = setOf(DockSide.Right), + splitter = { ReaderSplitter(reader.style) }, + panel = { body -> PaneCard(reader.style) { body() } }, + ) { + PaneCard(reader.style) { text() } + } + VerticalDivider() + // End bar: the content panes, the style switch, the layout of this window. + ActivityBar { + for (pane in listOf(Pane.Targum, Pane.Comments, Pane.Sources)) { + BarButton(pane.title.take(1), selected = reader.isOpen(group.id, pane)) { + reader.toggle(group.id, pane) + } + } + Spacer(Modifier.height(BAR_GAP_DP.dp)) + BarButton("◫", selected = reader.style == ReaderStyle.Islands) { + reader.style = if (reader.style == ReaderStyle.Islands) ReaderStyle.Classic else ReaderStyle.Islands + } + Spacer(Modifier.weight(1f)) + BarButton("S", selected = false) { reader.saveLayout(group.id) } + BarButton("R", selected = reader.savedLayout(group.id) != null) { reader.restoreLayout(group.id) } + BarButton("⟲", selected = false) { reader.resetLayout(group.id) } + } + } + } +} + +/** + * A sefer's text: the tab's own body, so it is composed in whichever window + * shows the tab and its scroll position follows it there. + */ +@Composable +private fun BookText( + reader: ReaderState, + book: Book, +) { + val state = reader.stateOf(book.id) + val chapter = state.chapter.coerceIn(book.chapters.indices) + Column(Modifier.fillMaxSize()) { + val scroll = rememberScrollState() + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(scroll) + .padding(TEXT_PADDING_DP.dp), + verticalArrangement = Arrangement.spacedBy(TEXT_GAP_DP.dp), + ) { + Text("${book.title} · ${book.chapters[chapter]}", fontSize = TITLE_SP.sp, fontWeight = FontWeight.Bold) + repeat(VERSES) { index -> + Text( + "פסוק ${index + 1} — ${SAMPLE_TEXT.repeat(1 + index % 3)}", + fontSize = TEXT_SP.sp, + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + HorizontalDivider() + Row( + Modifier.fillMaxWidth().height(BREADCRUMB_H_DP.dp).padding(horizontal = TEXT_PADDING_DP.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "תנ״ך › ${book.title} › ${book.chapters[chapter]}", + fontSize = BREADCRUMB_SP.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * A pane's body, for the sefer its window is showing: the books pane lists + * every open sefer and selects its tab, the contents pane lists the sefer's + * chapters, the rest list what they hold for the chapter in view. + */ +@Composable +private fun PaneContent( + reader: ReaderState, + pane: Pane, + book: Book?, +) { + if (book == null) { + Box(Modifier.fillMaxSize().padding(PANE_PADDING_DP.dp), contentAlignment = Alignment.Center) { + Text("אין ספר פתוח", fontSize = TEXT_SP.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + return + } + val state = reader.stateOf(book.id) + val scroll = rememberScrollState() + Column( + Modifier.fillMaxSize().verticalScroll(scroll).padding(PANE_PADDING_DP.dp), + verticalArrangement = Arrangement.spacedBy(ITEM_GAP_DP.dp), + ) { + when (pane) { + // Every sefer of the app: clicking one brings its tab to the front. + Pane.Tree -> + for (candidate in reader.books) { + PaneItem(candidate.title, selected = candidate.id == book.id) { reader.show(candidate.id) } + } + // The chapters of this sefer; the text follows the choice. + Pane.Toc -> + book.chapters.forEachIndexed { index, name -> + PaneItem(name, selected = index == state.chapter) { state.chapter = index } + } + else -> + repeat(ITEMS) { index -> + val label = "${pane.title} ${book.chapters[ + state.chapter.coerceIn( + book.chapters.indices, + ), + ]}·${index + 1}" + PaneItem(label, selected = state.selected(pane) == index) { state.select(pane, index) } + } + } + } +} + +@Composable +private fun PaneItem( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Text( + text = label, + fontSize = TEXT_SP.sp, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ITEM_CORNER_DP.dp)) + .background(if (selected) MaterialTheme.colorScheme.surfaceContainerHigh else Color.Transparent) + .clickable(onClick = onClick) + .padding(ITEM_PADDING_DP.dp), + ) +} + +/** + * Keeps the sefer list in step with the tab workspace: closing a tab is a + * workspace call, and a book still declared once its tab is gone would be + * registered again and hosted nowhere. + */ +@Composable +private fun DropClosedTab( + reader: ReaderState, + id: String, +) { + val closed = reader.tabs.tab(id) == null + LaunchedEffect(closed) { + if (closed) reader.forget(id) + } +} + +@Composable +private fun ActivityBar(content: @Composable () -> Unit) { + Column( + Modifier + .fillMaxHeight() + .width( + BAR_W_DP.dp, + ).background(MaterialTheme.colorScheme.surfaceContainer) + .padding(vertical = BAR_GAP_DP.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(BAR_GAP_DP.dp), + ) { content() } +} + +@Composable +private fun BarButton( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + FilledTonalIconButton( + onClick = onClick, + modifier = Modifier.size(BAR_BUTTON_DP.dp), + colors = + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = if (selected) colors.primary.copy(alpha = SELECTED_ALPHA) else Color.Transparent, + contentColor = if (selected) colors.primary else colors.onSurfaceVariant, + ), + ) { + Box( + contentAlignment = Alignment.Center, + ) { Text(label, fontSize = BAR_LABEL_SP.sp, fontWeight = FontWeight.SemiBold) } + } +} + +/** + * Material colours plus the window-chrome styles derived from them. + * + * Established once, above the windows: the workspaces open them, and these + * locals are bridged into every scene they create — the tab strips in the + * title bars and the floating panes' own scenes included. + */ +@Composable +private fun ReaderTheme( + colors: ColorScheme, + content: @Composable () -> Unit, +) { + MaterialTheme(colorScheme = colors) { + CompositionLocalProvider( + LocalTitleBarStyle provides rememberMaterialTitleBarStyle(colors), + LocalDecoratedWindowStyle provides rememberMaterialWindowStyle(colors), + content = content, + ) + } +} + +private const val BAR_W_DP = 48 +private const val BAR_GAP_DP = 8 +private const val BAR_BUTTON_DP = 36 +private const val BAR_LABEL_SP = 14 +private const val SELECTED_ALPHA = 0.18f +private const val TEXT_PADDING_DP = 24 +private const val TEXT_GAP_DP = 12 +private const val TITLE_SP = 26 +private const val TEXT_SP = 17 +private const val VERSES = 40 +private const val BREADCRUMB_H_DP = 28 +private const val BREADCRUMB_SP = 12 +private const val PANE_PADDING_DP = 8 +private const val ITEM_GAP_DP = 2 +private const val ITEM_PADDING_DP = 6 +private const val ITEM_CORNER_DP = 6 +private const val ITEMS = 40 +private const val SAMPLE_TEXT = "בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ. " diff --git a/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderChrome.kt b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderChrome.kt new file mode 100644 index 000000000..60fe9fafd --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderChrome.kt @@ -0,0 +1,207 @@ +package dev.nucleusframework.readerdockdemo + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.window.tao.DockSplitterScope +import dev.nucleusframework.window.tao.SatelliteScope +import dev.nucleusframework.window.tao.satelliteDragHandle + +/** + * The reader's pane header: a 32 dp strip with the bold title and, on hover, + * the pane's actions — float or dock, and hide. The whole strip is the grip + * that drags the pane between its dock and its own window. + * + * Composed by the satellite in both hosts: above the panel in the dock and in + * the title bar of the floating window, where the bar already is the grip. + */ +@Composable +fun SatelliteScope.PaneHeader(style: ReaderStyle) { + val colors = MaterialTheme.colorScheme + val hover = remember { MutableInteractionSource() } + val hovered by hover.collectIsHoveredAsState() + val background = + if (style == + ReaderStyle.Islands + ) { + colors.surfaceContainerHigh.copy(alpha = ISLANDS_HEADER_ALPHA) + } else { + colors.surfaceContainer + } + Column( + Modifier + .fillMaxWidth() + .background(if (isDocked) background else Color.Transparent) + .hoverable(hover) + .then(if (isDocked) Modifier.satelliteDragHandle(this) else Modifier), + ) { + Row( + Modifier.fillMaxWidth().height(HEADER_HEIGHT_DP.dp).padding(horizontal = HEADER_PADDING_DP.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(satellite.title, fontWeight = FontWeight.Bold, fontSize = HEADER_TEXT_SP.sp, color = colors.onSurface) + AnimatedVisibility(visible = hovered, enter = fadeIn(), exit = fadeOut()) { + Row( + horizontalArrangement = Arrangement.spacedBy(ACTION_GAP_DP.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isDocked) { + // A fixed pane has nowhere to float to. + if (satellite.isFloatable) HeaderAction(FLOAT_GLYPH) { undock() } + } else { + HeaderAction(DOCK_GLYPH) { dock() } + } + HeaderAction(HIDE_GLYPH) { close() } + } + } + } + if (isDocked && style == ReaderStyle.Classic) HorizontalDivider() + } +} + +/** + * What the floating pane draws in the strip its title bar leaves to the + * compositor: the grip that says "press here to move the window", as opposed + * to the header beside it, which drags the pane into the dock. + * + * Composed only where the two gestures have to be told apart + * ([SatelliteScope.isCompositorPlaced]); the slot is not composed at all + * elsewhere, so this costs nothing on Windows, macOS and X11. + */ +@Composable +fun SatelliteScope.PaneMoveAffordance() { + Text( + MOVE_GLYPH, + fontSize = ACTION_GLYPH_SP.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = MOVE_GLYPH_ALPHA), + ) +} + +@Composable +private fun HeaderAction( + glyph: String, + onClick: () -> Unit, +) { + IconButton(onClick = onClick, modifier = Modifier.size(ACTION_SIZE_DP.dp)) { + Text(glyph, fontSize = ACTION_GLYPH_SP.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** + * The reader's splitter: a 1 dp divider — invisible in the Islands style, where + * the cards' gaps are the dividers — carrying a wider invisible grip, exactly + * the split pane's `visiblePart` and `handle`. + */ +@Composable +fun DockSplitterScope.ReaderSplitter(style: ReaderStyle) { + val horizontal = orientation == Orientation.Horizontal + val line = + if (horizontal) { + Modifier.fillMaxHeight().width( + DIVIDER_DP.dp, + ) + } else { + Modifier.fillMaxWidth().height(DIVIDER_DP.dp) + } + val color = if (style == ReaderStyle.Islands) Color.Transparent else MaterialTheme.colorScheme.outlineVariant + Box(line.background(color), contentAlignment = Alignment.Center) { + val grip = + if (horizontal) { + Modifier + .requiredWidth( + GRIP_DP.dp, + ).fillMaxHeight() + } else { + Modifier.requiredHeight(GRIP_DP.dp).fillMaxWidth() + } + Box(grip.dockSplitterHandle()) + } +} + +/** + * The frame around a docked pane: nothing in the Classic style, where panes + * butt against each other along the dividers; a rounded card in the Islands + * style. + */ +@Composable +fun PaneCard( + style: ReaderStyle, + content: @Composable () -> Unit, +) { + if (style == ReaderStyle.Islands) { + Box( + Modifier + .fillMaxSize() + .padding( + top = CARD_GAP_V_DP.dp, + bottom = CARD_GAP_V_DP.dp, + start = CARD_GAP_H_DP.dp, + end = CARD_GAP_H_DP.dp, + ).clip(RoundedCornerShape(CARD_CORNER_DP.dp)) + .background(MaterialTheme.colorScheme.surface), + ) { content() } + } else { + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { content() } + } +} + +@Composable +fun HorizontalDivider() { + Box(Modifier.fillMaxWidth().height(DIVIDER_DP.dp).background(MaterialTheme.colorScheme.outlineVariant)) +} + +@Composable +fun VerticalDivider() { + Box(Modifier.fillMaxHeight().width(DIVIDER_DP.dp).background(MaterialTheme.colorScheme.outlineVariant)) +} + +private const val HEADER_HEIGHT_DP = 32 +private const val HEADER_PADDING_DP = 8 +private const val HEADER_TEXT_SP = 14 +private const val ACTION_GAP_DP = 4 +private const val ACTION_SIZE_DP = 24 +private const val MOVE_GLYPH_ALPHA = 0.55f +private const val ACTION_GLYPH_SP = 12 +private const val FLOAT_GLYPH = "\u2197" +private const val DOCK_GLYPH = "\u2199" +private const val HIDE_GLYPH = "\u2014" +private const val MOVE_GLYPH = "✥" +private const val ISLANDS_HEADER_ALPHA = 0.15f +private const val DIVIDER_DP = 1 +private const val GRIP_DP = 5 +private const val CARD_GAP_V_DP = 6 +private const val CARD_GAP_H_DP = 4 +private const val CARD_CORNER_DP = 12 diff --git a/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderState.kt b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderState.kt new file mode 100644 index 000000000..e52829b59 --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderState.kt @@ -0,0 +1,235 @@ +package dev.nucleusframework.readerdockdemo + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatelliteLayoutSnapshot +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.TabWorkspace + +/** The two looks of the reader: dividers everywhere, or every pane a rounded card. */ +enum class ReaderStyle { + Classic, + Islands, +} + +/** + * Where a pane may be docked: anywhere but the top. The reader's top is its + * tab strip and the text's own header; a pane dragged there is refused, and + * the top strip never lights up. + */ +val ReaderDockSides: Set = setOf(DockSide.Left, DockSide.Right, DockSide.Bottom) + +/** The sides [Pane.fixed] panes accept: the right of the text, where the reader puts them. */ +val ReaderFixedDockSides: Set = setOf(DockSide.Right) + +/** + * One pane of the reader: a satellite with a home in the dock. + * + * [fixed] is the reader's furniture — the book tree and the table of contents + * belong on the right of the text, in that order, and nowhere else: they + * cannot be torn into a window of their own, moved to another side, or + * reordered, and no other pane can be dropped in front of them. They can + * still be hidden and resized. + */ +enum class Pane( + val id: String, + val title: String, + val home: SatellitePlacement.Docked, + val openAtStart: Boolean, + val fixed: Boolean = false, +) { + Tree( + "tree", + "ספרים", + SatellitePlacement.Docked(DockSide.Right, order = 0, extent = 200.dp), + openAtStart = true, + fixed = true, + ), + Toc( + "toc", + "תוכן", + SatellitePlacement.Docked(DockSide.Right, order = 1, extent = 170.dp), + openAtStart = true, + fixed = true, + ), + Notes("notes", "הערות", SatellitePlacement.Docked(DockSide.Right, order = 2, extent = 220.dp), openAtStart = false), + Targum("targum", "תרגום", SatellitePlacement.Docked(DockSide.Left, extent = 240.dp), openAtStart = false), + Comments("comments", "מפרשים", SatellitePlacement.Docked(DockSide.Bottom, extent = 220.dp), openAtStart = true), + Sources("sources", "מקורות", SatellitePlacement.Docked(DockSide.Bottom, extent = 200.dp), openAtStart = false), + ; + + /** + * This pane's entry id in the workspace of the reader window [groupId]. + * + * The panes are per **window**, not per book: a window's dock is its own + * furniture, and a tab change must neither create nor destroy a panel. + * What follows the tab is what the panes *draw*. + */ + fun idIn(groupId: String): String = "$groupId-$id" +} + +/** One sefer: one tab, its chapters, and the text they hold. */ +class Book( + val id: String, + val title: String, + val chapters: List, +) + +/** + * What the reader remembers about a book, wherever its tab is shown: which + * chapter is open and which line each pane has selected. + * + * It lives here rather than in the panes because it belongs to the book: a tab + * moved to another window, or brought back after being closed and reopened, + * has to find it unchanged — and the panes that draw it belong to a window, + * not to a book. + */ +class BookState { + var chapter by mutableIntStateOf(0) + private val selections = mutableStateMapOf() + + fun selected(pane: Pane): Int = selections[pane] ?: -1 + + fun select( + pane: Pane, + index: Int, + ) { + selections[pane] = index + } +} + +/** + * Everything the demo drives: the seforim as tabs, and one dock of panes per + * reader window. + * + * [tabs] owns which windows exist and which sefer each window shows. + * [panesOfWindow] hands out one [SatelliteWorkspace] **per window**, which is + * what lets a tab change leave the dock alone: the panes exist as long as + * their window does, and only their content follows the selected tab. Tear a + * tab into a window of its own and it arrives with a dock of its own, so two + * windows read two seforim side by side, each with its own pane widths. + */ +class ReaderState { + // `captureThumbnails` is what puts the page itself on a sefer's hover + // card: the workspace keeps a reduced picture of the body each tab last + // showed. Off by default — it costs a layer and a readback per tab. + val tabs = + TabWorkspace( + defaultWindowSize = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp), + captureThumbnails = true, + ) + + /** The open seforim, in declaration order. One tab each. */ + val books = + mutableStateListOf( + Book("bereshit", "בראשית", chapterNames(BERESHIT_CHAPTERS)), + Book("shemot", "שמות", chapterNames(SHEMOT_CHAPTERS)), + Book("tehillim", "תהילים", chapterNames(TEHILLIM_CHAPTERS)), + ) + + var style: ReaderStyle by mutableStateOf(ReaderStyle.Classic) + + private val workspaces = mutableStateMapOf() + private val bookStates = mutableStateMapOf() + private val savedLayouts = mutableStateMapOf() + + /** The pane workspace of the reader window [groupId], created on first use. */ + fun panesOfWindow(groupId: String): SatelliteWorkspace = workspaces.getOrPut(groupId) { SatelliteWorkspace() } + + /** Drops the workspace of a window that is gone. */ + fun forgetWindow(groupId: String) { + workspaces.remove(groupId) + savedLayouts.remove(groupId) + } + + /** The book [id] names, or `null` once its tab has been closed. */ + fun book(id: String): Book? = books.firstOrNull { it.id == id } + + /** What the reader remembers about [bookId], created on first use. */ + fun stateOf(bookId: String): BookState = bookStates.getOrPut(bookId) { BookState() } + + /** Drops a book — and what the reader remembered about it — once its tab is gone. */ + fun forget(bookId: String) { + books.removeAll { it.id == bookId } + bookStates.remove(bookId) + } + + private var opened = 0 + + /** Opens another sefer; its tab lands in the window focused last. */ + fun openBook() { + opened++ + val title = ExtraTitles[(opened - 1) % ExtraTitles.size] + books += Book("sefer-$opened", title, chapterNames(EXTRA_CHAPTERS)) + } + + /** Brings the book [id] to the front of whichever window shows its tab. */ + fun show(id: String) { + tabs.select(id) + } + + // ── Per-window pane layout ─────────────────────────────────────────── + + fun isOpen( + groupId: String, + pane: Pane, + ): Boolean = panesOfWindow(groupId).satellite(pane.idIn(groupId))?.isOpen == true + + /** Shows or hides a pane of one window. Commentaries and sources share the bottom, so one closes the other. */ + fun toggle( + groupId: String, + pane: Pane, + ) { + val workspace = panesOfWindow(groupId) + val opening = !isOpen(groupId, pane) + when (pane) { + Pane.Comments -> if (opening) workspace.close(Pane.Sources.idIn(groupId)) + Pane.Sources -> if (opening) workspace.close(Pane.Comments.idIn(groupId)) + else -> Unit + } + workspace.toggle(pane.idIn(groupId)) + } + + fun savedLayout(groupId: String): SatelliteLayoutSnapshot? = savedLayouts[groupId] + + fun saveLayout(groupId: String) { + savedLayouts[groupId] = panesOfWindow(groupId).snapshot() + } + + fun restoreLayout(groupId: String) { + savedLayouts[groupId]?.let(panesOfWindow(groupId)::restore) + } + + /** Every pane of one window back where it started, at its starting width. */ + fun resetLayout(groupId: String) { + val workspace = panesOfWindow(groupId) + for (pane in Pane.entries) { + val id = pane.idIn(groupId) + workspace.dock(id, pane.home.side, order = pane.home.order) + pane.home.extent?.let { workspace.setDockedExtent(id, it) } + workspace.setDockedWeight(id, pane.home.weight) + if (pane.openAtStart) workspace.open(id) else workspace.close(id) + } + } + + private companion object { + const val WINDOW_W_DP = 1280 + const val WINDOW_H_DP = 820 + const val BERESHIT_CHAPTERS = 50 + const val SHEMOT_CHAPTERS = 40 + const val TEHILLIM_CHAPTERS = 30 + const val EXTRA_CHAPTERS = 24 + + val ExtraTitles = listOf("ויקרא", "במדבר", "דברים", "משלי", "איוב") + + fun chapterNames(count: Int): List = List(count) { "פרק ${it + 1}" } + } +} diff --git a/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderTabStrip.kt b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderTabStrip.kt new file mode 100644 index 000000000..d025e4ab7 --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderTabStrip.kt @@ -0,0 +1,87 @@ +package dev.nucleusframework.readerdockdemo + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.TabHoverPreview +import dev.nucleusframework.window.tao.TabHoverPreviewCard +import dev.nucleusframework.window.tao.TabStrip +import dev.nucleusframework.window.tao.TabStripScope + +/** + * The seforim of one window: the stock [TabStrip], plus the button that opens + * another sefer after the last tab, and the card shown under a sefer the + * pointer rests on. + * + * The stock strip is what publishes the geometry a tab dragged from another + * window is dropped onto, so the reader's own chrome goes *around* its tabs + * rather than in place of them. + * + * The card is right to left like everything else here: it hangs from the tab's + * *right* edge and grows leftwards, because the strip is composed in an + * `Rtl` direction and the card follows the reading direction it is given. It + * is never shown for the sefer being read — that page is on screen already. + */ +@Composable +fun TabStripScope.ReaderTabStrip( + reader: ReaderState, + onNewBook: () -> Unit, +) { + // The stock card with a line of the reader's own: the workspace knows a + // tab's title, so the number of chapters is looked up by the demo from the + // tab's id. The picture under it is the page the sefer was left on. + val preview = + remember(reader) { + TabHoverPreview { + TabHoverPreviewCard( + subtitle = { + val colors = LocalTitleBarStyle.current.colors + val chapters = reader.book(tab.id)?.chapters + Text( + text = "${chapters?.size ?: 0} פרקים", + color = colors.content.copy(alpha = SUBTITLE_ALPHA), + style = MaterialTheme.typography.bodySmall, + ) + }, + ) + } + } + TabStrip(hoverPreview = preview, trailing = { NewBookButton(onNewBook) }) +} + +/** Opens another sefer in this workspace. */ +@Composable +private fun NewBookButton(onClick: () -> Unit) { + val colors = LocalTitleBarStyle.current.colors + Box( + modifier = + Modifier + .padding(horizontal = BUTTON_PADDING_DP.dp) + .size(BUTTON_SIZE_DP.dp) + .clip(CircleShape) + .clickable(onClick = onClick) + .pointerHoverIcon(PointerIcon.Hand), + contentAlignment = Alignment.Center, + ) { + Text("+", color = colors.content, fontSize = BUTTON_GLYPH_SP.sp) + } +} + +private const val BUTTON_PADDING_DP = 6 +private const val BUTTON_SIZE_DP = 22 +private const val BUTTON_GLYPH_SP = 15 +private const val SUBTITLE_ALPHA = 0.7f diff --git a/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoState.kt b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoState.kt index c6613d5e0..e7c07ccc1 100644 --- a/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoState.kt +++ b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoState.kt @@ -15,11 +15,14 @@ import dev.nucleusframework.window.tao.TabWorkspace * @property id the tab's identity, stable for as long as the document is open. * @property title shown on the tab and, while it is the selected one, as the * title of the window holding it. + * @property path shown under the title on the tab's hover card, the way an + * editor's tooltip shows where a file lives. * @property draft what its editor starts with. */ class Document( val id: String, val title: String, + val path: String, val draft: String, ) @@ -33,16 +36,36 @@ class Document( * declared all over again. */ class DemoState { - val workspace = TabWorkspace(defaultWindowSize = DpSize(WINDOW_WIDTH_DP.dp, WINDOW_HEIGHT_DP.dp)) + // `captureThumbnails` is what puts a picture of the document on its hover + // card: the workspace keeps a reduced snapshot of whatever body was last + // on screen for each tab. Off by default — it costs a layer and a readback. + val workspace = + TabWorkspace( + defaultWindowSize = DpSize(WINDOW_WIDTH_DP.dp, WINDOW_HEIGHT_DP.dp), + captureThumbnails = true, + ) /** The open documents, in declaration order. One tab each. */ val documents = mutableStateListOf( - Document("readme", "README.md", "# Tabs demo\n\nDrag a tab out of this window."), - Document("main", "Main.kt", "fun main() = nucleusApplication { }"), - Document("build", "build.gradle.kts", "plugins { id(\"dev.nucleusframework\") }"), + Document( + "readme", + "README.md", + "examples/tabs-demo/README.md", + "# Tabs demo\n\nDrag a tab out of this window.", + ), + Document("main", "Main.kt", "src/main/kotlin/Main.kt", "fun main() = nucleusApplication { }"), + Document( + "build", + "build.gradle.kts", + "examples/tabs-demo/build.gradle.kts", + "plugins { id(\"dev.nucleusframework\") }", + ), ) + /** The document behind a tab id, for chrome that draws more than a title. */ + fun document(id: String): Document? = documents.firstOrNull { it.id == id } + /** The layout captured by "Save layout", ready for "Restore layout". */ var savedLayout: TabLayoutSnapshot? by mutableStateOf(null) private set @@ -56,7 +79,7 @@ class DemoState { */ fun open() { opened++ - documents += Document("note-$opened", "Untitled $opened", "") + documents += Document("note-$opened", "Untitled $opened", "untitled-$opened.txt", "") } /** Drops the document [id] once its tab is gone from the workspace. */ diff --git a/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoTabStrip.kt b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoTabStrip.kt index 700a9d0f3..d45437ae8 100644 --- a/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoTabStrip.kt +++ b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoTabStrip.kt @@ -5,22 +5,27 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.TabHoverPreview +import dev.nucleusframework.window.tao.TabHoverPreviewCard import dev.nucleusframework.window.tao.TabStrip import dev.nucleusframework.window.tao.TabStripScope /** * The strip of one window: the stock [TabStrip], plus a new-tab button right - * after the last tab. + * after the last tab and a hover card under the tab the pointer rests on. * * The stock strip is what publishes the geometry a tab dragged from another * window is dropped onto, which is why chrome is added *around* its tabs @@ -29,8 +34,33 @@ import dev.nucleusframework.window.tao.TabStripScope * `Modifier.tabDragHandle` itself. */ @Composable -fun TabStripScope.DemoTabStrip(onNewTab: () -> Unit) { - TabStrip(trailing = { NewTabButton(onNewTab) }) +fun TabStripScope.DemoTabStrip( + demo: DemoState, + onNewTab: () -> Unit, +) { + // The card is the stock one with a second line of the demo's own: the + // workspace knows a tab's title and nothing else, so anything past it — + // here the file's path — is looked up by the app from the tab's id. + // `TabHoverPreview(content = …)` would replace the card outright. + val preview = + remember(demo) { + TabHoverPreview { + TabHoverPreviewCard( + subtitle = { + val colors = LocalTitleBarStyle.current.colors + val path = demo.document(tab.id)?.path ?: "" + Text( + text = path, + color = colors.content.copy(alpha = SUBTITLE_ALPHA), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } + TabStrip(hoverPreview = preview, trailing = { NewTabButton(onNewTab) }) } /** The "+" of a browser: opens a document in this workspace. */ @@ -50,3 +80,5 @@ private fun NewTabButton(onClick: () -> Unit) { Text("+", color = colors.content, fontSize = 15.sp) } } + +private const val SUBTITLE_ALPHA = 0.7f diff --git a/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/Main.kt b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/Main.kt index d889d8025..9f257e835 100644 --- a/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/Main.kt +++ b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/Main.kt @@ -67,7 +67,7 @@ fun main() = DemoTheme(colors) { TabWindows( workspace = demo.workspace, - strip = { DemoTabStrip(onNewTab = demo::open) }, + strip = { DemoTabStrip(demo, onNewTab = demo::open) }, // Per-window chrome goes here, since the app opens no window // of its own: the receiver is the window being composed. windowWrapper = { content -> diff --git a/nucleus-application/api/nucleus-application.api b/nucleus-application/api/nucleus-application.api index d938ab290..d24ecb73d 100644 --- a/nucleus-application/api/nucleus-application.api +++ b/nucleus-application/api/nucleus-application.api @@ -6,17 +6,21 @@ public final class dev/nucleusframework/application/AotTrainingKt { public final class dev/nucleusframework/application/ComposableSingletons$SatelliteKt { public static final field INSTANCE Ldev/nucleusframework/application/ComposableSingletons$SatelliteKt; public fun ()V - public final fun getLambda$-385624683$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$669526924$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-290429981$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-939267807$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1449473754$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$624849194$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; } public final class dev/nucleusframework/application/ComposableSingletons$TabKt { public static final field INSTANCE Ldev/nucleusframework/application/ComposableSingletons$TabKt; public fun ()V - public final fun getLambda$-1232643942$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-280087461$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$1802342993$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1876189458$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-1157930213$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1616528785$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$1886912602$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$2066186131$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$283286354$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$773313628$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; } public final class dev/nucleusframework/application/DecoratedDialogKt { @@ -166,8 +170,8 @@ public final class dev/nucleusframework/application/NucleusWindowUnsafe$DefaultI } public final class dev/nucleusframework/application/SatelliteKt { - public static final fun Satellite (Ldev/nucleusframework/application/NucleusApplicationScope;Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement;ZZZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V - public static final fun Satellite (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement;ZZZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun Satellite (Ldev/nucleusframework/application/NucleusApplicationScope;Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement;ZLjava/util/Set;ZZZZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun Satellite (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement;ZLjava/util/Set;ZZZZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V public static final fun pinTo (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ldev/nucleusframework/application/NucleusWindow;)V } @@ -183,8 +187,8 @@ public final class dev/nucleusframework/application/SingleInstanceRestoreBusKt { public final class dev/nucleusframework/application/TabKt { public static final fun Tab (Ldev/nucleusframework/application/NucleusApplicationScope;Ldev/nucleusframework/window/tao/TabWorkspace;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V public static final fun Tab (Ldev/nucleusframework/window/tao/TabWorkspace;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V - public static final fun TabWindows (Ldev/nucleusframework/application/NucleusApplicationScope;Ldev/nucleusframework/window/tao/TabWorkspace;Lkotlin/jvm/functions/Function3;ZLkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V - public static final fun TabWindows (Ldev/nucleusframework/window/tao/TabWorkspace;Lkotlin/jvm/functions/Function3;ZLkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final fun TabWindows (Ldev/nucleusframework/application/NucleusApplicationScope;Ldev/nucleusframework/window/tao/TabWorkspace;Lkotlin/jvm/functions/Function3;ZLkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final fun TabWindows (Ldev/nucleusframework/window/tao/TabWorkspace;Lkotlin/jvm/functions/Function3;ZLkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V } public abstract class dev/nucleusframework/application/contextmenu/ContextMenuEntry { diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt index 3dba908e7..c051e4d28 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.ComposableOpenTarget import androidx.compose.ui.UiComposable import dev.nucleusframework.application.internal.TaoSatelliteWorkspaceAdapter import dev.nucleusframework.window.tao.DefaultSatelliteHeader +import dev.nucleusframework.window.tao.DockSide import dev.nucleusframework.window.tao.SatellitePlacement import dev.nucleusframework.window.tao.SatelliteScope import dev.nucleusframework.window.tao.SatelliteWorkspace @@ -46,6 +47,17 @@ import dev.nucleusframework.window.tao.SatelliteWorkspace * the windows that joined. `rememberSatelliteWorkspace`, `JoinSatelliteWorkspace` * and `DockLayout` are used as-is from `decorated-window-tao`. * + * @param dockSides the sides the satellite may be docked on; the others are + * never offered nor accepted. Empty: a floating-only palette. + * @param floatable whether the satellite can be a window of its own; `false` + * is a fixed panel that cannot be torn out. Requires a docked + * [initialPlacement]. + * @param reorderable whether the user may change its rank on its side; + * `false` pins it to the rank it was declared with. Requires a docked + * [initialPlacement]. + * @param floatingCaption composed in the strip of the floating title bar left + * to the compositor's window move, where the window is placed by the + * compositor; see [dev.nucleusframework.window.tao.Satellite]. * @param nativeContextMenu whether text fields in the floating window get the * native context menu, as for [SatelliteWindow]. */ @@ -58,10 +70,14 @@ public fun NucleusApplicationScope.Satellite( title: String, initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), initiallyOpen: Boolean = true, + dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, + reorderable: Boolean = true, resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, nativeContextMenu: Boolean = true, header: @Composable @UiComposable SatelliteScope.() -> Unit = { DefaultSatelliteHeader() }, + floatingCaption: @Composable @UiComposable SatelliteScope.() -> Unit = {}, content: @Composable @UiComposable SatelliteScope.() -> Unit, ) { when (this) { @@ -73,10 +89,14 @@ public fun NucleusApplicationScope.Satellite( title = title, initialPlacement = initialPlacement, initiallyOpen = initiallyOpen, + dockSides = dockSides, + floatable = floatable, + reorderable = reorderable, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, nativeContextMenu = nativeContextMenu, header = header, + floatingCaption = floatingCaption, content = content, ) } @@ -95,10 +115,14 @@ public fun Satellite( title: String, initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), initiallyOpen: Boolean = true, + dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, + reorderable: Boolean = true, resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, nativeContextMenu: Boolean = true, header: @Composable @UiComposable SatelliteScope.() -> Unit = { DefaultSatelliteHeader() }, + floatingCaption: @Composable @UiComposable SatelliteScope.() -> Unit = {}, content: @Composable @UiComposable SatelliteScope.() -> Unit, ) { LocalNucleusApplicationScope.current.Satellite( @@ -107,10 +131,14 @@ public fun Satellite( title = title, initialPlacement = initialPlacement, initiallyOpen = initiallyOpen, + dockSides = dockSides, + floatable = floatable, + reorderable = reorderable, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, nativeContextMenu = nativeContextMenu, header = header, + floatingCaption = floatingCaption, content = content, ) } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Tab.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Tab.kt index cccd50565..bfe61a754 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Tab.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Tab.kt @@ -46,6 +46,11 @@ import dev.nucleusframework.window.tao.TabWorkspace * that window's scope as receiver — where per-window chrome goes, since the * app does not open these windows itself: `WindowBackground`, * `WindowAppearance`, a themed `Surface`. Must invoke the lambda it is given. + * @param windowBodyWrapper composed inside each window, below the tab strip, + * around the selected tab's body: chrome that belongs to the window rather + * than to a tab goes here — a `DockLayout` with its satellites, an activity + * bar. [windowWrapper] wraps the window including its strip; this one wraps + * only what is under it. Must invoke the lambda it is given. * @param onLastWindowClosed called every time the workspace goes from holding * tabs to holding none, which is where an app calls `exitApplication`. */ @@ -56,6 +61,7 @@ public fun NucleusApplicationScope.TabWindows( strip: @Composable TabStripScope.() -> Unit = { TabStrip() }, nativeContextMenu: Boolean = true, windowWrapper: @Composable NucleusDecoratedWindowScope.(content: @Composable () -> Unit) -> Unit = { it() }, + windowBodyWrapper: @Composable NucleusDecoratedWindowScope.(body: @Composable () -> Unit) -> Unit = { it() }, onLastWindowClosed: () -> Unit = {}, ) { when (this) { @@ -66,6 +72,7 @@ public fun NucleusApplicationScope.TabWindows( strip = strip, nativeContextMenu = nativeContextMenu, windowWrapper = windowWrapper, + windowBodyWrapper = windowBodyWrapper, onLastWindowClosed = onLastWindowClosed, ) } @@ -82,6 +89,7 @@ public fun TabWindows( strip: @Composable TabStripScope.() -> Unit = { TabStrip() }, nativeContextMenu: Boolean = true, windowWrapper: @Composable NucleusDecoratedWindowScope.(content: @Composable () -> Unit) -> Unit = { it() }, + windowBodyWrapper: @Composable NucleusDecoratedWindowScope.(body: @Composable () -> Unit) -> Unit = { it() }, onLastWindowClosed: () -> Unit = {}, ) { LocalNucleusApplicationScope.current.TabWindows( @@ -89,6 +97,7 @@ public fun TabWindows( strip = strip, nativeContextMenu = nativeContextMenu, windowWrapper = windowWrapper, + windowBodyWrapper = windowBodyWrapper, onLastWindowClosed = onLastWindowClosed, ) } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoSatelliteWorkspaceAdapter.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoSatelliteWorkspaceAdapter.kt index 7994738b5..eef19bccb 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoSatelliteWorkspaceAdapter.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoSatelliteWorkspaceAdapter.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.currentCompositionLocalContext import androidx.compose.ui.platform.LocalLayoutDirection import dev.nucleusframework.application.TaoNucleusApplicationScope import dev.nucleusframework.application.internal.TaoSatelliteWindowAdapter.NucleusSatelliteScene +import dev.nucleusframework.window.tao.DockSide import dev.nucleusframework.window.tao.SatellitePlacement import dev.nucleusframework.window.tao.SatelliteScope import dev.nucleusframework.window.tao.SatelliteWorkspace @@ -26,10 +27,14 @@ internal object TaoSatelliteWorkspaceAdapter { title: String, initialPlacement: SatellitePlacement, initiallyOpen: Boolean, + dockSides: Set, + floatable: Boolean, + reorderable: Boolean, resizable: Boolean, hideWhileOwnerFullscreenOrMaximized: Boolean, nativeContextMenu: Boolean, header: @Composable SatelliteScope.() -> Unit, + floatingCaption: @Composable SatelliteScope.() -> Unit, content: @Composable SatelliteScope.() -> Unit, ) { val outerLocals = currentCompositionLocalContext @@ -41,6 +46,9 @@ internal object TaoSatelliteWorkspaceAdapter { title = title, initialPlacement = initialPlacement, initiallyOpen = initiallyOpen, + dockSides = dockSides, + floatable = floatable, + reorderable = reorderable, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, compositionLocalContext = outerLocals, @@ -48,6 +56,7 @@ internal object TaoSatelliteWorkspaceAdapter { NucleusSatelliteScene(outerLocals, parentLayoutDirection, nativeContextMenu) { inner() } }, header = header, + floatingCaption = floatingCaption, content = content, ) } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoTabWorkspaceAdapter.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoTabWorkspaceAdapter.kt index 5a24d072f..a509f4f7e 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoTabWorkspaceAdapter.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoTabWorkspaceAdapter.kt @@ -27,6 +27,7 @@ internal object TaoTabWorkspaceAdapter { strip: @Composable TabStripScope.() -> Unit, nativeContextMenu: Boolean, windowWrapper: @Composable NucleusDecoratedWindowScope.(content: @Composable () -> Unit) -> Unit, + windowBodyWrapper: @Composable NucleusDecoratedWindowScope.(body: @Composable () -> Unit) -> Unit, onLastWindowClosed: () -> Unit, ) { // Each window the workspace opens gets a fresh ComposeScene — see @@ -44,6 +45,14 @@ internal object TaoTabWorkspaceAdapter { windowWrapper(inner) } }, + // Inside the window's own scene, where `bindNucleusContent` + // has already provided the Nucleus locals: the wrapper is + // handed the same scope the content wrapper gets. + windowBodyWrapper = { body -> + bindNucleusContent(outerLocals, parentLayoutDirection, nativeContextMenu) { + windowBodyWrapper(body) + } + }, onLastWindowClosed = onLastWindowClosed, ) } diff --git a/settings.gradle.kts b/settings.gradle.kts index aa899577b..aa4924d43 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -96,6 +96,7 @@ include(":examples:satellite-demo") include(":examples:tabs-demo") include(":examples:jewel-tabs-demo") include(":examples:tab-satellites-demo") +include(":examples:reader-dock-demo") include(":examples:rect-stress-demo") include(":examples:watermark-demo") include(":examples:widget-demo")