From e26dd6fc8973583d6a027dbe7529f4ee8e77035d Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 7 Sep 2026 17:23:14 +0300 Subject: [PATCH 01/13] feat(tao): layered dock sides, per-panel sizes and app-owned dock chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `DockLayout` could only stack the panels of one side at equal shares of a single per-side extent, which is not the pane tree a reader-style app draws: navigation, contents and notes side by side on one edge, each with its own width and its own splitter, and a commentary strip that runs under the text but not under the navigation. That layout is now expressible, and every pane of it is a satellite, so it can also be torn into a window of its own. - `SatellitePlacement.Docked` carries the panel's own `extent` and `weight`; both ride in `SatelliteLayoutSnapshot` and are driven by `setDockedExtent` / `setDockedWeight`. - `DockLayout(sideOrder, layeredSides, splitter, panel)`: sides nest outermost-first, so a side can own the corners; a side is either *split* (panels share its length by weight, thickness by `dockExtent`) or *layered* (each panel a full-length layer of its own extent, with its own splitter). - The `splitter` and `panel` slots hand the chrome to the app — `DockSplitterScope.dockSplitterHandle()` carries the gesture, so a 1 dp line with a wider overflowing grip works — and the default header no longer imposes a height or a background on a docked panel. - Sides are physical: the layout composes LTR internally and gives the caller's direction back to the content, the panels and the slots, so a right-to-left app gets `DockSide.Left` on the left of the screen. - Every panel and the content are `movableContentOf`: no change of the layout — extent, weight, order, side, restore, side order, direction, a resize — rebuilds a subtree, so a docked pane keeps its scroll position and its `remember`s. Extents are fitted proportionally when the window is too small. - Drop feedback is drawn at the rectangle the release produces and hit-tested against that same rectangle: the side's own band, inset behind existing layers, counting the dragged panel's side as already freed, at the width `dock()` will apply. A zone is entered when the dragged satellite's edge reaches it — the palette, not the pointer — and the side a panel already occupies is neither drawn nor droppable. - `examples/reader-dock-demo`: the whole thing as a right-to-left book reader, with the reader's own dividers, hover headers and Islands style. Covered by 435 unit tests (the new classes registered in the GraalVM battery), 10 real-window cases with robot-driven splitter drags and 13 dock-layout monkeys (4 layout profiles x 3 seeds plus a 400-action run). --- CLAUDE.md | 4 +- .../api/decorated-window-tao.api | 40 +- .../nucleusframework/window/tao/DockLayout.kt | 719 ++++++++------ .../window/tao/DockSplitter.kt | 129 +++ .../window/tao/DockTransferTarget.kt | 100 ++ .../window/tao/DockZoneHints.kt | 160 ++++ .../nucleusframework/window/tao/Satellite.kt | 20 +- .../window/tao/SatelliteDragSessions.kt | 21 +- .../window/tao/SatellitePlacement.kt | 48 +- .../window/tao/SatelliteWorkspace.kt | 230 ++++- .../window/tao/workspace/HostGeometry.kt | 27 + .../window/tao/DockLandingRectTest.kt | 254 +++++ .../window/tao/SatelliteDockedGeometryTest.kt | 151 +++ .../window/tao/SatelliteWorkspaceTest.kt | 41 +- .../window/tao/TaoSceneTestBattery.kt | 69 ++ .../tao/TaoSceneTestBatteryDriftTest.kt | 4 + .../window/tao/headful/DockLayoutFixture.kt | 284 ++++++ .../tao/headful/DockLayoutHeadfulCases.kt | 887 ++++++++++++++++++ .../headful/DockLayoutMonkeyHeadfulCases.kt | 582 ++++++++++++ .../tao/headful/TaoHeadfulTestSuiteMain.kt | 2 + .../tao/headful/WorkspaceChaosSupport.kt | 23 +- examples/reader-dock-demo/build.gradle.kts | 51 + .../nucleusframework/readerdockdemo/Main.kt | 325 +++++++ .../readerdockdemo/ReaderChrome.kt | 186 ++++ .../readerdockdemo/ReaderState.kt | 80 ++ settings.gradle.kts | 1 + 26 files changed, 4105 insertions(+), 333 deletions(-) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockSplitter.kt create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockTransferTarget.kt create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockZoneHints.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/DockLandingRectTest.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockedGeometryTest.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutFixture.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutHeadfulCases.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutMonkeyHeadfulCases.kt create mode 100644 examples/reader-dock-demo/build.gradle.kts create mode 100644 examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/Main.kt create mode 100644 examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderChrome.kt create mode 100644 examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderState.kt diff --git a/CLAUDE.md b/CLAUDE.md index 5da10110e..ab9b5a52b 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` (`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. `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 already occupies in that window, so it is neither drawn nor droppable. The Wayland DnD path (`DockTransferTarget`) hit-tests the same published rects. `dock()` and the preview share one width (`dockSeedExtent`), so what lights up is what the release produces. 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 whose 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 — 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..e3f3b922d 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -176,6 +176,15 @@ 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$NativeViewKt { public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$NativeViewKt; public fun ()V @@ -259,7 +268,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,11 +279,24 @@ 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 @@ -512,15 +535,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; } @@ -614,6 +641,7 @@ 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; @@ -632,6 +660,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 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..59c742d43 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 @@ -71,15 +86,37 @@ import dev.nucleusframework.window.tao.workspace.rememberHostGeometry * * 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 +129,423 @@ 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() + + /** 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() -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 + /** + * 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 layout = layoutBoundsInWindowPx.translate(-origin) + val leaving = dragged?.takeIf { it.isDocked && it !in panelsOn(side) && panelsOn(sideOf(it)).size == 1 } + val band = + (bandBoundsInWindowPx[side] ?: layoutBoundsInWindowPx) + .translate(-origin) + .let { measured -> + val freed = leaving?.dockedBoundsInWindowPx?.translate(-origin) ?: return@let measured + unionOf(measured, freed).intersect(layout) + } + 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) } - drag.drop = outcome - clearPreview() - return true + DockSide.Bottom -> { + val bottom = inset?.top ?: band.bottom + Rect(band.left, bottom - thicknessPx, band.right, bottom) + } + } } - private fun zoneAt(positionInWindowPx: Offset): DockTarget? { - val zonePx = SatelliteWorkspace.DockZoneWidth.value * geometry.scaleOrOne() - return dockSideAt(geometry.layoutBoundsInWindowPx, positionInWindowPx, zonePx)?.let { DockTarget(host, it) } - } + /** One movable subtree per docked satellite, so a panel changing side keeps its composition. */ + private val movables = HashMap Unit>() - private fun preview(event: DragAndDropEvent) { - val drag = workspace.transferDrag ?: return - workspace.dockPreview = zoneAt(event.positionInWindowPx())?.takeIf { it != drag.own } - } + fun movableOf(entry: SatelliteEntry): @Composable () -> Unit = + movables.getOrPut(entry) { movableContentOf { DockPanel(this, entry) } } - private fun clearPreview() { - if (workspace.dockPreview?.host === host) workspace.dockPreview = null + fun pruneMovables(docked: List) { + movables.keys.retainAll(docked.toSet()) } - /** 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 -} + fun isLayered(side: DockSide): Boolean = side in layeredSides -/** - * 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 = + /** 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 workspace = state.workspace val scope = remember(workspace, entry) { SatelliteScopeImpl(workspace, entry, isDocked = true) } - val headerBackground = LocalTitleBarStyle.current.colors.background // 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 +553,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..265ea706f --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockTransferTarget.kt @@ -0,0 +1,100 @@ +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) + 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 — and + * against the layout's edges while none are published. + */ + private fun zoneAt(positionInWindowPx: Offset): DockTarget? { + val zonePx = SatelliteWorkspace.DockZoneWidth.value * geometry.scaleOrOne() + val zones = geometry.zoneBoundsInWindowPx + val side = + if (zones.isEmpty()) { + dockSideAt(geometry.layoutBoundsInWindowPx, positionInWindowPx, zonePx) + } else { + zones.entries.firstOrNull { (_, rect) -> !rect.isEmpty && rect.contains(positionInWindowPx) }?.key + } + return side?.let { DockTarget(host, it) } + } + + private fun preview(event: DragAndDropEvent) { + val drag = workspace.transferDrag ?: return + workspace.dockPreview = zoneAt(event.positionInWindowPx())?.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..dab7a3fa6 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockZoneHints.kt @@ -0,0 +1,160 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +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.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.styling.LocalTitleBarStyle +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 as soon as the drag starts — that is what tells the + * user the gesture exists — and the one the satellite has entered fills in + * solid. Both are drawn where the panel would actually land + * ([DockLayoutState.landingRectPx]): along the side's own band rather than the + * whole edge, inside the layers already docked there, at the width the drop + * will produce once it is the active one. + * + * The side the dragged panel is already docked on, in this very window, is + * left out: dropping it back there changes nothing, so offering it as a + * target would promise something the release does not do. + */ +@Composable +internal fun BoxScope.DockZoneHints( + workspace: SatelliteWorkspace, + host: TaoWindow, + state: DockLayoutState, +) { + val dragged = workspace.draggedSatellite ?: return + val preview = workspace.dockPreview + val accent = LocalTitleBarStyle.current.colors.content + val density = LocalDensity.current + val hinted = hintedSides(dragged, host) + val zoneWidthPx = with(density) { SatelliteWorkspace.DockZoneWidth.toPx() } + // What a drag is hit-tested against is what is drawn: the idle strips, + // 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 rectangles. + val zones = + hinted.associateWith { side -> + state.landingRectPx(side, zoneWidthPx, joinsStack = false, dragged = dragged) + } + val origin = state.layoutBoundsInWindowPx.topLeft + DisposableEffect(zones, origin) { + val geometry = workspace.dockHostGeometry(host) + geometry?.zoneBoundsInWindowPx = zones.mapValues { (_, rect) -> rect.translate(origin) } + onDispose { geometry?.zoneBoundsInWindowPx = emptyMap() } + } + // 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) { + val active = preview?.host === host && preview.side == side + // 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 = + when { + !active -> SatelliteWorkspace.DockZoneWidth + state.isLayered(side) -> workspace.dockSeedExtent(dragged, side) + else -> workspace.plannedDockExtent(dragged, side) + } + val rect = + if (active) { + state.landingRectPx(side, with(density) { extent.toPx() }, joinsStack = true, dragged = dragged) + } else { + zones.getValue(side) + } + if (rect.isEmpty) continue + Box( + Modifier + .offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) } + .size(with(density) { rect.width.toDp() }, with(density) { rect.height.toDp() }) + .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), + ) + } +} + +/** + * The sides worth hinting while [dragged] is in flight over [host]: every one + * except the side [dragged] is already docked on **in this window**, since + * dropping it back there is a no-op and offering it would promise a move that + * does not happen. Dragged from another window, or floating, every side is a + * real target. + */ +internal fun hintedSides( + dragged: SatelliteEntry, + host: TaoWindow, +): List { + val own = (dragged.placement as? SatellitePlacement.Docked)?.side?.takeIf { dragged.dockHost === host } + return if (own == null) DockSide.entries else DockSide.entries.filter { 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)) + +/** 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 + }, + ), + ) + } + +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 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..978a08d82 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 @@ -17,6 +17,7 @@ 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 @@ -412,12 +413,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 } 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..fa2771368 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.dockTargetAt(Rect(topLeft, windowSizePx()), pointer) } override fun end(pointerScreenPx: Offset) { @@ -80,6 +82,11 @@ private class FloatingDragSession( cancel() if (target != null) workspace.dock(entry.id, target.side, host = target.host) } + + /** 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( @@ -100,18 +107,24 @@ private class DockedDragSession( 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.dockTargetAt(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) + 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.dockTargetAt(ghostRectPx(), drop)?.takeIf { it != own } cancel() when { target != null -> workspace.dock(entry.id, target.side, host = target.host) 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..437c57200 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,38 @@ 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). + * 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. + * @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/SatelliteWorkspace.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspace.kt index a0c8f535c..5db0b9805 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 @@ -26,6 +26,7 @@ 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 @@ -118,8 +119,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 +225,27 @@ 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) + } /** Sets [dockExtent]; clamped to [MinDockExtent]. Driven by the [DockLayout] splitters. */ public fun setDockExtent( @@ -232,6 +255,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 ────────────────────────────────────────────────────────── /** @@ -285,8 +343,12 @@ 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. + * that side; `null` appends it after them. 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. A satellite moved between + * docks keeps its weight. */ public fun dock( id: String, @@ -296,11 +358,12 @@ public class SatelliteWorkspace( ) { val entry = entryMap[id] ?: 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)) + val extent = dockSeedExtent(entry, side) + val weight = (current as? SatellitePlacement.Docked)?.weight ?: 1f + if (current is SatellitePlacement.Floating) entry.lastFloating = currentFloating(entry, current) + if (side !in extents) setDockExtent(side, extent) + entry.placement = + SatellitePlacement.Docked(side, order ?: nextOrder(side, exclude = entry), extent, weight) entry.preferredDockSide = side entry.dockHost = host?.takeIf { it in members } @@ -404,13 +467,36 @@ 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) } + + 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 } @@ -723,6 +809,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( @@ -816,6 +905,123 @@ 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 + val side = dockSideEntered(zones, draggedRectPx, pointerPx) + // Over the layout, in a zone or not: no other layout under it is + // consulted, exactly as for a pointer hit. + return if (side != null) DockHit.Zone(DockTarget(host, side)) else DockHit.Content +} + +/** + * 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. + * + * Several zones at once — a palette larger than the layout reaches all four — + * are resolved by [pointer] when it is in exactly one of them, 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.isEmpty } + val gaps = + live + .filter { (side, zone) -> overlapsAcross(zone, dragged, side) } + .mapValues { (side, zone) -> abs(edgePx(dragged, side) - outerEdgePx(zone, side)) } + .filter { (side, gap) -> gap <= thicknessPx(live.getValue(side), side) } + val underPointer = live.filterValues { it.contains(pointer) }.keys + val candidates = gaps.keys + underPointer + candidates.singleOrNull()?.let { return it } + if (candidates.isEmpty()) return null + 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/workspace/HostGeometry.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/HostGeometry.kt index 9a3e98496..3e0eddffa 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,6 +34,16 @@ 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 @@ -49,6 +61,21 @@ internal class HostGeometry( /** 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 { (_, rect) -> rect.translate(origin) } + } + val rect = layoutBoundsInWindowPx.translate(origin) + return DockSide.entries.associateWith { side -> edgeStripPx(rect, side, zoneWidthPx) } + } } /** 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..4dd6a662e --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/DockLandingRectTest.kt @@ -0,0 +1,254 @@ +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 + +/** + * 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)) + } + + @Test + fun `a docked panel is not offered the side it is 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)) + } + + @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)) + } +} + +/** + * 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 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 `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/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/SatelliteWorkspaceTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspaceTest.kt index 5a5b256bd..a124f980e 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) @@ -225,7 +237,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 +305,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 +358,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 +387,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 +416,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 +449,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 +479,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 +492,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 +603,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 +627,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 +647,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/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index eea76d82a..5212ed3cb 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,75 @@ 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 on") { + DockZoneHintSidesTest().`a docked panel is not offered the side it is on`() + } + 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: 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`() } 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..687f37009 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,6 +91,10 @@ class TaoSceneTestBatteryDriftTest { LcdTextTest::class.java, WindowPositionerTest::class.java, SatelliteWorkspaceTest::class.java, + SatelliteDockedGeometryTest::class.java, + DockLandingRectTest::class.java, + DockZoneHintSidesTest::class.java, + DockTargetFromDraggedRectTest::class.java, RelocatingSaveableStateRegistryTest::class.java, WindowGroupTest::class.java, HostGeometryTest::class.java, 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..a5a2b704a --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutFixture.kt @@ -0,0 +1,284 @@ +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, +) + +/** + * 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()) + + /** 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, + ) { 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 + SideEffect { + 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() +} + +/** 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..b4d611a1e --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutHeadfulCases.kt @@ -0,0 +1,887 @@ +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.DockPanelHeaderHeight +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.DockTarget +import dev.nucleusframework.window.tao.SatelliteDragOrigin +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. + * + * 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(), + ) + + // ── 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).contains(DockSide.Bottom)) { + "the bottom panel is offered the side it is already on: ${hintedSides(tree, window)}" + } + check( + hintedSides(requireNotNull(workspace.satellite(INSPECTOR)), window).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 ────────────────────────────────────────────────────────── + + 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}" } + + 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 +} 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/TaoHeadfulTestSuiteMain.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt index f06c4ef3b..49ab76469 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,6 +386,8 @@ public object TaoHeadfulTestSuiteMain { SatelliteWorkspaceHeadfulCases.all() + SatelliteWorkspaceStressHeadfulCases.all() + SatelliteWorkspaceMonkeyHeadfulCases.all() + + DockLayoutHeadfulCases.all() + + DockLayoutMonkeyHeadfulCases.all() + TabWorkspaceHeadfulCases.all() + TabWorkspaceLifecycleHeadfulCases.all() + TabWorkspaceMotionHeadfulCases.all() + 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/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..d2f8164fa --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/Main.kt @@ -0,0 +1,325 @@ +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.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.Satellite +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.WindowScaffold +import dev.nucleusframework.window.material.MaterialTitleBar +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 + +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 built entirely from satellites. + * + * 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 32 dp hover strips; the + * *Islands* style turns every pane into a rounded card. And because every pane + * is a satellite, each can be torn out into a window of its own and dropped + * back — that is the only thing the split panes could not do. + */ +fun main() = + nucleusApplication { + val reader = remember { ReaderState() } + val dark = isSystemInDarkMode() + val colors = if (dark) DarkColors else LightColors + + DecoratedWindow( + onCloseRequest = ::exitApplication, + title = "Reader", + state = rememberWindowState(width = WINDOW_W_DP.dp, height = WINDOW_H_DP.dp), + minimumSize = DpSize(MIN_W_DP.dp, MIN_H_DP.dp), + ) { + JoinSatelliteWorkspace(reader.workspace) + ReaderTheme(colors) { + WindowBackground(colors.background) + WindowAppearance(if (dark) WindowAppearanceMode.Dark else WindowAppearanceMode.Light) + WindowScaffold(titleBar = { MaterialTitleBar { Text("Reader") } }) { padding -> + Surface(Modifier.fillMaxSize().padding(padding), color = colors.background) { + ReaderBody(reader) + } + } + } + } + + // Every pane, declared once at application scope; the workspace decides + // whether it is a panel of the dock or a window of its own. + ReaderTheme(colors) { + for (pane in Pane.entries) { + Satellite( + workspace = reader.workspace, + id = pane.id, + title = pane.title, + initialPlacement = pane.home, + initiallyOpen = pane.openAtStart, + header = { PaneHeader(reader.style) }, + ) { + Surface(Modifier.fillMaxSize(), color = colors.surface) { PaneContent(pane) } + } + } + } + } + +/** The reader: its two activity bars around the dock layout, all right-to-left. */ +@Composable +private fun ReaderBody(reader: ReaderState) { + 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(pane)) { reader.toggle(pane) } + } + } + VerticalDivider() + DockLayout( + workspace = reader.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) { TextColumn() } + } + VerticalDivider() + // End bar: the content panes and the style switch. + ActivityBar { + for (pane in listOf(Pane.Targum, Pane.Comments, Pane.Sources)) { + BarButton(pane.title.take(1), selected = reader.isOpen(pane)) { reader.toggle(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() } + BarButton("R", selected = reader.savedLayout != null) { reader.restoreLayout() } + BarButton("⟲", selected = false) { reader.resetLayout() } + } + } + } +} + +/** The main text: the document, with a breadcrumb strip under it. */ +@Composable +private fun TextColumn() { + 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("בראשית", 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( + "תנ״ך › תורה › בראשית › פרק א", + fontSize = BREADCRUMB_SP.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** A pane's body: a list the user can scroll, whose position survives dock and undock. */ +@Composable +private fun PaneContent(pane: Pane) { + val scroll = rememberScrollState() + var selected by rememberSaveable { mutableIntStateOf(-1) } + Column( + Modifier.fillMaxSize().verticalScroll(scroll).padding(PANE_PADDING_DP.dp), + verticalArrangement = Arrangement.spacedBy(ITEM_GAP_DP.dp), + ) { + repeat(ITEMS) { index -> + val chosen = selected == index + Text( + text = "${pane.title} ${index + 1}", + fontSize = TEXT_SP.sp, + color = if (chosen) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ITEM_CORNER_DP.dp)) + .background(if (chosen) MaterialTheme.colorScheme.surfaceContainerHigh else Color.Transparent) + .clickable { selected = index } + .padding(ITEM_PADDING_DP.dp), + ) + } + } +} + +@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, per + * window scene — and once more around the satellites, whose floating windows + * get it through the bridged locals. + */ +@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 WINDOW_W_DP = 1280 +private const val WINDOW_H_DP = 820 +private const val MIN_W_DP = 640 +private const val MIN_H_DP = 420 +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 = 60 +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..87955c1a0 --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderChrome.kt @@ -0,0 +1,186 @@ +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) { + HeaderAction(FLOAT_GLYPH) { undock() } + } else { + HeaderAction(DOCK_GLYPH) { dock() } + } + HeaderAction(HIDE_GLYPH) { close() } + } + } + } + if (isDocked && style == ReaderStyle.Classic) HorizontalDivider() + } +} + +@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 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 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..958787403 --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderState.kt @@ -0,0 +1,80 @@ +package dev.nucleusframework.readerdockdemo + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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 + +/** The two looks of the reader: dividers everywhere, or every pane a rounded card. */ +enum class ReaderStyle { + Classic, + Islands, +} + +/** One pane of the reader: a satellite with a home in the dock. */ +enum class Pane( + val id: String, + val title: String, + val home: SatellitePlacement.Docked, + val openAtStart: Boolean, +) { + Tree("tree", "ספרים", SatellitePlacement.Docked(DockSide.Right, order = 0, extent = 200.dp), openAtStart = true), + Toc("toc", "תוכן", SatellitePlacement.Docked(DockSide.Right, order = 1, extent = 170.dp), openAtStart = 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), +} + +/** + * What the demo drives: the workspace every pane is declared against, the + * visual style, and the saved layout. + * + * Everything the bars do is a workspace call — toggle a pane, save or restore + * the layout. The layout of the reader itself (which side is layered, which + * side owns the corners) is declared once in `Main.kt`; the workspace holds + * only what the user changed. + */ +class ReaderState { + val workspace = SatelliteWorkspace() + + var style: ReaderStyle by mutableStateOf(ReaderStyle.Classic) + + var savedLayout: SatelliteLayoutSnapshot? by mutableStateOf(null) + private set + + fun isOpen(pane: Pane): Boolean = workspace.satellite(pane.id)?.isOpen == true + + /** Shows or hides a pane. Commentaries and sources share the bottom, so one closes the other. */ + fun toggle(pane: Pane) { + val opening = !isOpen(pane) + when (pane) { + Pane.Comments -> if (opening) workspace.close(Pane.Sources.id) + Pane.Sources -> if (opening) workspace.close(Pane.Comments.id) + else -> Unit + } + workspace.toggle(pane.id) + } + + fun saveLayout() { + savedLayout = workspace.snapshot() + } + + fun restoreLayout() { + savedLayout?.let(workspace::restore) + } + + /** Every pane back where it started, at its starting width. */ + fun resetLayout() { + for (pane in Pane.entries) { + workspace.dock(pane.id, pane.home.side, order = pane.home.order) + pane.home.extent?.let { workspace.setDockedExtent(pane.id, it) } + workspace.setDockedWeight(pane.id, pane.home.weight) + if (pane.openAtStart) workspace.open(pane.id) else workspace.close(pane.id) + } + } +} 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") From d74a96a2dfe757f223bc9c11e675722f4978d87f Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 7 Sep 2026 18:13:05 +0300 Subject: [PATCH 02/13] feat(tao): stable dock ranks, and reorder a side's panels by dragging one over the others MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A panel docked again always went to the end of its side — the header button, a drop, a floating palette brought back — and nothing let the user say otherwise: a drop knew the side, not the place. - `dock(id, side, order)` inserts at that rank and keeps a side's ranks contiguous from 0; `undock()` closes the gap. `order = null` returns the satellite to the rank it last held on that side (declared, or the one it left), remembered per side in `SatelliteEntry.dockMemory` with its weight, and appends only when it never sat there. Closed panels keep their rank. - `DockTarget.order`: a side with panels publishes one slot per rank (`DockDropZone.slots`, cut at the neighbours' centres, the dragged panel excluded) and the pointer over the stack picks the rank — its own being no target. Drawn as an insertion bar between the two panels; a new innermost layer and an empty side keep the rectangle they promised. A pointer over a stack beats a strip running across its corner. The Wayland transfer path resolves the same slots. Covered by new unit classes (ranks, slots, bars, hit test, a drag session that reorders), three real-window cases on X11 (return to rank after undock, robot drag of a layer to the first rank, split side reorder with a closed panel in the middle) and one on native Wayland. --- CLAUDE.md | 2 +- .../api/decorated-window-tao.api | 9 +- .../nucleusframework/window/tao/DockLayout.kt | 104 +++++- .../window/tao/DockTransferTarget.kt | 23 +- .../window/tao/DockZoneHints.kt | 153 +++++--- .../window/tao/SatelliteDragSessions.kt | 16 +- .../window/tao/SatellitePlacement.kt | 11 +- .../window/tao/SatelliteWorkspace.kt | 177 +++++++-- .../window/tao/workspace/HostGeometry.kt | 42 ++- .../window/tao/DockLandingRectTest.kt | 171 ++++++++- .../window/tao/SatelliteDockRankTest.kt | 218 +++++++++++ .../window/tao/SatelliteWorkspaceTest.kt | 17 - .../window/tao/TaoSceneTestBattery.kt | 50 ++- .../tao/TaoSceneTestBatteryDriftTest.kt | 2 + .../window/tao/headful/DockLayoutFixture.kt | 25 ++ .../tao/headful/DockLayoutHeadfulCases.kt | 341 +++++++++++++++++- .../headful/WaylandWorkspaceHeadfulCases.kt | 92 ++++- 17 files changed, 1319 insertions(+), 134 deletions(-) create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockRankTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index ab9b5a52b..0dc001716 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ 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. `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 already occupies in that window, so it is neither drawn nor droppable. The Wayland DnD path (`DockTransferTarget`) hit-tests the same published rects. `dock()` and the preview share one width (`dockSeedExtent`), so what lights up is what the release produces. Headful coverage: `DockLayoutHeadfulCases` (robot splitter drags) + `DockLayoutMonkeyHeadfulCases` (profiles × seeds, `-Dnucleus.tao.headful.filter="dock layout"`). +- `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. `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. **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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. 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 diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index e3f3b922d..bd19c59cd 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -299,13 +299,16 @@ public abstract interface class dev/nucleusframework/window/tao/DockSplitterScop 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; 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 59c742d43..19ee1b5a6 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 @@ -82,7 +82,13 @@ 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 outlined under the pointer until released. Over + * a side that already has panels, the pointer's place along the stack picks + * the rank the drop takes — a bar between the two panels it would land + * 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 @@ -244,6 +250,102 @@ internal class DockLayoutState( } } + /** + * 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, or one has not been + * placed yet: nothing to order against. + */ + fun dropSlotsPx( + side: DockSide, + stripPx: Rect, + dragged: SatelliteEntry?, + ): List { + 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]) + } + } + return if (ranksDescend(side)) ascending.asReversed() else ascending + } + + /** + * The boundary a panel dropped at rank [order] on [side] slides into, as a + * bar of [thicknessPx] across the stack: between the panels of ranks + * `order - 1` and `order` — in the middle of the splitter that separates + * them — or along the stack's first or last edge. The [dragged] panel is + * not counted, as in [dropSlotsPx]. `null` while the side has no other + * panel, or one has not been placed yet. + */ + fun insertionBarPx( + side: DockSide, + dragged: SatelliteEntry?, + order: Int, + thicknessPx: Float, + ): Rect? { + val origin = layoutBoundsInWindowPx.topLeft + val panels = panelsOn(side).filter { it !== dragged } + if (panels.isEmpty()) return null + val rects = panels.map { (it.dockedBoundsInWindowPx ?: return null).translate(-origin) } + val alongX = side.isVertical == isLayered(side) + val descending = ranksDescend(side) + + // A panel's edge facing the lower ranks, and the one facing the higher. + fun near(rect: Rect): Float = + if (alongX) (if (descending) rect.right else rect.left) else (if (descending) rect.bottom else rect.top) + + fun far(rect: Rect): Float = + if (alongX) (if (descending) rect.left else rect.right) else (if (descending) rect.top else rect.bottom) + val rank = order.coerceIn(0, rects.size) + val at = + when (rank) { + 0 -> near(rects.first()) + rects.size -> far(rects.last()) + else -> (far(rects[rank - 1]) + near(rects[rank])) / 2f + } + val across = rects.reduce(::unionOf) + val half = thicknessPx / 2f + return if (alongX) { + Rect(at - half, across.top, at + half, across.bottom) + } else { + Rect(across.left, at - half, across.right, at + half) + } + } + + /** 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) + /** One movable subtree per docked satellite, so a panel changing side keeps its composition. */ private val movables = HashMap Unit>() 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 index 265ea706f..c81e7dd53 100644 --- 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 @@ -69,19 +69,22 @@ internal class DockTransferTarget( /** * 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 — and - * against the layout's edges while none are published. + * 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. */ - private fun zoneAt(positionInWindowPx: Offset): DockTarget? { + internal fun zoneAt(positionInWindowPx: Offset): DockTarget? { val zonePx = SatelliteWorkspace.DockZoneWidth.value * geometry.scaleOrOne() val zones = geometry.zoneBoundsInWindowPx - val side = - if (zones.isEmpty()) { - dockSideAt(geometry.layoutBoundsInWindowPx, positionInWindowPx, zonePx) - } else { - zones.entries.firstOrNull { (_, rect) -> !rect.isEmpty && rect.contains(positionInWindowPx) }?.key - } - return side?.let { DockTarget(host, it) } + 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) { 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 index dab7a3fa6..705db1610 100644 --- 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.workspace.DockDropZone import kotlin.math.roundToInt /** @@ -39,9 +40,13 @@ import kotlin.math.roundToInt * whole edge, inside the layers already docked there, at the width the drop * will produce once it is the active one. * - * The side the dragged panel is already docked on, in this very window, is - * left out: dropping it back there changes nothing, so offering it as a - * target would promise something the release does not do. + * A side with panels on it is also cut into ranks ([DockLayoutState.dropSlotsPx]), + * one region per place the panel can take among them, and the active rank is + * drawn as a bar on the edge it would slide into — except a new innermost + * layer, drawn as the column it becomes. 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( @@ -50,27 +55,29 @@ internal fun BoxScope.DockZoneHints( state: DockLayoutState, ) { val dragged = workspace.draggedSatellite ?: return - val preview = workspace.dockPreview val accent = LocalTitleBarStyle.current.colors.content val density = LocalDensity.current - val hinted = hintedSides(dragged, host) + 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, - // 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 rectangles. + // 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 -> - state.landingRectPx(side, zoneWidthPx, joinsStack = false, dragged = dragged) + 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 { (_, rect) -> rect.translate(origin) } + 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. @@ -80,46 +87,106 @@ internal fun BoxScope.DockZoneHints( .pointerHoverIcon(TaoPointerIcons.Grabbing, overrideDescendants = true), ) for (side in hinted) { - val active = preview?.host === host && preview.side == side - // 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 = - when { - !active -> SatelliteWorkspace.DockZoneWidth - state.isLayered(side) -> workspace.dockSeedExtent(dragged, side) - else -> workspace.plannedDockExtent(dragged, side) - } - val rect = - if (active) { - state.landingRectPx(side, with(density) { extent.toPx() }, joinsStack = true, dragged = dragged) - } else { - zones.getValue(side) - } - if (rect.isEmpty) continue - Box( - Modifier - .offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) } - .size(with(density) { rect.width.toDp() }, with(density) { rect.height.toDp() }) - .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), - ) + SideHint(workspace, state, host, side, zones.getValue(side), dragged, own, accent) } } +/** + * One side's feedback: the active rank as a bar between the two panels it + * lands between — or, for a new innermost layer and for an empty side, the + * rect the panel will occupy — else the idle strip. + */ +@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?, + accent: Color, +) { + val density = LocalDensity.current + 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 + val order = preview?.order?.takeIf { active && zone.slots.isNotEmpty() } + when { + // Between two panels of the stack — a new innermost layer is drawn as + // the column it becomes, like a drop on an empty side. + order != null && !(state.isLayered(side) && order == zone.slots.lastIndex) -> { + val bar = state.insertionBarPx(side, dragged, order, with(density) { InsertionBarThickness.toPx() }) + if (bar != null) ZoneRect(bar, accent.copy(alpha = INSERTION_BAR_ALPHA), outline = null) + } + active -> { + // 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 rect = state.landingRectPx(side, with(density) { extent.toPx() }, joinsStack = true, dragged = dragged) + ZoneRect(rect, accent.copy(alpha = ZONE_ACTIVE_ALPHA), outline = accent, dashed = false) + } + else -> { + ZoneRect( + zone.strip, + accent.copy(alpha = ZONE_HINT_ALPHA), + outline = accent.copy(alpha = ZONE_OUTLINE_ALPHA), + ) + } + } +} + +@Composable +private fun ZoneRect( + rect: Rect, + fill: Color, + outline: Color?, + dashed: Boolean = true, +) { + 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() }) + .background(fill) + .then(if (outline != null) Modifier.dashedOutline(outline, dashed) else Modifier), + ) +} + /** * The sides worth hinting while [dragged] is in flight over [host]: every one - * except the side [dragged] is already docked on **in this window**, since - * dropping it back there is a no-op and offering it would promise a move that - * does not happen. Dragged from another window, or floating, every side is a - * real target. + * except the side [dragged] is already docked on **in this window** while it + * is alone there, 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. + * [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 } - return if (own == null) DockSide.entries else DockSide.entries.filter { it != own } + val alone = + own != null && + satellites.none { + it !== dragged && + it.isShown && + it.dockHost === host && + (it.placement as? SatellitePlacement.Docked)?.side == own + } + return if (alone) DockSide.entries.filter { it != own } else DockSide.entries } /** The smallest rect containing both. */ @@ -153,6 +220,8 @@ private fun Modifier.dashedOutline( } private val ZoneOutlineWidth: Dp = 1.5.dp +private val InsertionBarThickness: Dp = 4.dp +private const val INSERTION_BAR_ALPHA = 0.9f private val ZoneDashOn: Dp = 5.dp private val ZoneDashOff: Dp = 4.dp private const val ZONE_HINT_ALPHA = 0.10f 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 fa2771368..fce36e4de 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 @@ -80,7 +80,7 @@ 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. */ @@ -102,7 +102,8 @@ 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 @@ -127,7 +128,7 @@ private class DockedDragSession( val target = workspace.dockTargetAt(ghostRectPx(), drop)?.takeIf { it != own } cancel() when { - target != null -> workspace.dock(entry.id, target.side, host = target.host) + target != null -> workspace.dropAt(entry.id, target) panelScreenRectPx.contains(drop) -> Unit else -> workspace.undock(entry.id, workspace.floatingAtScreen(drop - grabOffsetPx, panelScreenRectPx.size)) } @@ -167,18 +168,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 437c57200..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 @@ -96,10 +96,13 @@ public sealed interface SatellitePlacement { * [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) - * on a split side, and from the edge towards the content on a layered - * one. + * @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 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 5db0b9805..537db0b88 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 @@ -67,6 +68,9 @@ public class SatelliteEntry internal constructor( /** `true` while [placement] is [SatellitePlacement.Docked]. */ public val isDocked: Boolean get() = placement is SatellitePlacement.Docked + /** `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. */ public var preferredDockSide: DockSide by mutableStateOf((initialPlacement as? SatellitePlacement.Docked)?.side ?: DockSide.Right) @@ -84,6 +88,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) @@ -342,13 +355,25 @@ 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 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. A satellite moved between - * docks keeps its weight. + * 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. */ public fun dock( id: String, @@ -359,16 +384,54 @@ public class SatelliteWorkspace( val entry = entryMap[id] ?: return val current = entry.placement val extent = dockSeedExtent(entry, side) - val weight = (current as? SatellitePlacement.Docked)?.weight ?: 1f if (current is SatellitePlacement.Floating) entry.lastFloating = currentFloating(entry, current) + leaveStack(entry) + val remembered = entry.dockMemory[side] + val weight = (current as? SatellitePlacement.Docked)?.weight ?: remembered?.weight ?: 1f if (side !in extents) setDockExtent(side, extent) - entry.placement = - SatellitePlacement.Docked(side, order ?: nextOrder(side, exclude = entry), extent, weight) - entry.preferredDockSide = side entry.dockHost = host?.takeIf { it in members } ?: entry.dockHost?.takeIf { it in members } ?: owner + entry.placement = SatellitePlacement.Docked(side, order = 0, extent, weight) + insertInStack(entry, order ?: 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 + 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 }) } /** @@ -384,7 +447,9 @@ public class SatelliteWorkspace( val entry = entryMap[id] ?: 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) } /** @@ -711,6 +776,9 @@ 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 -> { applyFloating(entry, placement) @@ -788,15 +856,53 @@ 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`. + */ + private fun insertInStack( + entry: SatelliteEntry, + index: Int?, + ) { + val docked = entry.placement as SatellitePlacement.Docked + val stack = stackOf(docked.side, entry.dockHost, exclude = entry).toMutableList() + stack.add(index?.coerceIn(0, stack.size) ?: stack.size, entry) + renumber(stack) + } + + 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 { @@ -823,10 +929,18 @@ public class SatelliteWorkspace( } } -/** A dock zone: the [side] of the [DockLayout] in [host]. */ +/** + * 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, ) /** @@ -921,10 +1035,10 @@ internal fun HostGeometry.dockHitTest( val onPointer = rect.contains(pointerPx) if (!overlaps && !onPointer) return null val zones = zoneScreenRectsPx(zoneWidth.value * scaleFactor()) ?: return null - val side = dockSideEntered(zones, draggedRectPx, pointerPx) // Over the layout, in a zone or not: no other layout under it is // consulted, exactly as for a pointer hit. - return if (side != null) DockHit.Zone(DockTarget(host, side)) else DockHit.Content + val side = dockSideEntered(zones, draggedRectPx, pointerPx) ?: return DockHit.Content + return DockHit.Zone(DockTarget(host, side, zones.getValue(side).slotAt(pointerPx))) } /** @@ -943,26 +1057,31 @@ internal fun HostGeometry.dockHitTest( * wherever it is dragged, and treating that as "entered" would pin it to a * zone for the whole gesture. * - * Several zones at once — a palette larger than the layout reaches all four — - * are resolved by [pointer] when it is in exactly one of them, so an + * 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, + zones: Map, dragged: Rect, pointer: Offset, ): DockSide? { - val live = zones.filterValues { !it.isEmpty } + val live = zones.filterValues { !it.strip.isEmpty } val gaps = live - .filter { (side, zone) -> overlapsAcross(zone, dragged, side) } - .mapValues { (side, zone) -> abs(edgePx(dragged, side) - outerEdgePx(zone, side)) } - .filter { (side, gap) -> gap <= thicknessPx(live.getValue(side), side) } + .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 } } 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 3e0eddffa..96ad689c4 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 @@ -42,7 +42,7 @@ internal class HostGeometry( * none; the hit test then falls back to the edges of * [layoutBoundsInWindowPx]. */ - var zoneBoundsInWindowPx: Map = emptyMap() + 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 @@ -68,13 +68,47 @@ internal class HostGeometry( * of the layout — the same four zones the pointer hit test uses. `null` * while [clientOriginPx] is. */ - fun zoneScreenRectsPx(zoneWidthPx: Float): Map? { + fun zoneScreenRectsPx(zoneWidthPx: Float): Map? { val origin = clientOriginPx() ?: return null if (zoneBoundsInWindowPx.isNotEmpty()) { - return zoneBoundsInWindowPx.mapValues { (_, rect) -> rect.translate(origin) } + return zoneBoundsInWindowPx.mapValues { (_, zone) -> zone.translate(origin) } } val rect = layoutBoundsInWindowPx.translate(origin) - return DockSide.entries.associateWith { side -> edgeStripPx(rect, side, zoneWidthPx) } + 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. + */ + fun slotAt(point: Offset): Int? = slots.indices.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/test/kotlin/dev/nucleusframework/window/tao/DockLandingRectTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/DockLandingRectTest.kt index 4dd6a662e..88cd7fefc 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -135,14 +136,33 @@ class DockZoneHintSidesTest { @Test fun `a floating satellite is offered every side`() { val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true) - assertEquals(DockSide.entries, hintedSides(entry, host)) + assertEquals(DockSide.entries, hintedSides(entry, host, workspace.satellites)) } @Test - fun `a docked panel is not offered the side it is on`() { + 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)) + 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 @@ -150,7 +170,115 @@ class DockZoneHintSidesTest { 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)) + assertEquals(DockSide.entries, hintedSides(entry, other, workspace.satellites)) + } +} + +/** + * The ranks a drop can take among the panels of a side + * ([DockLayoutState.dropSlotsPx]) and the bar drawn for one + * ([DockLayoutState.insertionBarPx]), 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 `the insertion bar sits on the edge between the two ranks`() { + // Layered right: rank 1 is between the tree (900..1000) and the toc (800..900). + assertEquals(Rect(898f, 0f, 902f, 600f), state.insertionBarPx(DockSide.Right, null, 1, 4f)) + assertEquals(Rect(998f, 0f, 1002f, 600f), state.insertionBarPx(DockSide.Right, null, 0, 4f), "the side's edge") + assertEquals( + Rect(798f, 0f, 802f, 600f), + state.insertionBarPx(DockSide.Right, null, 2, 4f), + "past the innermost", + ) + // Split bottom, the sources dragged: only the comments remain. + assertEquals(Rect(348f, 540f, 352f, 600f), state.insertionBarPx(DockSide.Bottom, sources, 1, 4f)) + assertEquals(Rect(-2f, 540f, 2f, 600f), state.insertionBarPx(DockSide.Bottom, sources, 0, 4f)) + assertNull(state.insertionBarPx(DockSide.Left, null, 0, 4f)) } } @@ -220,7 +348,7 @@ class DockTargetFromDraggedRectTest { // 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 Rect(540f, 40f, 604f, 600f)) + 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) @@ -239,6 +367,39 @@ class DockTargetFromDraggedRectTest { 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() 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/SatelliteWorkspaceTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspaceTest.kt index a124f980e..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 @@ -113,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() 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 5212ed3cb..a35f7f6a4 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 @@ -704,8 +704,29 @@ public object TaoSceneTestBattery { 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 on") { - DockZoneHintSidesTest().`a docked panel is not offered the side it is on`() + 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: the insertion bar sits on the edge between the two ranks") { + DockDropSlotsTest().`the insertion bar sits on the edge between the two ranks`() } 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`() @@ -716,6 +737,12 @@ public object TaoSceneTestBattery { 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`() } @@ -732,8 +759,20 @@ 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("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`() @@ -765,6 +804,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`() } 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 687f37009..472f995bb 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 @@ -94,6 +94,8 @@ class TaoSceneTestBatteryDriftTest { SatelliteDockedGeometryTest::class.java, DockLandingRectTest::class.java, DockZoneHintSidesTest::class.java, + DockDropSlotsTest::class.java, + SatelliteDockRankTest::class.java, DockTargetFromDraggedRectTest::class.java, RelocatingSaveableStateRegistryTest::class.java, WindowGroupTest::class.java, 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 index a5a2b704a..2191bacd2 100644 --- 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 @@ -246,6 +246,31 @@ internal suspend fun TaoWindowTestScope.awaitDockedBodies( 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, 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 index b4d611a1e..e623bb89c 100644 --- 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 @@ -10,6 +10,7 @@ import dev.nucleusframework.window.tao.DockPanelHeaderHeight import dev.nucleusframework.window.tao.DockSide import dev.nucleusframework.window.tao.DockTarget 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 @@ -38,7 +39,15 @@ import kotlin.math.abs * 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. + * 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; + * 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 @@ -59,8 +68,302 @@ internal object DockLayoutHeadfulCases { aDropOnALayeredSideAddsALayerOfTheWindowsWidth(), undockingALayerLiftsTheWindowOffThePanel(), thePaletteEdgeDecidesTheZoneNotThePointer(), + aPanelDockedAgainReturnsToTheRankItLeft(), + aLayerDraggedOverTheOutermostOneBecomesTheFirst(), + aSplitPanelDroppedOnItsStackTakesTheRankUnderThePointer(), ) + // ── 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) + 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, layout.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, layout.right, LAYOUT_TOLERANCE_PX * 2), + ) { "the notes are not at the edge: $notes vs $layout" } + 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 ───────────── /** @@ -110,11 +413,16 @@ internal object DockLayoutHeadfulCases { // The panel already on the bottom is not offered that side. val tree = requireNotNull(workspace.satellite(TREE)) - check(!hintedSides(tree, window).contains(DockSide.Bottom)) { - "the bottom panel is offered the side it is already on: ${hintedSides(tree, window)}" + 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).size == DockSide.entries.size, + hintedSides(requireNotNull(workspace.satellite(INSPECTOR)), window, workspace.satellites).size == + DockSide.entries.size, ) { "a floating palette must be offered every side" } @@ -848,6 +1156,25 @@ internal object DockLayoutHeadfulCases { // ── 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)), @@ -884,4 +1211,10 @@ internal object DockLayoutHeadfulCases { /** 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 } 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..aa609b08c 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,8 @@ 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.SatellitePlacement import dev.nucleusframework.window.tao.TabDropTarget import dev.nucleusframework.window.tao.TransferDrop import kotlin.math.abs @@ -30,7 +32,10 @@ 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; + * 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 +49,83 @@ internal object WaylandWorkspaceHeadfulCases { recordedZoneDocksAndNoRecordUndocks(), everyZoneResolvesFromAWindowCoordinate(), tabTransferDragTearsOffAndMergesBack(), + aTransferDropResolvesARankAndReorders(), ) + 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 +368,14 @@ 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 } From 254572d71bbe617e29b214753401f2915de83e1d Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 7 Sep 2026 18:41:07 +0300 Subject: [PATCH 03/13] feat(tao): let a satellite name the sides it may be docked on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app could not keep a pane off an edge: every satellite was droppable on all four sides, so a reader whose top is its own activity bar had no way to say so. - `Satellite(dockSides = …)`, fixed at declaration like the placement: `dock()` and `restore()` refuse any other side, `hintedSides` and the zone hints neither draw nor publish it, the drag sessions resolve through `dockTargetFor(entry, …)`, the Wayland transfer target filters on it, and the default header drops its Dock action for a floating-only palette (`dockSides = emptySet()`). A declared docked placement must name an allowed side. - `preferredDockSide` starts on an allowed side, so the header's Dock button always has somewhere to go. - `reader-dock-demo` declares its panes for the left, right and bottom only: the top strip never lights up. Covered by `SatelliteDockSidesTest` (5 cases, in the GraalVM battery) and a real-window case: the top is neither hinted nor published, a release there leaves the palette floating, a direct dock is refused, and the left side still takes it. --- CLAUDE.md | 2 +- .../api/decorated-window-tao.api | 7 +- .../window/tao/DockTransferTarget.kt | 5 +- .../window/tao/DockZoneHints.kt | 5 +- .../nucleusframework/window/tao/Satellite.kt | 10 +- .../window/tao/SatelliteDragSessions.kt | 6 +- .../window/tao/SatelliteWorkspace.kt | 40 ++++- .../window/tao/SatelliteDockSidesTest.kt | 161 ++++++++++++++++++ .../window/tao/TaoSceneTestBattery.kt | 21 +++ .../tao/TaoSceneTestBatteryDriftTest.kt | 1 + .../window/tao/headful/DockLayoutFixture.kt | 2 + .../tao/headful/DockLayoutHeadfulCases.kt | 139 ++++++++++++++- .../nucleusframework/readerdockdemo/Main.kt | 1 + .../readerdockdemo/ReaderState.kt | 7 + .../api/nucleus-application.api | 8 +- .../nucleusframework/application/Satellite.kt | 7 + .../internal/TaoSatelliteWorkspaceAdapter.kt | 3 + 17 files changed, 402 insertions(+), 23 deletions(-) create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockSidesTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 0dc001716..a2e977671 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ 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. `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. **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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. Headful coverage: `DockLayoutHeadfulCases` (robot splitter drags) + `DockLayoutMonkeyHeadfulCases` (profiles × seeds, `-Dnucleus.tao.headful.filter="dock layout"`). +- `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. `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. **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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. 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 diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index bd19c59cd..37b1f92dd 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -194,8 +194,8 @@ 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$-780840243$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-884347139$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; } public final class dev/nucleusframework/window/tao/ComposableSingletons$TabStripKt { @@ -504,6 +504,7 @@ 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; @@ -515,7 +516,7 @@ public final class dev/nucleusframework/window/tao/SatelliteEntry { 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;ZZLandroidx/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 satelliteDragHandle (Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/SatelliteScope;)Landroidx/compose/ui/Modifier; } 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 index c81e7dd53..cf822e134 100644 --- 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 @@ -52,7 +52,7 @@ internal class DockTransferTarget( override fun onDrop(event: DragAndDropEvent): Boolean { val drag = workspace.transferDrag ?: return false val position = event.positionInWindowPx() - val zone = zoneAt(position) + val zone = zoneAt(position)?.takeIf { it.side in drag.entry.dockSides } val outcome = when { zone != null && zone != drag.own -> TransferDrop.Dock(zone) @@ -89,7 +89,8 @@ internal class DockTransferTarget( private fun preview(event: DragAndDropEvent) { val drag = workspace.transferDrag ?: return - workspace.dockPreview = zoneAt(event.positionInWindowPx())?.takeIf { it != drag.own } + workspace.dockPreview = + zoneAt(event.positionInWindowPx())?.takeIf { it != drag.own && it.side in drag.entry.dockSides } } private fun clearPreview() { 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 index 705db1610..66e780192 100644 --- 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 @@ -169,7 +169,8 @@ private fun ZoneRect( * is alone there, 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. + * 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( @@ -186,7 +187,7 @@ internal fun hintedSides( it.dockHost === host && (it.placement as? SatellitePlacement.Docked)?.side == own } - return if (alone) DockSide.entries.filter { it != own } else DockSide.entries + return DockSide.entries.filter { it in dragged.dockSides && !(alone && it == own) } } /** The smallest rect containing both. */ 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 978a08d82..acaaa150e 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 @@ -142,6 +142,11 @@ 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 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]. @@ -164,6 +169,7 @@ public fun ApplicationScope.Satellite( title: String, initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), initiallyOpen: Boolean = true, + dockSides: Set = DockSide.entries.toSet(), resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, compositionLocalContext: CompositionLocalContext? = null, @@ -173,7 +179,7 @@ public fun ApplicationScope.Satellite( header: @Composable @UiComposable SatelliteScope.() -> Unit = { DefaultSatelliteHeader() }, content: @Composable @UiComposable SatelliteScope.() -> Unit, ) { - val entry = remember(workspace, id) { workspace.register(id, title, initialPlacement, initiallyOpen) } + val entry = remember(workspace, id) { workspace.register(id, title, initialPlacement, initiallyOpen, dockSides) } val scope = remember(entry) { SatelliteScopeImpl(workspace, entry, isDocked = false) } // Published as snapshot state so the DockLayout hosting the panel picks up // a new lambda without this composable knowing where the panel lives. @@ -445,7 +451,7 @@ public fun SatelliteScope.DefaultSatelliteHeader() { 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() } } } } 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 fce36e4de..f9714c4ac 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 @@ -72,7 +72,7 @@ private class FloatingDragSession( origin.move(topLeft.x.toWindowCoordinate(), topLeft.y.toWindowCoordinate()) // 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.dockTargetAt(Rect(topLeft, windowSizePx()), pointer) + workspace.dockPreview = workspace.dockTargetFor(entry, Rect(topLeft, windowSizePx()), pointer) } override fun end(pointerScreenPx: Offset) { @@ -112,7 +112,7 @@ private class DockedDragSession( // 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.dockTargetAt(ghost, pointer)?.takeIf { it != own } + 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. @@ -125,7 +125,7 @@ private class DockedDragSession( if (!isLive) return pointer = pointerScreenPx.sanitizedOrNull() ?: pointer val drop = pointer - val target = workspace.dockTargetAt(ghostRectPx(), drop)?.takeIf { it != own } + val target = workspace.dockTargetFor(entry, ghostRectPx(), drop)?.takeIf { it != own } cancel() when { target != null -> workspace.dropAt(entry.id, target) 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 537db0b88..32d23c6b9 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 @@ -44,6 +44,12 @@ 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(), ) { /** Human-readable title, shown by the default header. */ public var title: String by mutableStateOf(title) @@ -71,9 +77,18 @@ public class SatelliteEntry internal constructor( /** `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. */ + /** + * 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 /** @@ -374,6 +389,9 @@ public class SatelliteWorkspace( * 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. */ public fun dock( id: String, @@ -382,6 +400,7 @@ public class SatelliteWorkspace( host: TaoWindow? = null, ) { val entry = entryMap[id] ?: return + if (side !in entry.dockSides) return val current = entry.placement val extent = dockSeedExtent(entry, side) if (current is SatellitePlacement.Floating) entry.lastFloating = currentFloating(entry, current) @@ -555,6 +574,13 @@ public class SatelliteWorkspace( pointerScreenPx: Offset, ): DockTarget? = zoneOf { it.dockHitTest(draggedScreenRectPx, pointerScreenPx, DockZoneWidth) } + /** [dockTargetAt] for the satellite [entry]: a zone on a side it may not dock on is no target for it. */ + internal fun dockTargetFor( + entry: SatelliteEntry, + draggedScreenRectPx: Rect, + pointerScreenPx: Offset, + ): DockTarget? = dockTargetAt(draggedScreenRectPx, pointerScreenPx)?.takeIf { it.side in entry.dockSides } + private inline fun zoneOf(hitTest: (HostGeometry) -> DockHit?): DockTarget? { val hit = dockHosts @@ -752,12 +778,17 @@ public class SatelliteWorkspace( title: String, initialPlacement: SatellitePlacement, initiallyOpen: Boolean, + dockSides: Set = DockSide.entries.toSet(), ): 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" + } + val entry = SatelliteEntry(id, title, initialPlacement, initiallyOpen, dockSides) if (initialPlacement is SatellitePlacement.Docked) entry.dockHost = owner entryMap[id] = entry pendingRestore.remove(id)?.let { apply(entry, it) } @@ -786,6 +817,9 @@ public class SatelliteWorkspace( 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 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/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index a35f7f6a4..83b5e5dfe 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 @@ -759,6 +759,27 @@ 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("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`() } 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 472f995bb..e1dc689ca 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 @@ -96,6 +96,7 @@ class TaoSceneTestBatteryDriftTest { DockZoneHintSidesTest::class.java, DockDropSlotsTest::class.java, SatelliteDockRankTest::class.java, + SatelliteDockSidesTest::class.java, DockTargetFromDraggedRectTest::class.java, RelocatingSaveableStateRegistryTest::class.java, WindowGroupTest::class.java, 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 index 2191bacd2..f96e077a5 100644 --- 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 @@ -48,6 +48,7 @@ internal class DockPanelSpec( val id: String, val placement: SatellitePlacement, val open: Boolean = true, + val dockSides: Set = DockSide.entries.toSet(), ) /** @@ -192,6 +193,7 @@ internal class DockLayoutFixture( title = "Panel ${spec.id}", initialPlacement = spec.placement, initiallyOpen = spec.open, + dockSides = spec.dockSides, ) { PanelBody(spec.id) } } } 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 index e623bb89c..8ca705ea9 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -45,6 +46,9 @@ import kotlin.math.abs * 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; + * 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. @@ -71,8 +75,134 @@ internal object DockLayoutHeadfulCases { aPanelDockedAgainReturnsToTheRankItLeft(), aLayerDraggedOverTheOutermostOneBecomesTheFirst(), aSplitPanelDroppedOnItsStackTakesTheRankUnderThePointer(), + aPaletteIsNeverOfferedASideItWasNotDeclaredFor(), ) + // ── 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 ─────────────────────────── /** @@ -101,6 +231,9 @@ internal object DockLayoutHeadfulCases { 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 @@ -123,7 +256,7 @@ internal object DockLayoutHeadfulCases { ), ) // The outer half of the outermost layer: rank 0. - val target = toScreen(fixture, Offset(tree.left + tree.width * OUTER_HALF, layout.center.y)) + 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) { @@ -149,8 +282,8 @@ internal object DockLayoutHeadfulCases { val treeAfter = panel(fixture, TREE) val toc = panel(fixture, TOC) check( - near(notes.right, layout.right, LAYOUT_TOLERANCE_PX * 2), - ) { "the notes are not at the edge: $notes vs $layout" } + 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), 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 index d2f8164fa..67d2a98b8 100644 --- 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 @@ -129,6 +129,7 @@ fun main() = title = pane.title, initialPlacement = pane.home, initiallyOpen = pane.openAtStart, + dockSides = ReaderDockSides, header = { PaneHeader(reader.style) }, ) { Surface(Modifier.fillMaxSize(), color = colors.surface) { PaneContent(pane) } 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 index 958787403..1786901e6 100644 --- 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 @@ -15,6 +15,13 @@ enum class ReaderStyle { Islands, } +/** + * Where a pane may be docked: anywhere but the top. The reader's top is its + * activity bar 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) + /** One pane of the reader: a satellite with a home in the dock. */ enum class Pane( val id: String, diff --git a/nucleus-application/api/nucleus-application.api b/nucleus-application/api/nucleus-application.api index d938ab290..532a04363 100644 --- a/nucleus-application/api/nucleus-application.api +++ b/nucleus-application/api/nucleus-application.api @@ -6,8 +6,8 @@ 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$-1396241758$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-998471637$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; } public final class dev/nucleusframework/application/ComposableSingletons$TabKt { @@ -166,8 +166,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;ZZZLkotlin/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;ZZZLkotlin/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 } 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..70ed821ec 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,8 @@ 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 nativeContextMenu whether text fields in the floating window get the * native context menu, as for [SatelliteWindow]. */ @@ -58,6 +61,7 @@ public fun NucleusApplicationScope.Satellite( title: String, initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), initiallyOpen: Boolean = true, + dockSides: Set = DockSide.entries.toSet(), resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, nativeContextMenu: Boolean = true, @@ -73,6 +77,7 @@ public fun NucleusApplicationScope.Satellite( title = title, initialPlacement = initialPlacement, initiallyOpen = initiallyOpen, + dockSides = dockSides, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, nativeContextMenu = nativeContextMenu, @@ -95,6 +100,7 @@ public fun Satellite( title: String, initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), initiallyOpen: Boolean = true, + dockSides: Set = DockSide.entries.toSet(), resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, nativeContextMenu: Boolean = true, @@ -107,6 +113,7 @@ public fun Satellite( title = title, initialPlacement = initialPlacement, initiallyOpen = initiallyOpen, + dockSides = dockSides, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, nativeContextMenu = nativeContextMenu, 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..3f9640df8 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,6 +27,7 @@ internal object TaoSatelliteWorkspaceAdapter { title: String, initialPlacement: SatellitePlacement, initiallyOpen: Boolean, + dockSides: Set, resizable: Boolean, hideWhileOwnerFullscreenOrMaximized: Boolean, nativeContextMenu: Boolean, @@ -41,6 +43,7 @@ internal object TaoSatelliteWorkspaceAdapter { title = title, initialPlacement = initialPlacement, initiallyOpen = initiallyOpen, + dockSides = dockSides, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, compositionLocalContext = outerLocals, From 4c7df0293ab9c8b487f4636cb83a11994b9d94e8 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 7 Sep 2026 18:55:11 +0300 Subject: [PATCH 04/13] =?UTF-8?q?feat(tao):=20a=20fixed=20panel=20?= =?UTF-8?q?=E2=80=94=20declared=20docked,=20never=20torn=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dockSides` could keep a satellite off an edge but not keep it in the dock: every docked panel was one drag away from a window of its own, so an app had no way to say "this pane is furniture". - `Satellite(floatable = false)`: `undock()` refuses it, a `restore()` that floats it is ignored (its open state still applies), the docked drag publishes no tear-out ghost and a release clear of every zone leaves the panel where it was, and the default header drops its Float action. The declaration requires a docked `initialPlacement` — a fixed panel with nowhere to live is a mistake, not a runtime surprise. Everything inside the dock still works: hide, resize, and reorder among its neighbours. - `reader-dock-demo`: the book tree and the table of contents are the reader's furniture — `floatable = false` on the right side only. Covered by `SatelliteFixedPanelTest` (6 cases, in the GraalVM battery) and a real-window case where the same gesture that tears out the ordinary neighbour leaves the fixed panel in place, nothing rebuilt. --- CLAUDE.md | 2 +- .../api/decorated-window-tao.api | 7 +- .../nucleusframework/window/tao/Satellite.kt | 12 +- .../window/tao/SatelliteDragSessions.kt | 11 +- .../window/tao/SatelliteWorkspace.kt | 19 ++- .../window/tao/SatelliteFixedPanelTest.kt | 152 ++++++++++++++++++ .../window/tao/TaoSceneTestBattery.kt | 18 +++ .../tao/TaoSceneTestBatteryDriftTest.kt | 1 + .../window/tao/headful/DockLayoutFixture.kt | 2 + .../tao/headful/DockLayoutHeadfulCases.kt | 105 ++++++++++++ .../nucleusframework/readerdockdemo/Main.kt | 3 +- .../readerdockdemo/ReaderChrome.kt | 3 +- .../readerdockdemo/ReaderState.kt | 29 +++- .../api/nucleus-application.api | 8 +- .../nucleusframework/application/Satellite.kt | 7 + .../internal/TaoSatelliteWorkspaceAdapter.kt | 2 + 16 files changed, 361 insertions(+), 20 deletions(-) create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteFixedPanelTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index a2e977671..a37144809 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ 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. `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. **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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. Headful coverage: `DockLayoutHeadfulCases` (robot splitter drags) + `DockLayoutMonkeyHeadfulCases` (profiles × seeds, `-Dnucleus.tao.headful.filter="dock layout"`). +- `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. `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` (`reader-dock-demo`: the book tree and the contents are `floatable = false` + `dockSides = setOf(Right)`, still reorderable between themselves). **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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. 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 diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index 37b1f92dd..afb1b8b4a 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -194,8 +194,8 @@ 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$-780840243$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$-884347139$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-477659663$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$1238690337$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; } public final class dev/nucleusframework/window/tao/ComposableSingletons$TabStripKt { @@ -511,12 +511,13 @@ public final class dev/nucleusframework/window/tao/SatelliteEntry { 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 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;ZLjava/util/Set;ZZLandroidx/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;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 satelliteDragHandle (Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/SatelliteScope;)Landroidx/compose/ui/Modifier; } 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 acaaa150e..2b967173f 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 @@ -147,6 +147,10 @@ internal class SatelliteScopeImpl( * [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 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]. @@ -170,6 +174,7 @@ public fun ApplicationScope.Satellite( initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), initiallyOpen: Boolean = true, dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, compositionLocalContext: CompositionLocalContext? = null, @@ -179,7 +184,10 @@ public fun ApplicationScope.Satellite( header: @Composable @UiComposable SatelliteScope.() -> Unit = { DefaultSatelliteHeader() }, content: @Composable @UiComposable SatelliteScope.() -> Unit, ) { - val entry = remember(workspace, id) { workspace.register(id, title, initialPlacement, initiallyOpen, dockSides) } + val entry = + remember(workspace, id) { + workspace.register(id, title, initialPlacement, initiallyOpen, dockSides, floatable) + } val scope = remember(entry) { SatelliteScopeImpl(workspace, entry, isDocked = false) } // Published as snapshot state so the DockLayout hosting the panel picks up // a new lambda without this composable knowing where the panel lives. @@ -448,7 +456,7 @@ 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 { if (satellite.dockSides.isNotEmpty()) HeaderAction("Dock", colors.content) { dock() } 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 f9714c4ac..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 @@ -115,8 +115,11 @@ private class DockedDragSession( 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, ghost, 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) @@ -129,7 +132,9 @@ private class DockedDragSession( cancel() when { target != null -> workspace.dropAt(entry.id, target) - panelScreenRectPx.contains(drop) -> Unit + // 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)) } } 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 32d23c6b9..39e52b95b 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 @@ -50,6 +50,12 @@ public class SatelliteEntry internal constructor( * 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, ) { /** Human-readable title, shown by the default header. */ public var title: String by mutableStateOf(title) @@ -457,13 +463,15 @@ public class SatelliteWorkspace( * 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 val floating = placement ?: liftOffPlacement(entry) ?: entry.lastFloating @@ -779,6 +787,7 @@ public class SatelliteWorkspace( initialPlacement: SatellitePlacement, initiallyOpen: Boolean, dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, ): SatelliteEntry { entryMap[id]?.let { it.title = title @@ -788,7 +797,10 @@ public class SatelliteWorkspace( "satellite '$id' is declared docked on ${(initialPlacement as SatellitePlacement.Docked).side}, " + "a side its dockSides $dockSides do not allow" } - val entry = SatelliteEntry(id, title, initialPlacement, initiallyOpen, dockSides) + require(floatable || initialPlacement is SatellitePlacement.Docked) { + "satellite '$id' cannot float and is not declared docked: it would have nowhere to live" + } + val entry = SatelliteEntry(id, title, initialPlacement, initiallyOpen, dockSides, floatable) if (initialPlacement is SatellitePlacement.Docked) entry.dockHost = owner entryMap[id] = entry pendingRestore.remove(id)?.let { apply(entry, it) } @@ -812,6 +824,9 @@ public class SatelliteWorkspace( (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() 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..a90135508 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteFixedPanelTest.kt @@ -0,0 +1,152 @@ +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`): it never becomes a + * window of its own — [SatelliteWorkspace.undock] refuses it, a drag released + * over the content leaves it docked and shows no tear-out ghost, and a + * snapshot that floats it is ignored — while everything it *can* do inside + * the dock still works. + */ +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, + ) + 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) + } + } + + @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 fixed panel is still reordered on its own side`() { + 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)) + 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)) + assertEquals(DockTarget(a, DockSide.Left, 0), workspace.dockPreview) + session.end(Offset(250f, 200f)) + assertEquals(0, assertIs(toc.placement).order) + assertEquals(1, assertIs(tree.placement).order) + } +} 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 83b5e5dfe..9fb2d11ef 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 @@ -759,6 +759,24 @@ 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("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 fixed panel is still reordered on its own side") { + SatelliteFixedPanelTest().`a fixed panel is still reordered on its own side`() + } run("SatelliteDockSidesTest: dock refuses a side the satellite was not declared for") { SatelliteDockSidesTest().`dock refuses a side the satellite was not declared for`() } 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 e1dc689ca..9afa1e66e 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 @@ -97,6 +97,7 @@ class TaoSceneTestBatteryDriftTest { DockDropSlotsTest::class.java, SatelliteDockRankTest::class.java, SatelliteDockSidesTest::class.java, + SatelliteFixedPanelTest::class.java, DockTargetFromDraggedRectTest::class.java, RelocatingSaveableStateRegistryTest::class.java, WindowGroupTest::class.java, 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 index f96e077a5..15538a81f 100644 --- 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 @@ -49,6 +49,7 @@ internal class DockPanelSpec( val placement: SatellitePlacement, val open: Boolean = true, val dockSides: Set = DockSide.entries.toSet(), + val floatable: Boolean = true, ) /** @@ -194,6 +195,7 @@ internal class DockLayoutFixture( initialPlacement = spec.placement, initiallyOpen = spec.open, dockSides = spec.dockSides, + floatable = spec.floatable, ) { PanelBody(spec.id) } } } 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 index 8ca705ea9..75b6d45da 100644 --- 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 @@ -46,6 +46,8 @@ import kotlin.math.abs * 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; + * 15. a fixed panel is never torn out — no ghost, no window, nothing + * rebuilt — while its ordinary neighbour still is; * 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; @@ -76,8 +78,105 @@ internal object DockLayoutHeadfulCases { aLayerDraggedOverTheOutermostOneBecomesTheFirst(), aSplitPanelDroppedOnItsStackTakesTheRankUnderThePointer(), aPaletteIsNeverOfferedASideItWasNotDeclaredFor(), + aFixedPanelIsNeverTornOut(), ) + // ── 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, + ), + 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, 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) + val session = beginDockedDrag(workspace, TREE, grab) + 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" } + + // 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, (layout.right - client.x), LAYOUT_TOLERANCE_PX * 2)) { + "the fixed panel is not still at the edge: ${panel(fixture, TREE)}" + } + }, + ) + } + // ── 14. dockSides ──────────────────────────────────────────────────── /** @@ -1350,4 +1449,10 @@ internal object DockLayoutHeadfulCases { /** 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 + + /** How far inside a panel's leading edge a grab is taken. */ + private const val GRAB_EDGE_INSET_DP = 8f } 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 index 67d2a98b8..c9ac00190 100644 --- 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 @@ -129,7 +129,8 @@ fun main() = title = pane.title, initialPlacement = pane.home, initiallyOpen = pane.openAtStart, - dockSides = ReaderDockSides, + dockSides = if (pane.fixed) ReaderFixedDockSides else ReaderDockSides, + floatable = !pane.fixed, header = { PaneHeader(reader.style) }, ) { Surface(Modifier.fillMaxSize(), color = colors.surface) { PaneContent(pane) } 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 index 87955c1a0..50073be9b 100644 --- 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 @@ -79,7 +79,8 @@ fun SatelliteScope.PaneHeader(style: ReaderStyle) { verticalAlignment = Alignment.CenterVertically, ) { if (isDocked) { - HeaderAction(FLOAT_GLYPH) { undock() } + // A fixed pane has nowhere to float to. + if (satellite.isFloatable) HeaderAction(FLOAT_GLYPH) { undock() } } else { HeaderAction(DOCK_GLYPH) { dock() } } 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 index 1786901e6..613c6d46c 100644 --- 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 @@ -22,15 +22,38 @@ enum class ReaderStyle { */ val ReaderDockSides: Set = setOf(DockSide.Left, DockSide.Right, DockSide.Bottom) -/** One pane of the reader: a satellite with a home in the dock. */ +/** 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 and nowhere else: they cannot be torn into a + * window of their own, nor moved to another side. They can still be hidden, + * resized, and reordered between themselves. + */ 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), - Toc("toc", "תוכן", SatellitePlacement.Docked(DockSide.Right, order = 1, extent = 170.dp), openAtStart = true), + 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), diff --git a/nucleus-application/api/nucleus-application.api b/nucleus-application/api/nucleus-application.api index 532a04363..62253d66a 100644 --- a/nucleus-application/api/nucleus-application.api +++ b/nucleus-application/api/nucleus-application.api @@ -6,8 +6,8 @@ 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$-1396241758$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-998471637$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-747774978$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$687194247$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; } public final class dev/nucleusframework/application/ComposableSingletons$TabKt { @@ -166,8 +166,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;ZLjava/util/Set;ZZZLkotlin/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;ZZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)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;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;ZLjava/util/Set;ZZZZLkotlin/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 } 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 70ed821ec..f238456b9 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt @@ -49,6 +49,9 @@ import dev.nucleusframework.window.tao.SatelliteWorkspace * * @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 nativeContextMenu whether text fields in the floating window get the * native context menu, as for [SatelliteWindow]. */ @@ -62,6 +65,7 @@ public fun NucleusApplicationScope.Satellite( initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), initiallyOpen: Boolean = true, dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, nativeContextMenu: Boolean = true, @@ -78,6 +82,7 @@ public fun NucleusApplicationScope.Satellite( initialPlacement = initialPlacement, initiallyOpen = initiallyOpen, dockSides = dockSides, + floatable = floatable, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, nativeContextMenu = nativeContextMenu, @@ -101,6 +106,7 @@ public fun Satellite( initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), initiallyOpen: Boolean = true, dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, nativeContextMenu: Boolean = true, @@ -114,6 +120,7 @@ public fun Satellite( initialPlacement = initialPlacement, initiallyOpen = initiallyOpen, dockSides = dockSides, + floatable = floatable, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, nativeContextMenu = nativeContextMenu, 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 3f9640df8..86345386a 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 @@ -28,6 +28,7 @@ internal object TaoSatelliteWorkspaceAdapter { initialPlacement: SatellitePlacement, initiallyOpen: Boolean, dockSides: Set, + floatable: Boolean, resizable: Boolean, hideWhileOwnerFullscreenOrMaximized: Boolean, nativeContextMenu: Boolean, @@ -44,6 +45,7 @@ internal object TaoSatelliteWorkspaceAdapter { initialPlacement = initialPlacement, initiallyOpen = initiallyOpen, dockSides = dockSides, + floatable = floatable, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, compositionLocalContext = outerLocals, From 3276222d4a5338adeac87bd984267470617c732f Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 7 Sep 2026 19:15:30 +0300 Subject: [PATCH 05/13] feat(tao): pin a panel to its rank, and stop offering a drag that leads nowhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixed panel could still be reordered — and pushed down by a neighbour dropped in front of it — so the reader's book tree and table of contents would not stay in the order the app declared. - `Satellite(reorderable = false)` pins the rank: `dock()` ignores any order for it and gives it the rank it was declared with, another panel's insertion is pushed past the last pinned one so it can join them but never displace one, its own side is no longer hinted, and a target resolved for it carries no rank — so no preview promises a move that will not happen. Requires a docked `initialPlacement`. - The ranks in front of a pinned panel stay in the published slots as empty rects, so a slot's index is still the rank it stands for and a drop aimed at a pinned panel lands right behind it, where the bar is drawn. - `Modifier.satelliteDragHandle` is inert on a satellite a drag could not move anywhere — pinned, fixed to the side it is on, and alone in the workspace — instead of leaving a gesture that can only end where it began. - `reader-dock-demo`: the book tree and the contents are furniture now — no tear-out, no side change, no reorder, and nothing docks in front of them. Covered by four unit cases (in the GraalVM battery) plus the layered-side geometry, and the real-window case now checks that the neighbour docked at rank 0 lands behind the pinned panel and that the pinned one is offered nothing at all. --- CLAUDE.md | 2 +- .../api/decorated-window-tao.api | 7 +- .../nucleusframework/window/tao/DockLayout.kt | 21 +++++- .../window/tao/DockTransferTarget.kt | 6 +- .../window/tao/DockZoneHints.kt | 14 ++-- .../nucleusframework/window/tao/Satellite.kt | 33 ++++++-- .../window/tao/SatelliteWorkspace.kt | 70 +++++++++++++++-- .../window/tao/workspace/HostGeometry.kt | 8 +- .../window/tao/DockLandingRectTest.kt | 34 +++++++++ .../window/tao/SatelliteFixedPanelTest.kt | 75 +++++++++++++++++-- .../window/tao/TaoSceneTestBattery.kt | 16 +++- .../window/tao/headful/DockLayoutFixture.kt | 2 + .../tao/headful/DockLayoutHeadfulCases.kt | 43 ++++++++++- .../nucleusframework/readerdockdemo/Main.kt | 1 + .../readerdockdemo/ReaderState.kt | 7 +- .../api/nucleus-application.api | 8 +- .../nucleusframework/application/Satellite.kt | 7 ++ .../internal/TaoSatelliteWorkspaceAdapter.kt | 2 + 18 files changed, 304 insertions(+), 52 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a37144809..13febe2e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ 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. `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` (`reader-dock-demo`: the book tree and the contents are `floatable = false` + `dockSides = setOf(Right)`, still reorderable between themselves). **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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. Headful coverage: `DockLayoutHeadfulCases` (robot splitter drags) + `DockLayoutMonkeyHeadfulCases` (profiles × seeds, `-Dnucleus.tao.headful.filter="dock layout"`). +- `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. `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. `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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. 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 diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index afb1b8b4a..69f20c57b 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -194,8 +194,8 @@ 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$-477659663$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$1238690337$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-1865121467$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$467551509$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; } public final class dev/nucleusframework/window/tao/ComposableSingletons$TabStripKt { @@ -513,11 +513,12 @@ public final class dev/nucleusframework/window/tao/SatelliteEntry { 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;ZLjava/util/Set;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;Landroidx/compose/runtime/Composer;III)V public static final fun satelliteDragHandle (Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/SatelliteScope;)Landroidx/compose/ui/Modifier; } 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 19ee1b5a6..ee3fd7a9c 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 @@ -259,14 +259,19 @@ internal class DockLayoutState( * 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, or one has not been - * placed yet: nothing to order against. + * 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() @@ -296,7 +301,15 @@ internal class DockLayoutState( Rect(region.left, edges[index], region.right, edges[index + 1]) } } - return if (ranksDescend(side)) ascending.asReversed() else ascending + 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) } /** @@ -326,7 +339,7 @@ internal class DockLayoutState( fun far(rect: Rect): Float = if (alongX) (if (descending) rect.left else rect.right) else (if (descending) rect.top else rect.bottom) - val rank = order.coerceIn(0, rects.size) + val rank = order.coerceIn(workspace.pinnedFloor(panels), rects.size) val at = when (rank) { 0 -> near(rects.first()) 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 index cf822e134..8cfe02b6d 100644 --- 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 @@ -52,7 +52,7 @@ internal class DockTransferTarget( override fun onDrop(event: DragAndDropEvent): Boolean { val drag = workspace.transferDrag ?: return false val position = event.positionInWindowPx() - val zone = zoneAt(position)?.takeIf { it.side in drag.entry.dockSides } + val zone = zoneAt(position)?.let { workspace.targetFor(drag.entry, it) } val outcome = when { zone != null && zone != drag.own -> TransferDrop.Dock(zone) @@ -90,7 +90,9 @@ internal class DockTransferTarget( private fun preview(event: DragAndDropEvent) { val drag = workspace.transferDrag ?: return workspace.dockPreview = - zoneAt(event.positionInWindowPx())?.takeIf { it != drag.own && it.side in drag.entry.dockSides } + zoneAt(event.positionInWindowPx()) + ?.let { workspace.targetFor(drag.entry, it) } + ?.takeIf { it != drag.own } } private fun clearPreview() { 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 index 66e780192..d4ad3a779 100644 --- 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 @@ -165,10 +165,12 @@ private fun ZoneRect( /** * 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 it - * is alone there, 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. + * 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. @@ -187,7 +189,9 @@ internal fun hintedSides( it.dockHost === host && (it.placement as? SatellitePlacement.Docked)?.side == own } - return DockSide.entries.filter { it in dragged.dockSides && !(alone && it == 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. */ 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 2b967173f..d1259d6ed 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 @@ -151,6 +151,12 @@ internal class SatelliteScopeImpl( * 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]. @@ -175,6 +181,7 @@ public fun ApplicationScope.Satellite( initiallyOpen: Boolean = true, dockSides: Set = DockSide.entries.toSet(), floatable: Boolean = true, + reorderable: Boolean = true, resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, compositionLocalContext: CompositionLocalContext? = null, @@ -186,7 +193,7 @@ public fun ApplicationScope.Satellite( ) { val entry = remember(workspace, id) { - workspace.register(id, title, initialPlacement, initiallyOpen, dockSides, floatable) + workspace.register(id, title, initialPlacement, initiallyOpen, dockSides, floatable, reorderable) } val scope = remember(entry) { SatelliteScopeImpl(workspace, entry, isDocked = false) } // Published as snapshot state so the DockLayout hosting the panel picks up @@ -368,15 +375,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 = 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 39e52b95b..0ebbac3b4 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 @@ -56,6 +56,13 @@ public class SatelliteEntry internal constructor( * 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) @@ -397,7 +404,10 @@ public class SatelliteWorkspace( * 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. + * 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, @@ -419,7 +429,9 @@ public class SatelliteWorkspace( ?: entry.dockHost?.takeIf { it in members } ?: owner entry.placement = SatellitePlacement.Docked(side, order = 0, extent, weight) - insertInStack(entry, order ?: remembered?.order) + // 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 } @@ -455,6 +467,7 @@ public class SatelliteWorkspace( ): 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 }) } @@ -582,12 +595,42 @@ public class SatelliteWorkspace( pointerScreenPx: Offset, ): DockTarget? = zoneOf { it.dockHitTest(draggedScreenRectPx, pointerScreenPx, DockZoneWidth) } - /** [dockTargetAt] for the satellite [entry]: a zone on a side it may not dock on is no target for it. */ + /** + * 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)?.takeIf { it.side in entry.dockSides } + ): 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 = @@ -788,6 +831,7 @@ public class SatelliteWorkspace( initiallyOpen: Boolean, dockSides: Set = DockSide.entries.toSet(), floatable: Boolean = true, + reorderable: Boolean = true, ): SatelliteEntry { entryMap[id]?.let { it.title = title @@ -800,7 +844,11 @@ public class SatelliteWorkspace( require(floatable || initialPlacement is SatellitePlacement.Docked) { "satellite '$id' cannot float and is not declared docked: it would have nowhere to live" } - val entry = SatelliteEntry(id, title, initialPlacement, initiallyOpen, dockSides, floatable) + 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) } @@ -935,6 +983,12 @@ public class SatelliteWorkspace( /** * 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, @@ -942,10 +996,14 @@ public class SatelliteWorkspace( ) { val docked = entry.placement as SatellitePlacement.Docked val stack = stackOf(docked.side, entry.dockHost, exclude = entry).toMutableList() - stack.add(index?.coerceIn(0, stack.size) ?: stack.size, entry) + 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 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 96ad689c4..54a8d6b7a 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 @@ -98,9 +98,13 @@ internal data class DockDropZone( /** * 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. + * `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.minByOrNull { distanceSquaredPx(slots[it], point) } + fun slotAt(point: Offset): Int? = + slots.indices + .filter { !slots[it].isEmpty } + .minByOrNull { distanceSquaredPx(slots[it], point) } private fun distanceSquaredPx( rect: Rect, 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 index 88cd7fefc..a5cb8b9a4 100644 --- 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 @@ -265,6 +265,40 @@ class DockDropSlotsTest { 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") + assertEquals(Rect(98f, 0f, 102f, 600f), state.insertionBarPx(DockSide.Left, movable, 0, 4f)) + // The pinned layer itself is offered no rank at all. + assertEquals(emptyList(), state.dropSlotsPx(DockSide.Left, strip, dragged = pinned)) + } + @Test fun `the insertion bar sits on the edge between the two ranks`() { // Layered right: rank 1 is between the tree (900..1000) and the toc (800..900). 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 index a90135508..4c2b470ba 100644 --- 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 @@ -14,11 +14,12 @@ import kotlin.test.assertIs import kotlin.test.assertNull /** - * A fixed panel ([SatelliteEntry.isFloatable] `false`): it never becomes a + * 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, and a - * snapshot that floats it is ignored — while everything it *can* do inside - * the dock still works. + * 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) @@ -57,6 +58,7 @@ class SatelliteFixedPanelTest { initiallyOpen = true, dockSides = setOf(DockSide.Left), floatable = false, + reorderable = false, ) entry.content = {} entry.dockedBoundsInWindowPx = boundsInWindowPx @@ -82,6 +84,9 @@ class SatelliteFixedPanelTest { assertFailsWith { workspace.register("tree", "Tree", floating, initiallyOpen = true, floatable = false) } + assertFailsWith { + workspace.register("toc", "Toc", floating, initiallyOpen = true, reorderable = false) + } } @Test @@ -129,10 +134,13 @@ class SatelliteFixedPanelTest { } @Test - fun `a fixed panel is still reordered on its own side`() { + 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 @@ -144,9 +152,60 @@ class SatelliteFixedPanelTest { val session = requireNotNull(workspace.beginDrag("toc", panelOrigin, Offset(200f, 550f))) session.update(Offset(250f, 200f)) - assertEquals(DockTarget(a, DockSide.Left, 0), workspace.dockPreview) + assertNull(workspace.dockPreview, "a pinned panel takes no rank, so its own side is no target") session.end(Offset(250f, 200f)) - assertEquals(0, assertIs(toc.placement).order) - assertEquals(1, assertIs(tree.placement).order) + 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/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index 9fb2d11ef..4bbde3265 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 @@ -725,6 +725,9 @@ public object TaoSceneTestBattery { 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: the insertion bar sits on the edge between the two ranks") { DockDropSlotsTest().`the insertion bar sits on the edge between the two ranks`() } @@ -774,8 +777,17 @@ public object TaoSceneTestBattery { 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 fixed panel is still reordered on its own side") { - SatelliteFixedPanelTest().`a fixed panel is still reordered on its own side`() + 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`() 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 index 15538a81f..62727a988 100644 --- 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 @@ -50,6 +50,7 @@ internal class DockPanelSpec( val open: Boolean = true, val dockSides: Set = DockSide.entries.toSet(), val floatable: Boolean = true, + val reorderable: Boolean = true, ) /** @@ -196,6 +197,7 @@ internal class DockLayoutFixture( initiallyOpen = spec.open, dockSides = spec.dockSides, floatable = spec.floatable, + reorderable = spec.reorderable, ) { PanelBody(spec.id) } } } 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 index 75b6d45da..921c2f46c 100644 --- 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 @@ -47,7 +47,8 @@ import kotlin.math.abs * 12. a layer dragged by its header over the outer half of the outermost * layer previews the first rank and lands there, nothing rebuilt; * 15. a fixed panel is never torn out — no ghost, no window, nothing - * rebuilt — while its ordinary neighbour still is; + * 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; @@ -99,13 +100,14 @@ internal object DockLayoutHeadfulCases { 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, its neighbour still is", + 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), @@ -131,7 +133,15 @@ internal object DockLayoutHeadfulCases { // 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) - val session = beginDockedDrag(workspace, TREE, grab) + // 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}" } @@ -149,6 +159,25 @@ internal object DockLayoutHeadfulCases { 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 @@ -170,7 +199,7 @@ internal object DockLayoutHeadfulCases { } tocSession.end(middle) awaitUntil("the toc floats") { fixture.floatingWindows.value[TOC]?.hasRealFramePx() == true } - check(near(panel(fixture, TREE).right, (layout.right - client.x), LAYOUT_TOLERANCE_PX * 2)) { + 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)}" } }, @@ -1420,6 +1449,12 @@ internal object DockLayoutHeadfulCases { ): 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, 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 index c9ac00190..9ccd13dd1 100644 --- 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 @@ -131,6 +131,7 @@ fun main() = initiallyOpen = pane.openAtStart, dockSides = if (pane.fixed) ReaderFixedDockSides else ReaderDockSides, floatable = !pane.fixed, + reorderable = !pane.fixed, header = { PaneHeader(reader.style) }, ) { Surface(Modifier.fillMaxSize(), color = colors.surface) { PaneContent(pane) } 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 index 613c6d46c..d081a481f 100644 --- 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 @@ -29,9 +29,10 @@ 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 and nowhere else: they cannot be torn into a - * window of their own, nor moved to another side. They can still be hidden, - * resized, and reordered between themselves. + * 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, diff --git a/nucleus-application/api/nucleus-application.api b/nucleus-application/api/nucleus-application.api index 62253d66a..25fc268f1 100644 --- a/nucleus-application/api/nucleus-application.api +++ b/nucleus-application/api/nucleus-application.api @@ -6,8 +6,8 @@ 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$-747774978$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$687194247$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1162796259$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$457937242$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; } public final class dev/nucleusframework/application/ComposableSingletons$TabKt { @@ -166,8 +166,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;ZLjava/util/Set;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;ZLjava/util/Set;ZZZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)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;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;Landroidx/compose/runtime/Composer;III)V public static final fun pinTo (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ldev/nucleusframework/application/NucleusWindow;)V } 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 f238456b9..edded3105 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt @@ -52,6 +52,9 @@ import dev.nucleusframework.window.tao.SatelliteWorkspace * @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 nativeContextMenu whether text fields in the floating window get the * native context menu, as for [SatelliteWindow]. */ @@ -66,6 +69,7 @@ public fun NucleusApplicationScope.Satellite( initiallyOpen: Boolean = true, dockSides: Set = DockSide.entries.toSet(), floatable: Boolean = true, + reorderable: Boolean = true, resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, nativeContextMenu: Boolean = true, @@ -83,6 +87,7 @@ public fun NucleusApplicationScope.Satellite( initiallyOpen = initiallyOpen, dockSides = dockSides, floatable = floatable, + reorderable = reorderable, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, nativeContextMenu = nativeContextMenu, @@ -107,6 +112,7 @@ public fun Satellite( initiallyOpen: Boolean = true, dockSides: Set = DockSide.entries.toSet(), floatable: Boolean = true, + reorderable: Boolean = true, resizable: Boolean = true, hideWhileOwnerFullscreenOrMaximized: Boolean = true, nativeContextMenu: Boolean = true, @@ -121,6 +127,7 @@ public fun Satellite( initiallyOpen = initiallyOpen, dockSides = dockSides, floatable = floatable, + reorderable = reorderable, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, nativeContextMenu = nativeContextMenu, 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 86345386a..1d27b624b 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 @@ -29,6 +29,7 @@ internal object TaoSatelliteWorkspaceAdapter { initiallyOpen: Boolean, dockSides: Set, floatable: Boolean, + reorderable: Boolean, resizable: Boolean, hideWhileOwnerFullscreenOrMaximized: Boolean, nativeContextMenu: Boolean, @@ -46,6 +47,7 @@ internal object TaoSatelliteWorkspaceAdapter { initiallyOpen = initiallyOpen, dockSides = dockSides, floatable = floatable, + reorderable = reorderable, resizable = resizable, hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, compositionLocalContext = outerLocals, From be9662fbccfb18e17626304e8a2b3f4edb671164 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 7 Sep 2026 19:34:06 +0300 Subject: [PATCH 06/13] feat(tao): let an app tell "move the window" from "move the satellite" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where the compositor places the window, moving it and moving the satellite are two different gestures and cannot share one area. Nucleus already split the title bar for that, but nothing about the split was public: the capability was internal, the reserved strip was a private constant, and a custom header had no way to adapt the way the stock one does. - `TaoWindow.canPlaceOnScreen` is the public capability (the internal `supportsScreenPlacement` is gone). Branch on it rather than on `isNativeWaylandSurface`: it is the question — can the app place this window — not the platform that answers it. - `SatelliteScope.isCompositorPlaced` gives custom chrome the same answer for the window it is composed in; the floating scope reads the satellite's own window, a docked panel reads its host. - `Satellite(floatingCaption = …)` fills the strip the title bar leaves to the compositor's move, `SatelliteCaptionStripWidth` wide, composed only where one is reserved — so an app never guesses that width nor accidentally claims the only area that can move the palette. - `SatelliteWorkspace.dragKind` (`Window` / `Transfer`) says how the drag in flight is carried, which is what tells preview code whether `dragGhost` will ever be published. - `reader-dock-demo` draws a move glyph in that strip. Covered by three unit cases in the GraalVM battery plus both halves of the contract on real windows: the X11 case asserts nothing is reserved and a drag is window-carried, the native-Wayland one asserts the strip is reserved at the published width, the panel and the palette are both told, and the drag is transfer-carried with no ghost. --- CLAUDE.md | 2 +- .../api/decorated-window-tao.api | 19 +++- .../nucleusframework/window/tao/DockLayout.kt | 4 +- .../nucleusframework/window/tao/Satellite.kt | 81 +++++++++++++--- .../window/tao/SatelliteWindow.kt | 5 +- .../window/tao/SatelliteWorkspace.kt | 42 ++++++++- .../window/tao/TabWorkspace.kt | 3 +- .../nucleusframework/window/tao/TaoWindow.kt | 22 +++++ .../window/tao/workspace/CrossWindowDrag.kt | 4 +- .../window/tao/workspace/HostGeometry.kt | 4 +- .../window/tao/workspace/ScreenPlacement.kt | 21 +---- .../window/tao/workspace/TransferDrag.kt | 2 +- .../window/tao/SatelliteDragKindTest.kt | 89 ++++++++++++++++++ .../window/tao/TaoSceneTestBattery.kt | 9 ++ .../tao/TaoSceneTestBatteryDriftTest.kt | 1 + .../window/tao/headful/DockLayoutFixture.kt | 25 +++++ .../tao/headful/DockLayoutHeadfulCases.kt | 94 +++++++++++++++++++ .../headful/WaylandWorkspaceHeadfulCases.kt | 84 +++++++++++++++++ .../nucleusframework/readerdockdemo/Main.kt | 3 + .../readerdockdemo/ReaderChrome.kt | 20 ++++ .../api/nucleus-application.api | 10 +- .../nucleusframework/application/Satellite.kt | 7 ++ .../internal/TaoSatelliteWorkspaceAdapter.kt | 2 + 23 files changed, 502 insertions(+), 51 deletions(-) create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDragKindTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 13febe2e6..be71cf14a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ 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. `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. `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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. Headful coverage: `DockLayoutHeadfulCases` (robot splitter drags) + `DockLayoutMonkeyHeadfulCases` (profiles × seeds, `-Dnucleus.tao.headful.filter="dock layout"`). +- `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`). `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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. 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 diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index 69f20c57b..54a0958ba 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -194,8 +194,9 @@ 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$-1865121467$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$467551509$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + 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$TabStripKt { @@ -480,6 +481,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 { } @@ -518,7 +527,8 @@ public final class dev/nucleusframework/window/tao/SatelliteEntry { 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;ZLjava/util/Set;ZZZZLandroidx/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; } @@ -587,6 +597,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 } @@ -651,6 +662,7 @@ public final class dev/nucleusframework/window/tao/SatelliteWorkspace { 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; @@ -1240,6 +1252,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 ee3fd7a9c..cdb8785bc 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 @@ -654,7 +654,9 @@ private fun DockPanel( ) { if (entry.content == null) return val workspace = state.workspace - val scope = remember(workspace, entry) { SatelliteScopeImpl(workspace, entry, isDocked = true) } + // 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 val containerSize = state.containerSize 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 d1259d6ed..c7bcc9503 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 @@ -13,7 +13,6 @@ 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 @@ -45,6 +44,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 @@ -55,7 +55,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 @@ -80,6 +79,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) @@ -100,7 +119,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 @@ -168,6 +196,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") @@ -189,13 +225,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, dockSides, floatable, reorderable) } - val scope = remember(entry) { SatelliteScopeImpl(workspace, entry, isDocked = false) } + // 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 { @@ -245,6 +285,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 @@ -252,7 +296,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( @@ -287,8 +332,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) } } } } @@ -408,6 +457,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 @@ -425,13 +486,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 { @@ -520,9 +582,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 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 0ebbac3b4..0a19272e4 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 @@ -25,7 +25,6 @@ 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 @@ -552,6 +551,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) @@ -667,7 +685,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 } @@ -1036,6 +1054,26 @@ public class SatelliteWorkspace( } } +/** + * 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 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..345a9320d 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 @@ -18,7 +18,6 @@ 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 /** @@ -512,7 +511,7 @@ public class TabWorkspace( when (origin) { is TabDragOrigin.Strip -> origin.window } - if (!from.supportsScreenPlacement) { + if (!from.canPlaceOnScreen) { from.warnScreenPlacementUnsupported("TabWorkspace.beginDrag") return null } 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..67390c6e7 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,7 +123,7 @@ 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) 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 54a8d6b7a..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 @@ -50,11 +50,11 @@ internal class HostGeometry( /** * 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) } 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..18a62d745 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 @@ -52,7 +52,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 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/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index 4bbde3265..25a6c2b51 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 @@ -762,6 +762,15 @@ 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("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`() } 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 9afa1e66e..d461e937f 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 @@ -97,6 +97,7 @@ class TaoSceneTestBatteryDriftTest { DockDropSlotsTest::class.java, SatelliteDockRankTest::class.java, SatelliteDockSidesTest::class.java, + SatelliteDragKindTest::class.java, SatelliteFixedPanelTest::class.java, DockTargetFromDraggedRectTest::class.java, RelocatingSaveableStateRegistryTest::class.java, 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 index 62727a988..651661e6d 100644 --- 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 @@ -89,6 +89,13 @@ internal class DockLayoutFixture( 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()) @@ -195,6 +202,18 @@ internal class DockLayoutFixture( 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, @@ -214,7 +233,13 @@ internal class DockLayoutFixture( 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) } 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 index 921c2f46c..aef6ebb87 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -46,6 +47,8 @@ import kotlin.math.abs * 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; @@ -80,8 +83,96 @@ internal object DockLayoutHeadfulCases { 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 ──────────────────────────────────────────────── /** @@ -1488,6 +1579,9 @@ internal object DockLayoutHeadfulCases { /** 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/WaylandWorkspaceHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceHeadfulCases.kt index aa609b08c..b8a78808c 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 @@ -6,6 +6,8 @@ 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 @@ -33,6 +35,10 @@ import kotlin.math.abs * 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; + * 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. @@ -50,8 +56,86 @@ internal object WaylandWorkspaceHeadfulCases { everyZoneResolvesFromAWindowCoordinate(), tabTransferDragTearsOffAndMergesBack(), aTransferDropResolvesARankAndReorders(), + chromeIsToldTheCompositorPlacesTheWindow(), ) + /** + * 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( 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 index 9ccd13dd1..08df9c579 100644 --- 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 @@ -133,6 +133,9 @@ fun main() = 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(pane) } } 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 index 50073be9b..60fe9fafd 100644 --- 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 @@ -92,6 +92,24 @@ fun SatelliteScope.PaneHeader(style: ReaderStyle) { } } +/** + * 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, @@ -175,10 +193,12 @@ 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 diff --git a/nucleus-application/api/nucleus-application.api b/nucleus-application/api/nucleus-application.api index 25fc268f1..1b886daec 100644 --- a/nucleus-application/api/nucleus-application.api +++ b/nucleus-application/api/nucleus-application.api @@ -6,8 +6,10 @@ 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$1162796259$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$457937242$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 { @@ -166,8 +168,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;ZLjava/util/Set;ZZZZZLkotlin/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;Landroidx/compose/runtime/Composer;III)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 } 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 edded3105..c051e4d28 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt @@ -55,6 +55,9 @@ import dev.nucleusframework.window.tao.SatelliteWorkspace * @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]. */ @@ -74,6 +77,7 @@ public fun NucleusApplicationScope.Satellite( hideWhileOwnerFullscreenOrMaximized: Boolean = true, nativeContextMenu: Boolean = true, header: @Composable @UiComposable SatelliteScope.() -> Unit = { DefaultSatelliteHeader() }, + floatingCaption: @Composable @UiComposable SatelliteScope.() -> Unit = {}, content: @Composable @UiComposable SatelliteScope.() -> Unit, ) { when (this) { @@ -92,6 +96,7 @@ public fun NucleusApplicationScope.Satellite( hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, nativeContextMenu = nativeContextMenu, header = header, + floatingCaption = floatingCaption, content = content, ) } @@ -117,6 +122,7 @@ public fun Satellite( 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( @@ -132,6 +138,7 @@ public fun Satellite( hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, nativeContextMenu = nativeContextMenu, header = header, + floatingCaption = floatingCaption, content = content, ) } 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 1d27b624b..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 @@ -34,6 +34,7 @@ internal object TaoSatelliteWorkspaceAdapter { hideWhileOwnerFullscreenOrMaximized: Boolean, nativeContextMenu: Boolean, header: @Composable SatelliteScope.() -> Unit, + floatingCaption: @Composable SatelliteScope.() -> Unit, content: @Composable SatelliteScope.() -> Unit, ) { val outerLocals = currentCompositionLocalContext @@ -55,6 +56,7 @@ internal object TaoSatelliteWorkspaceAdapter { NucleusSatelliteScene(outerLocals, parentLayoutDirection, nativeContextMenu) { inner() } }, header = header, + floatingCaption = floatingCaption, content = content, ) } From 1b6de9e90da0bb1c7c88f6d986f25314fa6a814b Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 7 Sep 2026 20:01:01 +0300 Subject: [PATCH 07/13] feat(examples): the reader's seforim are tabs, and its dock belongs to the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader had one window and one book. Composing it with the tab archetype needed a slot the tab windows did not have: `windowWrapper` wraps the whole window *including* its strip, so chrome hung there receives the strip inside its own content — the reader's tab strip landed in the middle of the dock and its panes climbed over the title bar. - `TabWindows(windowBodyWrapper = …)`, in tao and in `nucleus-application`: composed inside the window, below the strip, around the selected tab's body. Window-level chrome goes there — a `DockLayout` and its satellites, an activity bar — and it is one call site for every window, so a tab change neither rebuilds it nor moves the body's relocation keys. - `reader-dock-demo` is now both archetypes at once: each sefer is a tab, the strip is the top of the window, and everything under it is the reader. The dock belongs to the window, so a tab change only changes what the panes draw; a tab torn out arrives with a dock of its own, with its own widths and its own chapter. The books pane selects a tab, the contents pane drives the text, and what the reader remembers per book outlives every window. Covered by a real-window case: the chrome is under the strip, at the height it asked for, built exactly once per window, and neither a selection change nor a tear-off rebuilds it. Driven by hand on the demo too — tabs, tear-off, and the per-window panes. --- CLAUDE.md | 4 +- .../api/decorated-window-tao.api | 7 +- .../nucleusframework/window/tao/TabWindows.kt | 19 +- .../window/tao/headful/TabWorkspaceFixture.kt | 40 +++ .../tao/headful/TabWorkspaceHeadfulCases.kt | 67 +++- .../nucleusframework/readerdockdemo/Main.kt | 315 +++++++++++++----- .../readerdockdemo/ReaderState.kt | 173 ++++++++-- .../readerdockdemo/ReaderTabStrip.kt | 54 +++ .../api/nucleus-application.api | 14 +- .../dev/nucleusframework/application/Tab.kt | 9 + .../internal/TaoTabWorkspaceAdapter.kt | 9 + 11 files changed, 586 insertions(+), 125 deletions(-) create mode 100644 examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderTabStrip.kt diff --git a/CLAUDE.md b/CLAUDE.md index be71cf14a..1d08fa494 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` (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`). `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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. Headful coverage: `DockLayoutHeadfulCases` (robot splitter drags) + `DockLayoutMonkeyHeadfulCases` (profiles × seeds, `-Dnucleus.tao.headful.filter="dock layout"`). +- `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`). `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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. 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), `reader-dock-demo` (a right-to-left book reader whose 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 — 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. +- `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 54a0958ba..cb81e465a 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -208,8 +208,9 @@ public final class dev/nucleusframework/window/tao/ComposableSingletons$TabStrip 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 { @@ -824,7 +825,7 @@ 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 { 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..5c78e3891 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 @@ -187,7 +197,7 @@ 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 +211,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 +254,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) } } } } 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..f49569743 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,6 +4,8 @@ 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.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -19,6 +21,8 @@ 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.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition @@ -62,6 +66,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. * @@ -188,6 +198,33 @@ internal class TabWorkspaceFixture( lastWindowClosed.value = true lastWindowClosedCount.value++ }, + // 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) @@ -410,3 +447,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..437bb577a 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,10 @@ 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 edge cases — abrupt pointer jumps, a backing-scale change, minimize, * maximize, interrupted gestures — live in [TabWorkspaceStressHeadfulCases]. @@ -31,8 +34,70 @@ internal object TabWorkspaceHeadfulCases { stateSurvivesMovesAndReordersDoNotRebuild(), snapshotRestoresWindows(), closingTheSelectedTabPicksANeighbour(), + theWindowBodyWrapperHoldsTheWindowsOwnChrome(), ) + /** + * Chrome that belongs to the window rather than to a tab: the strip stays + * the top of the window, the app's `windowBodyWrapper` sits under it with + * the tab body inside, and it is built once per window — a selection + * change and a tear-off leave it standing, while a second window gets its + * own. + * + * That is what lets an app hang a whole `DockLayout` there, as + * `examples/reader-dock-demo` does. + */ + 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 * a real mouse, push it back into the other window's strip, then close 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 index 08df9c579..f474732e6 100644 --- 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 @@ -27,11 +27,14 @@ 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.mutableIntStateOf +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable 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 @@ -39,20 +42,17 @@ 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.DpSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.rememberWindowState -import dev.nucleusframework.application.DecoratedWindow 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.WindowScaffold -import dev.nucleusframework.window.material.MaterialTitleBar import dev.nucleusframework.window.material.rememberMaterialTitleBarStyle import dev.nucleusframework.window.material.rememberMaterialWindowStyle import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle @@ -60,6 +60,8 @@ 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( @@ -82,7 +84,8 @@ private val LightColors = ) /** - * A right-to-left book reader built entirely from satellites. + * 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 @@ -90,10 +93,21 @@ private val LightColors = * 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 32 dp hover strips; the - * *Islands* style turns every pane into a rounded card. And because every pane - * is a satellite, each can be torn out into a window of its own and dropped - * back — that is the only thing the split panes could not do. + * 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 { @@ -101,62 +115,133 @@ fun main() = val dark = isSystemInDarkMode() val colors = if (dark) DarkColors else LightColors - DecoratedWindow( - onCloseRequest = ::exitApplication, - title = "Reader", - state = rememberWindowState(width = WINDOW_W_DP.dp, height = WINDOW_H_DP.dp), - minimumSize = DpSize(MIN_W_DP.dp, MIN_H_DP.dp), - ) { - JoinSatelliteWorkspace(reader.workspace) - ReaderTheme(colors) { - WindowBackground(colors.background) - WindowAppearance(if (dark) WindowAppearanceMode.Dark else WindowAppearanceMode.Light) - WindowScaffold(titleBar = { MaterialTitleBar { Text("Reader") } }) { padding -> - Surface(Modifier.fillMaxSize().padding(padding), color = colors.background) { - ReaderBody(reader) + ReaderTheme(colors) { + TabWindows( + workspace = reader.tabs, + strip = { ReaderTabStrip(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) } + } } + } - // Every pane, declared once at application scope; the workspace decides - // whether it is a panel of the dock or a window of its own. - ReaderTheme(colors) { - for (pane in Pane.entries) { - Satellite( - workspace = reader.workspace, - id = pane.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(pane) } +/** + * 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) } } } } +} -/** The reader: its two activity bars around the dock layout, all right-to-left. */ +/** + * 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) { +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(pane)) { reader.toggle(pane) } + BarButton(pane.title.take(1), selected = reader.isOpen(group.id, pane)) { + reader.toggle(group.id, pane) + } } } VerticalDivider() DockLayout( - workspace = reader.workspace, + 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 @@ -167,30 +252,40 @@ private fun ReaderBody(reader: ReaderState) { splitter = { ReaderSplitter(reader.style) }, panel = { body -> PaneCard(reader.style) { body() } }, ) { - PaneCard(reader.style) { TextColumn() } + PaneCard(reader.style) { text() } } VerticalDivider() - // End bar: the content panes and the style switch. + // 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(pane)) { reader.toggle(pane) } + 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() } - BarButton("R", selected = reader.savedLayout != null) { reader.restoreLayout() } - BarButton("⟲", selected = false) { reader.resetLayout() } + 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) } } } } } -/** The main text: the document, with a breadcrumb strip under it. */ +/** + * 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 TextColumn() { +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( @@ -201,7 +296,7 @@ private fun TextColumn() { .padding(TEXT_PADDING_DP.dp), verticalArrangement = Arrangement.spacedBy(TEXT_GAP_DP.dp), ) { - Text("בראשית", fontSize = TITLE_SP.sp, fontWeight = FontWeight.Bold) + 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)}", @@ -217,7 +312,7 @@ private fun TextColumn() { verticalAlignment = Alignment.CenterVertically, ) { Text( - "תנ״ך › תורה › בראשית › פרק א", + "תנ״ך › ${book.title} › ${book.chapters[chapter]}", fontSize = BREADCRUMB_SP.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -225,33 +320,89 @@ private fun TextColumn() { } } -/** A pane's body: a list the user can scroll, whose position survives dock and undock. */ +/** + * 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(pane: Pane) { +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() - var selected by rememberSaveable { mutableIntStateOf(-1) } Column( Modifier.fillMaxSize().verticalScroll(scroll).padding(PANE_PADDING_DP.dp), verticalArrangement = Arrangement.spacedBy(ITEM_GAP_DP.dp), ) { - repeat(ITEMS) { index -> - val chosen = selected == index - Text( - text = "${pane.title} ${index + 1}", - fontSize = TEXT_SP.sp, - color = if (chosen) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(ITEM_CORNER_DP.dp)) - .background(if (chosen) MaterialTheme.colorScheme.surfaceContainerHigh else Color.Transparent) - .clickable { selected = index } - .padding(ITEM_PADDING_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( @@ -289,9 +440,11 @@ private fun BarButton( } /** - * Material colours plus the window-chrome styles derived from them, per - * window scene — and once more around the satellites, whose floating windows - * get it through the bridged locals. + * 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( @@ -307,10 +460,6 @@ private fun ReaderTheme( } } -private const val WINDOW_W_DP = 1280 -private const val WINDOW_H_DP = 820 -private const val MIN_W_DP = 640 -private const val MIN_H_DP = 420 private const val BAR_W_DP = 48 private const val BAR_GAP_DP = 8 private const val BAR_BUTTON_DP = 36 @@ -327,5 +476,5 @@ 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 = 60 +private const val ITEMS = 40 private const val SAMPLE_TEXT = "בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ. " 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 index d081a481f..609820c1b 100644 --- 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 @@ -1,13 +1,18 @@ 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 { @@ -17,8 +22,8 @@ enum class ReaderStyle { /** * Where a pane may be docked: anywhere but the top. The reader's top is its - * activity bar and the text's own header; a pane dragged there is refused, - * and the top strip never lights up. + * 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) @@ -59,53 +64,165 @@ enum class Pane( 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 + } } /** - * What the demo drives: the workspace every pane is declared against, the - * visual style, and the saved layout. + * Everything the demo drives: the seforim as tabs, and one dock of panes per + * reader window. * - * Everything the bars do is a workspace call — toggle a pane, save or restore - * the layout. The layout of the reader itself (which side is layered, which - * side owns the corners) is declared once in `Main.kt`; the workspace holds - * only what the user changed. + * [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 { - val workspace = SatelliteWorkspace() + val tabs = TabWorkspace(defaultWindowSize = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp)) + + /** 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) - var savedLayout: SatelliteLayoutSnapshot? by mutableStateOf(null) - private set + 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) + } - fun isOpen(pane: Pane): Boolean = workspace.satellite(pane.id)?.isOpen == true + /** The book [id] names, or `null` once its tab has been closed. */ + fun book(id: String): Book? = books.firstOrNull { it.id == id } - /** Shows or hides a pane. Commentaries and sources share the bottom, so one closes the other. */ - fun toggle(pane: Pane) { - val opening = !isOpen(pane) + /** 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.id) - Pane.Sources -> if (opening) workspace.close(Pane.Comments.id) + 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.id) + workspace.toggle(pane.idIn(groupId)) } - fun saveLayout() { - savedLayout = workspace.snapshot() + fun savedLayout(groupId: String): SatelliteLayoutSnapshot? = savedLayouts[groupId] + + fun saveLayout(groupId: String) { + savedLayouts[groupId] = panesOfWindow(groupId).snapshot() } - fun restoreLayout() { - savedLayout?.let(workspace::restore) + fun restoreLayout(groupId: String) { + savedLayouts[groupId]?.let(panesOfWindow(groupId)::restore) } - /** Every pane back where it started, at its starting width. */ - fun resetLayout() { + /** 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) { - workspace.dock(pane.id, pane.home.side, order = pane.home.order) - pane.home.extent?.let { workspace.setDockedExtent(pane.id, it) } - workspace.setDockedWeight(pane.id, pane.home.weight) - if (pane.openAtStart) workspace.open(pane.id) else workspace.close(pane.id) + 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..57b8e112c --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderTabStrip.kt @@ -0,0 +1,54 @@ +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.Text +import androidx.compose.runtime.Composable +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.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. + * + * 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. + */ +@Composable +fun TabStripScope.ReaderTabStrip(onNewBook: () -> Unit) { + TabStrip(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 diff --git a/nucleus-application/api/nucleus-application.api b/nucleus-application/api/nucleus-application.api index 1b886daec..d24ecb73d 100644 --- a/nucleus-application/api/nucleus-application.api +++ b/nucleus-application/api/nucleus-application.api @@ -15,10 +15,12 @@ public final class dev/nucleusframework/application/ComposableSingletons$Satelli 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 { @@ -185,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/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/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, ) } From 66c9504ff3d1a0ef019465e20c666f3fb038b445 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Tue, 8 Sep 2026 01:08:40 +0300 Subject: [PATCH 08/13] feat(tao): a tab strip that reorders in hand, and a drag that survives Wayland MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip jumped: a reorder swapped tabs in place, a drop was a thin bar, and on a compositor-placed surface the whole gesture was the platform's drag-and-drop session — no motion at all. This ports the state machine of `sh.calvin.reorderable`'s `ReorderableRow`, which is what SeforimApp uses. - **In hand**: tabs are `key`ed on their id, the dragged one is drawn at the pointer's travel since the grab, a neighbour steps a whole tab aside when that tab's *edge* crosses its *centre* (spring), and the release slides it into the slot it was over *before* the order changes — `pendingReorder` + `TabStripMotion.settle`, so nothing is ever seen jumping. Offsets are draw-time layer translations: the slots a drop resolves against never move. - **Two paths, by capability**: where the app places its windows the gesture stays `beginDrag` (ghost, screen hit-test) and the strip animates from the pointer it publishes. Where it cannot, the grip reorders *locally* — window px only — and hands the gesture to the platform's DnD session the moment the pointer leaves the strip (`TransferDragGesture`), which is the only way another window can be told where the pointer is and preview the drop. - `DragGhostWindow(popupFor = …)`: the preview is a popup overlay of the window it came from, so it follows the pointer out of a window the client cannot place. The tab slot carries `noWindowDrag()`, or the title bar's compositor move swallows the gesture. - Tabs open and close by width, the close button plays the tab out before the workspace drops it, and `insertionIndex` is direction-aware — a right-to-left strip used to resolve every drop mirrored. 90 real-window tab cases on X11 and 29 on native Wayland, including the numbers behind the motion, the RTL strip, the in-strip carry and the hand-over. --- CLAUDE.md | 2 +- .../api/decorated-window-tao.api | 13 +- .../window/tao/TabDragSessions.kt | 72 +++- .../nucleusframework/window/tao/TabStrip.kt | 153 ++++---- .../window/tao/TabStripAnimation.kt | 339 ++++++++++++++++ .../window/tao/TabStripDrag.kt | 259 ++++++++++++ .../nucleusframework/window/tao/TabWindows.kt | 10 +- .../window/tao/TabWorkspace.kt | 243 +++++++++++- .../window/tao/workspace/CrossWindowDrag.kt | 2 +- .../window/tao/workspace/DragGhostWindow.kt | 11 +- .../window/tao/workspace/TransferDrag.kt | 74 +++- .../window/tao/TabWorkspaceTest.kt | 26 ++ .../window/tao/TaoSceneTestBattery.kt | 3 + .../tao/headful/TabStripMotionHeadfulCases.kt | 369 ++++++++++++++++++ .../window/tao/headful/TabWorkspaceFixture.kt | 40 ++ .../tao/headful/TabWorkspaceHeadfulCases.kt | 37 +- .../headful/TabWorkspaceMotionHeadfulCases.kt | 32 +- .../headful/TabWorkspaceStressHeadfulCases.kt | 14 +- .../tao/headful/TaoHeadfulTestSuiteMain.kt | 1 + .../headful/WaylandWorkspaceHeadfulCases.kt | 91 +++++ .../nucleusframework/readerdockdemo/Main.kt | 9 +- 21 files changed, 1673 insertions(+), 127 deletions(-) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripAnimation.kt create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripDrag.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabStripMotionHeadfulCases.kt diff --git a/CLAUDE.md b/CLAUDE.md index 1d08fa494..97c2cca57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ 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` (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`). `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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. Headful coverage: `DockLayoutHeadfulCases` (robot splitter drags) + `DockLayoutMonkeyHeadfulCases` (profiles × seeds, `-Dnucleus.tao.headful.filter="dock layout"`). +- `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), 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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. 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 diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index cb81e465a..9fa40dd81 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -202,7 +202,7 @@ public final class dev/nucleusframework/window/tao/ComposableSingletons$Satellit 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$-2032640526$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; } public final class dev/nucleusframework/window/tao/ComposableSingletons$TabWindowsKt { @@ -796,9 +796,16 @@ 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 TabStrip (Ldev/nucleusframework/window/tao/TabStripScope;Landroidx/compose/ui/Modifier;Landroidx/compose/animation/core/AnimationSpec;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V 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; } 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..d55453b17 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 @@ -108,6 +108,7 @@ private class TabWindowDragSession( // 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) + workspace.dragPointerScreenPx = pointer } override fun end(pointerScreenPx: Offset) { @@ -137,14 +138,22 @@ 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) + val target = workspace.dropTargetAt(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, Rect(pointer - grabOffsetPx, tabSizePx), scaleFactor) } override fun end(pointerScreenPx: Offset) { @@ -152,9 +161,19 @@ private class TabTearOffDragSession( pointer = pointerScreenPx.sanitizedOrNull() ?: pointer val drop = pointer val target = workspace.dropTargetAt(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 +182,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/TabStrip.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStrip.kt index 09670928a..dca6111cd 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,5 +1,7 @@ package dev.nucleusframework.window.tao +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -18,6 +20,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 +32,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 +46,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 +80,16 @@ 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. + * + * @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 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,35 +97,45 @@ internal class TabStripScopeImpl( @Composable public fun TabStripScope.TabStrip( modifier: Modifier = Modifier, + reorderAnimation: AnimationSpec? = TabReorderAnimation, 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 of this strip is in this strip's hands — no ghost was published + // for it — or is still sliding home after being let go: the tabs + // themselves show where it lands, and the indicator would say it twice. + val carried = + (dragged != null && dragged.group === group && preview != null && workspace.dragGhost == null) || + motion.animating != null + 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 gap a tab coming from *another* window would take. + if (!carried && preview?.index == index) DropIndicator() + // 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() + if (!carried && preview != null && preview.index >= entries.size) DropIndicator() trailing() } } @@ -221,74 +242,44 @@ public fun Modifier.tabSlot( 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() - } - /** 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 +287,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 +307,7 @@ private fun TabItem( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - TabCloseButton(colors.content) { scope.workspace.close(tab.id) } + TabCloseButton(colors.content, onClose) } } @@ -385,8 +375,19 @@ 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 + +/** 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 DROP_INDICATOR_ALPHA = 0.8f private const val GHOST_FILL_ALPHA = 0.22f private const val GHOST_BORDER_ALPHA = 0.55f 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..0ed0fb48f --- /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. */ +private val TabEnterAnimation: FiniteAnimationSpec = + tween(durationMillis = TAB_ENTER_MILLIS, easing = FastOutSlowInEasing) + +/** How a tab closes: its width shuts, taking the strip with it. */ +private 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 5c78e3891..d2f8c5e58 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 @@ -164,7 +164,6 @@ public fun ApplicationScope.TabWindows( TabGhostCard(ghost.tab.title) } } - val currentOnLastClosed = rememberUpdatedState(onLastWindowClosed) // The groups to compose, mirrored out of the workspace by an effect rather @@ -197,7 +196,14 @@ public fun ApplicationScope.TabWindows( for (group in groups) { key(group.id) { - TabWindow(workspace, group, compositionLocalContext, strip, windowContentWrapper, windowBodyWrapper) + TabWindow( + workspace, + group, + compositionLocalContext, + strip, + windowContentWrapper, + windowBodyWrapper, + ) } } } 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 345a9320d..4e5961650 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,7 @@ 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.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -19,6 +20,7 @@ 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.warnScreenPlacementUnsupported +import kotlinx.coroutines.CoroutineScope /** * One tab known to a [TabWorkspace]: its identity, title and body. @@ -45,6 +47,13 @@ public class TabEntry internal constructor( 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() } @@ -398,10 +407,134 @@ 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 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) + draggedTab = 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. @@ -468,24 +601,106 @@ public class TabWorkspace( 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)) + 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) + } + TabDropTarget(group, index) }.firstOrNull() + /** + * 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 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 @@ -519,6 +734,8 @@ public class TabWorkspace( val session = createTabDragSession(entry, origin, start) ?: return null drags.begin(session) draggedTab = entry + dragGrabScreenPx = start + dragPointerScreenPx = start return session } @@ -684,7 +901,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, 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 67390c6e7..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 @@ -127,7 +127,7 @@ internal fun Modifier.screenDragHandle( 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/TransferDrag.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/TransferDrag.kt index 18a62d745..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 @@ -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/TabWorkspaceTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabWorkspaceTest.kt index a910e78cc..08f3d75a3 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() 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 25a6c2b51..0f6c5fd6c 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 @@ -1010,6 +1010,9 @@ public object TaoSceneTestBattery { TransferDragTest().`the hotspot follows the grab point into the reduced picture of a region`() } + 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`() } 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 f49569743..18d63f57c 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height 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 @@ -23,7 +24,9 @@ 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 @@ -31,6 +34,7 @@ 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.TabStrip import dev.nucleusframework.window.tao.TabWindowGroup import dev.nucleusframework.window.tao.TabWindows import dev.nucleusframework.window.tao.TabWorkspace @@ -55,6 +59,8 @@ 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, ) { val workspace = TabWorkspace(defaultWindowSize = windowSize) @@ -198,6 +204,9 @@ internal class TabWorkspaceFixture( lastWindowClosed.value = true lastWindowClosedCount.value++ }, + strip = { + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { TabStrip() } + }, // 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". @@ -354,6 +363,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, 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 437bb577a..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 @@ -21,6 +21,9 @@ import kotlin.math.abs * 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]. * @@ -37,16 +40,6 @@ internal object TabWorkspaceHeadfulCases { theWindowBodyWrapperHoldsTheWindowsOwnChrome(), ) - /** - * Chrome that belongs to the window rather than to a tab: the strip stays - * the top of the window, the app's `windowBodyWrapper` sits under it with - * the tab body inside, and it is built once per window — a selection - * change and a tear-off leave it standing, while a second window gets its - * own. - * - * That is what lets an app hang a whole `DockLayout` there, as - * `examples/reader-dock-demo` does. - */ private fun theWindowBodyWrapperHoldsTheWindowsOwnChrome(): TaoWindowTestCase { val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) return TaoWindowTestCase( @@ -435,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/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 49ab76469..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 @@ -389,6 +389,7 @@ public object TaoHeadfulTestSuiteMain { 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 b8a78808c..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 @@ -35,6 +35,9 @@ import kotlin.math.abs * 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; + * 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 @@ -57,8 +60,87 @@ internal object WaylandWorkspaceHeadfulCases { 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 @@ -462,4 +544,13 @@ internal object WaylandWorkspaceHeadfulCases { /** 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/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 index f474732e6..a30bf1a67 100644 --- 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 @@ -118,7 +118,14 @@ fun main() = ReaderTheme(colors) { TabWindows( workspace = reader.tabs, - strip = { ReaderTabStrip(onNewBook = reader::openBook) }, + // 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(onNewBook = reader::openBook) + } + }, windowWrapper = { content -> WindowBackground(colors.background) WindowAppearance(if (dark) WindowAppearanceMode.Dark else WindowAppearanceMode.Light) From d51980f5c30322bec0bbd0f570886b08206eb550 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Tue, 8 Sep 2026 01:33:38 +0300 Subject: [PATCH 09/13] =?UTF-8?q?feat(tao):=20one=20drop=20preview=20every?= =?UTF-8?q?where=20=E2=80=94=20the=20card,=20drawn=20on=20the=20space=20it?= =?UTF-8?q?=20will=20fill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drag used to answer with four different pictures: a translucent card following the pointer, a solid rectangle on an empty dock side, a 4 dp bar between two panels of a stack, a 3 dp line between two tabs of another window's strip, and dashed strips for the sides merely on offer. Now there is one: the card the panel or tab travels under is also drawn on the very space the release fills, and the neighbours make room for it. - `DragPreviewDefaults.kt`: the shared surface (fill, border, corner) behind `SatelliteGhostCard` and `TabGhostCard`, at `hint` intensity for the sides merely on offer — solid and faint, no dashes. - Dock: `DockLayoutState.dropRectPx(side, dragged, order, extentPx)` replaces `insertionBarPx` and is the space for every case — the edge strip of an empty side, the layer at that rank of a layered side, the share the re-divided weights give it in a split stack, dividers counted. `dock()` and the preview share the weight too (`dockSeedWeight`). - Tabs: the strip another window's tab is carried over opens a slot of that tab's width (`TabStripScope.dropGhost` → `TabDropGhost`, drawn by `TabDropGhostCard`, sized from the source slot by `draggedTabWidth`), and the slot is dropped from the composition the frame the tab lands in it, so the card is seen becoming the tab rather than shutting beside it. Custom strips draw `dropGhost` themselves; `jewel-tabs-demo` inserts a placeholder `TabData.Editor`. --- CLAUDE.md | 2 +- .../api/decorated-window-tao.api | 30 +++ .../nucleusframework/window/tao/DockLayout.kt | 139 +++++++++----- .../window/tao/DockZoneHints.kt | 144 ++++---------- .../window/tao/DragPreviewDefaults.kt | 57 ++++++ .../nucleusframework/window/tao/Satellite.kt | 26 +-- .../window/tao/SatelliteWorkspace.kt | 16 +- .../nucleusframework/window/tao/TabStrip.kt | 175 +++++++++++++----- .../window/tao/TabStripAnimation.kt | 4 +- .../nucleusframework/window/tao/TabWindows.kt | 2 +- .../window/tao/TabWorkspace.kt | 14 ++ .../window/tao/DockLandingRectTest.kt | 60 ++++-- .../window/tao/TaoSceneTestBattery.kt | 10 +- .../jeweltabsdemo/JewelTabStrip.kt | 84 +++++---- 14 files changed, 492 insertions(+), 271 deletions(-) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DragPreviewDefaults.kt diff --git a/CLAUDE.md b/CLAUDE.md index 97c2cca57..2d4131ec0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ 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` (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), 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`), drawn as an insertion bar (`insertionBarPx`), 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`), so what lights up is what the release produces. Headful coverage: `DockLayoutHeadfulCases` (robot splitter drags) + `DockLayoutMonkeyHeadfulCases` (profiles × seeds, `-Dnucleus.tao.headful.filter="dock layout"`). +- `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), 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 diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index 9fa40dd81..8250d3966 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -185,6 +185,18 @@ public final class dev/nucleusframework/window/tao/ComposableSingletons$DockLayo 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 @@ -730,6 +742,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 @@ -805,7 +833,9 @@ public final class dev/nucleusframework/window/tao/TabStripDragKt { } 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;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; } 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 cdb8785bc..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 @@ -82,13 +82,15 @@ 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. Over - * a side that already has panels, the pointer's place along the stack picks - * the rank the drop takes — a bar between the two panels it would land - * 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. + * 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 @@ -213,15 +215,7 @@ internal class DockLayoutState( dragged: SatelliteEntry? = null, ): Rect { val origin = layoutBoundsInWindowPx.topLeft - val layout = layoutBoundsInWindowPx.translate(-origin) - val leaving = dragged?.takeIf { it.isDocked && it !in panelsOn(side) && panelsOn(sideOf(it)).size == 1 } - val band = - (bandBoundsInWindowPx[side] ?: layoutBoundsInWindowPx) - .translate(-origin) - .let { measured -> - val freed = leaving?.dockedBoundsInWindowPx?.translate(-origin) ?: return@let measured - unionOf(measured, freed).intersect(layout) - } + val band = bandPx(side, dragged) val stack = panelsOn(side) .mapNotNull { it.dockedBoundsInWindowPx } @@ -313,45 +307,94 @@ internal class DockLayoutState( } /** - * The boundary a panel dropped at rank [order] on [side] slides into, as a - * bar of [thicknessPx] across the stack: between the panels of ranks - * `order - 1` and `order` — in the middle of the splitter that separates - * them — or along the stack's first or last edge. The [dragged] panel is - * not counted, as in [dropSlotsPx]. `null` while the side has no other - * panel, or one has not been placed yet. + * 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. */ - fun insertionBarPx( + private fun bandPx( side: DockSide, dragged: SatelliteEntry?, - order: Int, - thicknessPx: Float, - ): Rect? { + ): Rect { val origin = layoutBoundsInWindowPx.topLeft - val panels = panelsOn(side).filter { it !== dragged } - if (panels.isEmpty()) return null - val rects = panels.map { (it.dockedBoundsInWindowPx ?: return null).translate(-origin) } - val alongX = side.isVertical == isLayered(side) - val descending = ranksDescend(side) - - // A panel's edge facing the lower ranks, and the one facing the higher. - fun near(rect: Rect): Float = - if (alongX) (if (descending) rect.right else rect.left) else (if (descending) rect.bottom else rect.top) - - fun far(rect: Rect): Float = - if (alongX) (if (descending) rect.left else rect.right) else (if (descending) rect.top else rect.bottom) - val rank = order.coerceIn(workspace.pinnedFloor(panels), rects.size) - val at = - when (rank) { - 0 -> near(rects.first()) - rects.size -> far(rects.last()) - else -> (far(rects[rank - 1]) + near(rects[rank])) / 2f + 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) + } + + /** + * 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 across = rects.reduce(::unionOf) - val half = thicknessPx / 2f + 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(at - half, across.top, at + half, across.bottom) + Rect(stack.left + start, stack.top, stack.left + start + share, stack.bottom) } else { - Rect(across.left, at - half, across.right, at + half) + Rect(stack.left, stack.top + start, stack.right, stack.top + start + share) } } 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 index d4ad3a779..782a44141 100644 --- 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 @@ -1,31 +1,17 @@ package dev.nucleusframework.window.tao -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect -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.platform.LocalDensity -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import dev.nucleusframework.window.styling.LocalTitleBarStyle import dev.nucleusframework.window.tao.workspace.DockDropZone import kotlin.math.roundToInt @@ -33,20 +19,18 @@ 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 as soon as the drag starts — that is what tells the - * user the gesture exists — and the one the satellite has entered fills in - * solid. Both are drawn where the panel would actually land - * ([DockLayoutState.landingRectPx]): along the side's own band rather than the - * whole edge, inside the layers already docked there, at the width the drop - * will produce once it is the active one. - * - * A side with panels on it is also cut into ranks ([DockLayoutState.dropSlotsPx]), - * one region per place the panel can take among them, and the active rank is - * drawn as a bar on the edge it would slide into — except a new innermost - * layer, drawn as the column it becomes. 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. + * 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( @@ -55,7 +39,6 @@ internal fun BoxScope.DockZoneHints( state: DockLayoutState, ) { val dragged = workspace.draggedSatellite ?: return - val accent = LocalTitleBarStyle.current.colors.content val density = LocalDensity.current val hinted = hintedSides(dragged, host, workspace.satellites) val zoneWidthPx = with(density) { SatelliteWorkspace.DockZoneWidth.toPx() } @@ -87,14 +70,13 @@ internal fun BoxScope.DockZoneHints( .pointerHoverIcon(TaoPointerIcons.Grabbing, overrideDescendants = true), ) for (side in hinted) { - SideHint(workspace, state, host, side, zones.getValue(side), dragged, own, accent) + SideHint(workspace, state, host, side, zones.getValue(side), dragged, own) } } /** - * One side's feedback: the active rank as a bar between the two panels it - * lands between — or, for a new innermost layer and for an empty side, the - * rect the panel will occupy — else the idle strip. + * 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 @@ -106,61 +88,46 @@ private fun SideHint( zone: DockDropZone, dragged: SatelliteEntry, own: DockTarget?, - accent: Color, ) { - val density = LocalDensity.current 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 - val order = preview?.order?.takeIf { active && zone.slots.isNotEmpty() } - when { - // Between two panels of the stack — a new innermost layer is drawn as - // the column it becomes, like a drop on an empty side. - order != null && !(state.isLayered(side) && order == zone.slots.lastIndex) -> { - val bar = state.insertionBarPx(side, dragged, order, with(density) { InsertionBarThickness.toPx() }) - if (bar != null) ZoneRect(bar, accent.copy(alpha = INSERTION_BAR_ALPHA), outline = null) - } - active -> { - // 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 rect = state.landingRectPx(side, with(density) { extent.toPx() }, joinsStack = true, dragged = dragged) - ZoneRect(rect, accent.copy(alpha = ZONE_ACTIVE_ALPHA), outline = accent, dashed = false) - } - else -> { - ZoneRect( - zone.strip, - accent.copy(alpha = ZONE_HINT_ALPHA), - outline = accent.copy(alpha = ZONE_OUTLINE_ALPHA), - ) - } + 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 ZoneRect( +private fun PreviewAt( rect: Rect, - fill: Color, - outline: Color?, - dashed: Boolean = true, + 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() }) - .background(fill) - .then(if (outline != null) Modifier.dashedOutline(outline, dashed) else Modifier), - ) + .size(with(density) { rect.width.toDp() }, with(density) { rect.height.toDp() }), + ) { + content() + } } /** @@ -199,36 +166,3 @@ 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)) - -/** 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 - }, - ), - ) - } - -private val ZoneOutlineWidth: Dp = 1.5.dp -private val InsertionBarThickness: Dp = 4.dp -private const val INSERTION_BAR_ALPHA = 0.9f -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 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 c7bcc9503..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,7 +9,6 @@ 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 @@ -256,7 +255,7 @@ public fun ApplicationScope.Satellite( title = ghost.satellite.title, compositionLocalContext = compositionLocalContext, ) { - SatelliteGhostCard(ghost.satellite.title) + SatelliteGhostCard(ghost.satellite.title, Modifier.fillMaxSize()) } } @@ -353,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, @@ -597,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/SatelliteWorkspace.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspace.kt index 0a19272e4..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 @@ -287,6 +287,20 @@ public class SatelliteWorkspace( .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( side: DockSide, @@ -418,10 +432,10 @@ public class SatelliteWorkspace( if (side !in entry.dockSides) return val current = entry.placement 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] - val weight = (current as? SatellitePlacement.Docked)?.weight ?: remembered?.weight ?: 1f if (side !in extents) setDockExtent(side, extent) entry.dockHost = host?.takeIf { it in members } 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 dca6111cd..8eb825efb 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,17 +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 @@ -84,7 +85,10 @@ internal class TabStripScopeImpl( * 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. + * 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: @@ -101,15 +105,18 @@ public fun TabStripScope.TabStrip( 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 of this strip is in this strip's hands — no ghost was published - // for it — or is still sliding home after being let go: the tabs - // themselves show where it lands, and the indicator would say it twice. - val carried = - (dragged != null && dragged.group === group && preview != null && workspace.dragGhost == null) || - motion.animating != null + // 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), @@ -117,8 +124,8 @@ public fun TabStripScope.TabStrip( horizontalArrangement = Arrangement.Start, ) { entries.forEachIndexed { index, entry -> - // The gap a tab coming from *another* window would take. - if (!carried && preview?.index == index) DropIndicator() + // 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 @@ -135,11 +142,111 @@ public fun TabStripScope.TabStrip( ) } } - if (!carried && preview != null && preview.index >= entries.size) DropIndicator() + key(landing.generation) { TabDropGhostSlot(ghost, entries.size) } trailing() } } +/** + * 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) } + } +} + /** * Publishes this element as [group]'s tab strip: the drop target a tab dragged * from any window of [workspace] can be released on. @@ -326,35 +433,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), @@ -369,9 +460,6 @@ 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 @@ -381,9 +469,6 @@ 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 DROP_INDICATOR_ALPHA = 0.8f -private const val GHOST_FILL_ALPHA = 0.22f -private const val GHOST_BORDER_ALPHA = 0.55f private const val TAB_TITLE_SP = 12 private const val TAB_CLOSE_SP = 14 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 index 0ed0fb48f..0e5c8d239 100644 --- 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 @@ -45,11 +45,11 @@ import kotlinx.coroutines.launch public val TabReorderAnimation: AnimationSpec = spring(stiffness = Spring.StiffnessMediumLow) /** How a tab opens: its width grows into the strip. */ -private val TabEnterAnimation: FiniteAnimationSpec = +internal val TabEnterAnimation: FiniteAnimationSpec = tween(durationMillis = TAB_ENTER_MILLIS, easing = FastOutSlowInEasing) /** How a tab closes: its width shuts, taking the strip with it. */ -private val TabExitAnimation: FiniteAnimationSpec = +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. */ 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 d2f8c5e58..130e9b033 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 @@ -161,7 +161,7 @@ public fun ApplicationScope.TabWindows( title = ghost.tab.title, compositionLocalContext = compositionLocalContext, ) { - TabGhostCard(ghost.tab.title) + TabGhostCard(ghost.tab.title, Modifier.fillMaxSize()) } } val currentOnLastClosed = rememberUpdatedState(onLastWindowClosed) 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 4e5961650..36c97bb14 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 @@ -10,6 +10,7 @@ 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.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -675,6 +676,19 @@ public class TabWorkspace( 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 the pointer has passed, counting the dragged tab's own 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 index a5cb8b9a4..077136ae3 100644 --- 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 @@ -7,9 +7,11 @@ 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 @@ -176,8 +178,8 @@ class DockZoneHintSidesTest { /** * The ranks a drop can take among the panels of a side - * ([DockLayoutState.dropSlotsPx]) and the bar drawn for one - * ([DockLayoutState.insertionBarPx]), on the reader layout of + * ([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 { @@ -294,25 +296,51 @@ class DockDropSlotsTest { ) 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") - assertEquals(Rect(98f, 0f, 102f, 600f), state.insertionBarPx(DockSide.Left, movable, 0, 4f)) + // 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 `the insertion bar sits on the edge between the two ranks`() { - // Layered right: rank 1 is between the tree (900..1000) and the toc (800..900). - assertEquals(Rect(898f, 0f, 902f, 600f), state.insertionBarPx(DockSide.Right, null, 1, 4f)) - assertEquals(Rect(998f, 0f, 1002f, 600f), state.insertionBarPx(DockSide.Right, null, 0, 4f), "the side's edge") - assertEquals( - Rect(798f, 0f, 802f, 600f), - state.insertionBarPx(DockSide.Right, null, 2, 4f), - "past the innermost", - ) - // Split bottom, the sources dragged: only the comments remain. - assertEquals(Rect(348f, 540f, 352f, 600f), state.insertionBarPx(DockSide.Bottom, sources, 1, 4f)) - assertEquals(Rect(-2f, 540f, 2f, 600f), state.insertionBarPx(DockSide.Bottom, sources, 0, 4f)) - assertNull(state.insertionBarPx(DockSide.Left, null, 0, 4f)) + 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)) } } 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 0f6c5fd6c..b0f59b58b 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 @@ -728,8 +728,14 @@ public object TaoSceneTestBattery { 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: the insertion bar sits on the edge between the two ranks") { - DockDropSlotsTest().`the insertion bar sits on the edge between the two ranks`() + 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`() 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..c98f9527d 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 @@ -12,7 +12,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.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 @@ -52,45 +56,19 @@ import org.jetbrains.jewel.ui.theme.editorTabStyle @Composable fun TabStripScope.JewelEditorTabStrip(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), ) @@ -98,6 +76,46 @@ fun TabStripScope.JewelEditorTabStrip(onNewTab: () -> Unit) { } } +/** 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) { From b37c9a3a7bfacd9aa24343987bd02080bfdbad8f Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Tue, 8 Sep 2026 01:37:41 +0300 Subject: [PATCH 10/13] feat(tao): carry a torn-out tab by its top edge, so the ghost never hides the slot it aims at Wherever the tab was grabbed, the ghost hangs below the pointer: the slot it is aimed at in another window's strip sits where the pointer is, and a card carried by a lower grab point covered it. The torn-off window inherits the same offset and lands where the ghost was. Screen-placement path only; the Wayland drag icon is the compositor's. --- .../dev/nucleusframework/window/tao/TabDragSessions.kt | 9 +++++++-- .../dev/nucleusframework/window/tao/TabWorkspaceTest.kt | 8 +++++--- 2 files changed, 12 insertions(+), 5 deletions(-) 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 d55453b17..1501deb74 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 @@ -46,7 +46,12 @@ internal fun TabWorkspace.createTabDragSession( workspace = this, entry = entry, windowSizePx = tearOffSizePx(strip.window, outer, scale), - grabOffsetPx = pointerScreenPx - (client + slot.topLeft), + // Carried by its top edge wherever it was grabbed: the ghost hangs + // below the pointer, so the slot it is aimed at in another strip — + // which is where the pointer is — stays in view instead of being + // covered by the card. The torn-off window inherits the offset and + // lands where the ghost was. Only the grab's x is kept. + grabOffsetPx = Offset(pointerScreenPx.x - (client.x + slot.left), 0f), tabSizePx = slot.size, pointer = pointerScreenPx, scaleFactor = scale, @@ -130,7 +135,7 @@ private class TabTearOffDragSession( private val entry: TabEntry, /** The source window's outer size, which the torn-off window inherits. */ private val windowSizePx: Size, - /** Pointer offset from the dragged tab's top-left at the grab. */ + /** Pointer offset from the dragged tab's left edge at the grab; `0` along y, the tab is carried by its top. */ private val grabOffsetPx: Offset, private val tabSizePx: Size, /** Where the pointer was last seen; a rejected sample leaves it alone. */ 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 08f3d75a3..262ca1eed 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 @@ -498,9 +498,11 @@ class TabWorkspaceTest { assertEquals(listOf("a"), left.ids) val torn = assertNotNull(workspace.groups.firstOrNull { it.ids == listOf("b") }) - // Grabbed 10 px right and 20 px down inside the tab, so the window's - // top-left lands that far up and left of the drop. - assertEquals(DpOffset(490.dp, 380.dp), torn.position) + // Grabbed 10 px right and 20 px down inside the tab: the window's + // top-left lands 10 px left of the drop, and level with it — the tab + // is carried by its top edge wherever it was grabbed, so the ghost + // never covers the slot the pointer aims at. + assertEquals(DpOffset(490.dp, 400.dp), torn.position) assertEquals(DpSize(800.dp, 600.dp), torn.size, "the new window inherits the size of the old one") assertNull(workspace.dragGhost) } From 2aeccdfb09340ef5c4aeaaf0906218ffcc699ded Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Tue, 8 Sep 2026 01:40:26 +0300 Subject: [PATCH 11/13] Revert "feat(tao): carry a torn-out tab by its top edge, so the ghost never hides the slot it aims at" This reverts commit b37c9a3a7bfacd9aa24343987bd02080bfdbad8f. --- .../dev/nucleusframework/window/tao/TabDragSessions.kt | 9 ++------- .../dev/nucleusframework/window/tao/TabWorkspaceTest.kt | 8 +++----- 2 files changed, 5 insertions(+), 12 deletions(-) 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 1501deb74..d55453b17 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 @@ -46,12 +46,7 @@ internal fun TabWorkspace.createTabDragSession( workspace = this, entry = entry, windowSizePx = tearOffSizePx(strip.window, outer, scale), - // Carried by its top edge wherever it was grabbed: the ghost hangs - // below the pointer, so the slot it is aimed at in another strip — - // which is where the pointer is — stays in view instead of being - // covered by the card. The torn-off window inherits the offset and - // lands where the ghost was. Only the grab's x is kept. - grabOffsetPx = Offset(pointerScreenPx.x - (client.x + slot.left), 0f), + grabOffsetPx = pointerScreenPx - (client + slot.topLeft), tabSizePx = slot.size, pointer = pointerScreenPx, scaleFactor = scale, @@ -135,7 +130,7 @@ private class TabTearOffDragSession( private val entry: TabEntry, /** The source window's outer size, which the torn-off window inherits. */ private val windowSizePx: Size, - /** Pointer offset from the dragged tab's left edge at the grab; `0` along y, the tab is carried by its top. */ + /** Pointer offset from the dragged tab's top-left at the grab. */ private val grabOffsetPx: Offset, private val tabSizePx: Size, /** Where the pointer was last seen; a rejected sample leaves it alone. */ 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 262ca1eed..08f3d75a3 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 @@ -498,11 +498,9 @@ class TabWorkspaceTest { assertEquals(listOf("a"), left.ids) val torn = assertNotNull(workspace.groups.firstOrNull { it.ids == listOf("b") }) - // Grabbed 10 px right and 20 px down inside the tab: the window's - // top-left lands 10 px left of the drop, and level with it — the tab - // is carried by its top edge wherever it was grabbed, so the ghost - // never covers the slot the pointer aims at. - assertEquals(DpOffset(490.dp, 400.dp), torn.position) + // Grabbed 10 px right and 20 px down inside the tab, so the window's + // top-left lands that far up and left of the drop. + assertEquals(DpOffset(490.dp, 380.dp), torn.position) assertEquals(DpSize(800.dp, 600.dp), torn.size, "the new window inherits the size of the old one") assertNull(workspace.dragGhost) } From 4baceaae7118df29beaf0264290eba9e8448aafc Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Tue, 8 Sep 2026 01:44:41 +0300 Subject: [PATCH 12/13] feat(tao): preview a tab drop as soon as the card reaches the strip, not the pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tab dragged towards another window's strip only previewed the drop once the pointer itself was inside it, so the card hung over the strip — hiding the very slot it was aiming at — before anything lit up. The dock zones already resolve from the dragged satellite's own edge; tabs now do the same. `TabWorkspace.dropTargetAt(draggedScreenRectPx, pointerScreenPx, …)` takes the card as well as the pointer: a strip the pointer is in wins, else the first strip the card has reached. The tear-off session hands it the ghost rect, and a single-tab window's drag hands its own strip band — the window is what moves there, so a merge reads as soon as the two strips meet. The excluded group is dropped from the search rather than ending it, which is what a single-tab window needs: its own strip travels with the pointer and covers whatever it is over. --- CLAUDE.md | 2 +- .../api/decorated-window-tao.api | 2 + .../window/tao/TabDragSessions.kt | 35 ++++++++-- .../window/tao/TabWorkspace.kt | 70 ++++++++++++++----- .../window/tao/TabWorkspaceTest.kt | 36 ++++++++++ .../window/tao/TaoSceneTestBattery.kt | 6 ++ 6 files changed, 126 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2d4131ec0..a9dd523d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ 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` (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), 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-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 diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index 8250d3966..be942b6dc 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -874,6 +874,8 @@ public final class dev/nucleusframework/window/tao/TabWorkspace { 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 getDefaultWindowSize-MYxV2XQ ()J public final fun getDragGhost ()Ldev/nucleusframework/window/tao/TabDragGhost; 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 d55453b17..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,10 +107,28 @@ 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) { if (!isLive) return update(pointerScreenPx) @@ -144,7 +162,12 @@ private class TabTearOffDragSession( if (!isLive) return pointer = pointerScreenPx.sanitizedOrNull() ?: pointer workspace.dragVelocityPxPerSecond = velocity.sample(pointer.x) - val target = workspace.dropTargetAt(pointer, exclude = entry) + // 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 @@ -152,15 +175,17 @@ private class TabTearOffDragSession( // 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, Rect(pointer - grabOffsetPx, tabSizePx), scaleFactor) + 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. 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 36c97bb14..5709a04dd 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 @@ -592,25 +592,57 @@ 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 - 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) - } - TabDropTarget(group, index) - }.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 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 08f3d75a3..6ff6c67d9 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 @@ -333,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() 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 b0f59b58b..d667d7ed3 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 @@ -1064,6 +1064,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`() } From fbb9fdfd5328a984efb625ec483f511515b03b50 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Tue, 8 Sep 2026 08:05:50 +0300 Subject: [PATCH 13/13] feat(tao): a hover card for the tab under the pointer, and a click a drag no longer swallows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The card.** A tab strip can now preview the tab the pointer rests on, the way a browser does, and the app draws it: `TabHoverPreview` takes the whole composable, with `TabHoverPreviewCard` as a stock one to build on or replace. A strip written from scratch composes `TabHoverPreviewPopup` and needs nothing else — the anchor is the slot `Modifier.tabSlot` already publishes, which also publishes the hovered tab itself (`TabStripScope.hoveredTab`). The card is withheld in every case where it would be in the way, and the rule lives on `hoveredTab` so custom chrome inherits it: the selected tab, whose body is on screen already; a drag in flight, which passes the carried tab over every neighbour without pointing at any; a press, until the pointer has moved on; and the card itself, since reaching it means having left the tab. That last one has to be said explicitly — a popup surface takes the pointer off the window beneath it, so the tab never hears it leave. **The picture.** `TabWorkspace(captureThumbnails = true)` keeps a reduced snapshot of the body each tab last showed, for a card to draw (`TabEntry.thumbnail`, refreshed on demand with `captureThumbnail`). Off by default: it records the body into a layer and reads it back. A native embed draws outside the scene and is missing from the picture, which is documented. **The swallowed click.** The whole tab is a drag grip and it claims the press before the tab's own click gesture, so a click whose pointer drifts past the touch slop became a drag instead — and a drag that ended where it began left the strip exactly as it was, the click lost and the tab having wobbled for nothing. Lifting a tab now selects it, as a browser does on the press, so the click always lands and the tab being carried is always the one on screen. `examples/tabs-demo` shows the file path under the title, `jewel-tabs-demo` draws a card entirely in Jewel's own colours, and `reader-dock-demo` hangs a right-to-left card off the right edge of its seforim. --- .../api/decorated-window-tao.api | 51 +- .../window/tao/TabHoverPreview.kt | 439 ++++++++++++++++++ .../nucleusframework/window/tao/TabStrip.kt | 19 + .../nucleusframework/window/tao/TabWindows.kt | 12 +- .../window/tao/TabWorkspace.kt | 119 ++++- .../window/tao/TabHoverPreviewTest.kt | 196 ++++++++ .../window/tao/TabWorkspaceTest.kt | 40 ++ .../window/tao/TaoSceneTestBattery.kt | 37 ++ .../tao/TaoSceneTestBatteryDriftTest.kt | 1 + .../tao/headful/SatelliteWorkspaceFixture.kt | 16 + .../window/tao/headful/TabWorkspaceFixture.kt | 47 +- .../headful/TabWorkspaceMouseHeadfulCases.kt | 93 +++- .../headful/WorkspaceFileDropHeadfulCases.kt | 5 + .../jeweltabsdemo/DemoState.kt | 11 +- .../jeweltabsdemo/JewelTabStrip.kt | 78 +++- .../nucleusframework/jeweltabsdemo/Main.kt | 2 +- .../nucleusframework/readerdockdemo/Main.kt | 2 +- .../readerdockdemo/ReaderState.kt | 9 +- .../readerdockdemo/ReaderTabStrip.kt | 39 +- .../nucleusframework/tabsdemo/DemoState.kt | 33 +- .../nucleusframework/tabsdemo/DemoTabStrip.kt | 38 +- .../dev/nucleusframework/tabsdemo/Main.kt | 2 +- 22 files changed, 1259 insertions(+), 30 deletions(-) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabHoverPreview.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabHoverPreviewTest.kt diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index be942b6dc..6c2322dac 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -211,10 +211,16 @@ public final class dev/nucleusframework/window/tao/ComposableSingletons$Satellit 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$-2032640526$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 { @@ -776,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 } @@ -800,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 @@ -834,7 +873,7 @@ public final class dev/nucleusframework/window/tao/TabStripDragKt { 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;Lkotlin/jvm/functions/Function3;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; @@ -868,15 +907,17 @@ public final class dev/nucleusframework/window/tao/TabWindowsKt { 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; @@ -902,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 { 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 8eb825efb..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 @@ -94,6 +94,10 @@ internal class TabStripScopeImpl( * 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. @@ -102,6 +106,7 @@ internal class TabStripScopeImpl( public fun TabStripScope.TabStrip( modifier: Modifier = Modifier, reorderAnimation: AnimationSpec? = TabReorderAnimation, + hoverPreview: TabHoverPreview? = null, trailing: @Composable TabStripScope.() -> Unit = {}, ) { val entries = tabs @@ -145,6 +150,10 @@ public fun TabStripScope.TabStrip( 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) } } /** @@ -333,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, @@ -348,6 +362,11 @@ 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)) } + // 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) 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 130e9b033..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 @@ -286,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 @@ -296,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 5709a04dd..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 @@ -10,6 +10,7 @@ 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 @@ -46,6 +47,30 @@ 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) /** @@ -103,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 @@ -182,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) @@ -253,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. @@ -444,6 +534,23 @@ public class TabWorkspace( 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 @@ -460,7 +567,7 @@ public class TabWorkspace( val group = entry.group ?: return null transferDrag?.cancel() releaseDrag(null) - draggedTab = entry + holdForDrag(entry) dropPreview = TabDropTarget(group, group.tabIds.indexOf(tabId)) return group } @@ -779,7 +886,7 @@ public class TabWorkspace( transferDrag?.cancel() val session = createTabDragSession(entry, origin, start) ?: return null drags.begin(session) - draggedTab = entry + holdForDrag(entry) dragGrabScreenPx = start dragPointerScreenPx = start return session @@ -812,7 +919,7 @@ public class TabWorkspace( releaseDrag(null) val session = createTabTransferDrag(entry, group, window) transferDrag = session - draggedTab = entry + holdForDrag(entry) return session } @@ -1016,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/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 6ff6c67d9..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 @@ -490,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 d667d7ed3..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 @@ -1016,6 +1016,43 @@ 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`() } 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 d461e937f..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 @@ -106,6 +106,7 @@ class TaoSceneTestBatteryDriftTest { 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/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/TabWorkspaceFixture.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceFixture.kt index 18d63f57c..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 @@ -6,6 +6,7 @@ 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 @@ -34,11 +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 @@ -61,6 +64,12 @@ internal class TabWorkspaceFixture( 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) @@ -106,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) @@ -205,7 +243,9 @@ internal class TabWorkspaceFixture( lastWindowClosedCount.value++ }, strip = { - CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { TabStrip() } + 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, @@ -299,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 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/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 c98f9527d..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,20 +1,34 @@ 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 @@ -52,9 +66,17 @@ 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 @@ -74,6 +96,56 @@ fun TabStripScope.JewelEditorTabStrip(onNewTab: () -> Unit) { ) 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. */ @@ -123,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/src/main/kotlin/dev/nucleusframework/readerdockdemo/Main.kt b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/Main.kt index a30bf1a67..23df3225d 100644 --- 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 @@ -123,7 +123,7 @@ fun main() = // leftwards, and the strip animates the same way. strip = { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { - ReaderTabStrip(onNewBook = reader::openBook) + ReaderTabStrip(reader, onNewBook = reader::openBook) } }, windowWrapper = { content -> 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 index 609820c1b..e52829b59 100644 --- 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 @@ -118,7 +118,14 @@ class BookState { * windows read two seforim side by side, each with its own pane widths. */ class ReaderState { - val tabs = TabWorkspace(defaultWindowSize = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp)) + // `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 = 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 index 57b8e112c..d025e4ab7 100644 --- 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 @@ -5,8 +5,10 @@ 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 @@ -15,20 +17,50 @@ 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. + * 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(onNewBook: () -> Unit) { - TabStrip(trailing = { NewBookButton(onNewBook) }) +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. */ @@ -52,3 +84,4 @@ private fun NewBookButton(onClick: () -> Unit) { 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 ->