|
| 1 | +--- |
| 2 | +name: reactor-docking |
| 3 | +description: "Reactor docking windows — `DockManager`, `DockSplit`/`DockTabGroup`/`Document`/`ToolWindow`, drag-to-float/redock, roles (`DocumentArea`/`ToolWindowStrip`), persistence. Use when building IDE-/Office-shaped layouts with dockable, tear-out tool windows and document wells. READ THIS BEFORE wiring `DockManager` — the content-vs-shape ownership model is the #1 thing people get wrong." |
| 4 | +--- |
| 5 | + |
| 6 | +# Docking in Reactor |
| 7 | + |
| 8 | +`DockManager` hosts a tree of dockable panes that the user can drag, split, |
| 9 | +tear out into floating windows, re-dock, pin to a side, and persist. It is the |
| 10 | +foundation for IDE-/Office-shaped apps (solution explorer + editor well + |
| 11 | +tool panes). |
| 12 | + |
| 13 | +```csharp |
| 14 | +configure: host => DockingNativeInterop.Register(host.Reconciler) // required once at startup |
| 15 | +``` |
| 16 | + |
| 17 | +## The ownership model — READ THIS FIRST |
| 18 | + |
| 19 | +This is the single most important rule, and the easiest to get wrong: |
| 20 | + |
| 21 | +> **The app owns CONTENT. The host owns SHAPE.** |
| 22 | + |
| 23 | +- **Content** = which panes exist, their `Title`/`Content`/`Key`, which document |
| 24 | + is active. The app declares this via `manager.Layout`, fresh every render, |
| 25 | + derived from its own `UseState`. |
| 26 | +- **Shape** = the user's drag-modified arrangement: split orientations/ratios, |
| 27 | + which group a tab lives in, floating windows. The **host owns this |
| 28 | + internally** (spec 045 §2.30) and resolves the effective layout each render |
| 29 | + by matching your content to its shape *by `Key`*. |
| 30 | + |
| 31 | +### ❌ The anti-pattern that breaks everything |
| 32 | + |
| 33 | +Do **NOT** round-trip the host's live layout back into your own state: |
| 34 | + |
| 35 | +```csharp |
| 36 | +// ❌ WRONG — feeds the host's shape back into the content prop. |
| 37 | +var (layout, setLayout) = UseState<DockNode?>(BuildLayout()); |
| 38 | +new DockManager { |
| 39 | + Layout = layout, |
| 40 | + OnLiveLayoutChanged = next => setLayout(next), // ⛔ double-owns the shape |
| 41 | +}; |
| 42 | +``` |
| 43 | + |
| 44 | +This double-owns the shape. Symptoms (all of these at once): |
| 45 | +- **Drag-out-to-float works, but you can't drag back to re-dock.** |
| 46 | +- **Clicking tabs doesn't switch** / selection resets. |
| 47 | +- Splitter drags snap back. |
| 48 | + |
| 49 | +`OnLiveLayoutChanged` is for **observation only** (e.g. a layout inspector). It |
| 50 | +is never a setter for `manager.Layout`. |
| 51 | + |
| 52 | +### ✅ The correct pattern |
| 53 | + |
| 54 | +The app holds *which documents are open* (content), not the live tree. Opening |
| 55 | +or closing a pane changes the **set of `Key`s** in `manager.Layout`; the host |
| 56 | +detects the key-set change and merges it into its shape. Reset is a |
| 57 | +`.WithKey(...)` remount. |
| 58 | + |
| 59 | +```csharp |
| 60 | +var (openDocs, setOpenDocs) = UseState(ImmutableList.Create(File.AppCs)); |
| 61 | +var (activeKey, setActiveKey) = UseState<object?>(File.AppCs.Key); |
| 62 | +var (epoch, bumpEpoch) = UseReducer(0); |
| 63 | + |
| 64 | +void Open(ProjectFile f) { |
| 65 | + if (!openDocs.Any(d => Equals(d.Key, f.Key))) setOpenDocs(openDocs.Add(f)); |
| 66 | + setActiveKey(f.Key); // focus it (drives SelectedIndex) |
| 67 | +} |
| 68 | + |
| 69 | +var editorWell = new DockTabGroup( |
| 70 | + openDocs.Select(MakeDoc).ToArray(), |
| 71 | + SelectedIndex: openDocs.FindIndex(d => Equals(d.Key, activeKey)), |
| 72 | + ShowWhenEmpty: true, |
| 73 | + Role: DockGroupRole.DocumentArea); |
| 74 | + |
| 75 | +new DockManager { |
| 76 | + Layout = BuildLayout(editorWell), |
| 77 | + // Sync state when the host closes a tab via its X button. The host already |
| 78 | + // removed it from its shape; drop it from openDocs so the key sets converge. |
| 79 | + OnDocumentClosing = args => |
| 80 | + setOpenDocs(openDocs.RemoveAll(d => Equals(d.Key, args.Document.Key))), |
| 81 | +}.WithKey($"dock-{epoch}"); // View ▸ Reset Layout: bumpEpoch + reset openDocs |
| 82 | +``` |
| 83 | + |
| 84 | +Rules of thumb: |
| 85 | +- Derive `manager.Layout` from app state every render. Never store the host's tree. |
| 86 | +- Add/remove a pane ⇒ the `Key` set changes ⇒ the host picks it up. |
| 87 | +- Model-mutator additions (drag, `DockHostModel.Dock`) keep the same app key set, |
| 88 | + so the host preserves them. That's why round-tripping is both unnecessary and harmful. |
| 89 | +- Reset / discard drag state ⇒ `.WithKey($"dock-{epoch}")` bump remounts the host. |
| 90 | +- Layout persistence across launches ⇒ `PersistenceId`, not `OnLiveLayoutChanged`. |
| 91 | + |
| 92 | +## Building a layout |
| 93 | + |
| 94 | +```csharp |
| 95 | +new DockSplit(Orientation.Vertical, new DockNode[] { |
| 96 | + new DockSplit(Orientation.Horizontal, new DockNode[] { |
| 97 | + new DockTabGroup(new DockableContent[]{ solutionTool }, Width: 260, |
| 98 | + Role: DockGroupRole.ToolWindowStrip), |
| 99 | + editorWell, // DocumentArea |
| 100 | + new DockTabGroup(new DockableContent[]{ propsTool, gitTool }, Width: 300, |
| 101 | + TabPosition: TabPosition.Bottom, CompactTabs: true, |
| 102 | + Role: DockGroupRole.ToolWindowStrip), |
| 103 | + }), |
| 104 | + new DockTabGroup(new DockableContent[]{ output, terminal, errors }, |
| 105 | + Height: 220, TabPosition: TabPosition.Bottom, CompactTabs: true, |
| 106 | + Role: DockGroupRole.ToolWindowStrip), |
| 107 | +}); |
| 108 | +``` |
| 109 | + |
| 110 | +- `Document` (closable, not pinnable) vs `ToolWindow` (hideable, side-pinnable, |
| 111 | + `AllowedSides` mask). Both reconcile through `DockableContent`. |
| 112 | +- **Roles** (spec 046): `DocumentArea` is the preferred `Dock(Center)` target and |
| 113 | + **survives empty** (the well stays a visible drop target). `ToolWindowStrip` is |
| 114 | + the preferred target for tool-window drops. Use object-initializer syntax for |
| 115 | + `Document`/`ToolWindow` so you opt into permission flags additively. |
| 116 | +- Every pane needs a **stable, equatable `Key`** — it's how the host preserves |
| 117 | + pane state across reorders, tear-out, and re-dock. No fallback to title-keying. |
| 118 | + |
| 119 | +## Pane content gotchas |
| 120 | + |
| 121 | +### Make pane bodies fill the pane |
| 122 | + |
| 123 | +A docked pane body is **content-sized** by default — a plain `Border`/`TextBox` |
| 124 | +collapses to its desired height at the top of the pane. To fill, put it in a |
| 125 | +flex container with `grow`: |
| 126 | + |
| 127 | +```csharp |
| 128 | +FlexColumn( |
| 129 | + editorTextBox.Flex(grow: 1, basis: 0) |
| 130 | +).Flex(grow: 1); |
| 131 | +``` |
| 132 | + |
| 133 | +### Multi-line TextBox: `AcceptsReturn` must be an element prop, not `.Set` |
| 134 | + |
| 135 | +The `TextBox` descriptor applies `AcceptsReturn`/`TextWrapping` **before** `Text` |
| 136 | +on purpose — single-line mode truncates `Text` at the first newline. A |
| 137 | +`.Set(tb => tb.AcceptsReturn = true)` lambda runs *after* `Text` is assigned, so |
| 138 | +the body collapses to one line. Use the first-class modifiers: |
| 139 | + |
| 140 | +```csharp |
| 141 | +// ✅ ordered before Text by the descriptor |
| 142 | +TextBox(text, setText).AcceptsReturn().TextWrapping(TextWrapping.NoWrap) |
| 143 | + |
| 144 | +// ❌ runs after Text → multi-line content truncated to the first line |
| 145 | +TextBox(text, setText).Set(tb => tb.AcceptsReturn = true) |
| 146 | +``` |
| 147 | + |
| 148 | +Generally: any prop whose *ordering relative to another prop matters* belongs on |
| 149 | +the element (`.AcceptsReturn()`, `.TextWrapping()`), not in a `.Set(...)` escape |
| 150 | +hatch — `.Set` always runs last. |
| 151 | + |
| 152 | +## Key APIs |
| 153 | + |
| 154 | +| API | Purpose | |
| 155 | +|-----|---------| |
| 156 | +| `DockManager { Layout, PersistenceId, ... }` | The host element | |
| 157 | +| `DockSplit(Orientation, children)` | Resizable split (ratios owned by host) | |
| 158 | +| `DockTabGroup(docs, TabPosition, CompactTabs, SelectedIndex, Width/Height, Role)` | A tab group | |
| 159 | +| `Document { Title, Key, Content, CanClose }` | Closable document pane | |
| 160 | +| `ToolWindow { Title, Key, Content, CanPin, AllowedSides, ... }` | Hideable/pinnable tool pane | |
| 161 | +| `.WithKey($"dock-{epoch}")` | Remount to reset/discard drag shape | |
| 162 | +| `OnDocumentClosing` | Sync app state when the host closes a tab | |
| 163 | +| `OnLiveLayoutChanged` | **Observe** the resolved layout (never feed back to `Layout`) | |
| 164 | +| `DockingNativeInterop.Register(host.Reconciler)` | One-time startup registration | |
| 165 | + |
| 166 | +See `samples/apps/reactor-ide` for a complete IDE-shaped app exercising all of this. |
0 commit comments