Skip to content

Commit 59857ae

Browse files
fix(docking): commit tab click-selection; reshape dock-showcase into ReactorIde sample (#438)
* fix(docking): commit tab click-selection; reshape dock-showcase into ReactorIde sample DockTabTearOff captured the pointer on PointerPressed to track a possible tear-off drag. For a plain click the TabViewItem then never received the release, so WinUI never committed the tab selection — clicking a tab did nothing in multi-tab groups. Commit the selection in OnReleased when the press never crossed the tear-off threshold (a surviving candidate). The threshold path already clears the candidate before tearing off, so this never double-fires. Latent until now because every existing docking demo used a single tab per group. Also rebuild the docking showcase as one cohesive IDE app: - rename samples/apps/dock-showcase -> reactor-ide (DockShowcase -> ReactorIde) - replace the 10-scene spec-review harness + JSON/op-log debug panels with a single VS-shaped app: Solution Explorer, editor well (DocumentArea), Properties/Git, Output/Terminal/Errors, menu bar, status bar - follow the idiomatic ownership model (app owns content, host owns shape): open/close drives manager.Layout key-set changes, reset via .WithKey() -- no OnLiveLayoutChanged round-trip (which breaks float/redock + selection) - editor panes fill via FlexColumn grow; multi-line TextBox uses .AcceptsReturn()/.TextWrapping() element props so the descriptor orders them before Text (a .Set lambda runs after Text and truncates to one line) Add the reactor-docking agent-kit skill documenting the ownership model and these gotchas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * review: do FileRow indent math in floating-point (CodeQL) CodeQL flagged `indent * 14` as int-multiplication-cast-to-double. The indent is only ever 1–2 so overflow is impossible, but make one operand a double literal to clear the static-analysis finding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f0d3e81 commit 59857ae

6 files changed

Lines changed: 703 additions & 1444 deletions

File tree

Reactor.slnx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,8 @@
227227
<Platform Solution="*|x64" Project="x64" />
228228
</Project>
229229
</Folder>
230-
<Folder Name="/samples/apps/dock-showcase/">
231-
<Project Path="samples/apps/dock-showcase/DockShowcase.csproj">
230+
<Folder Name="/samples/apps/reactor-ide/">
231+
<Project Path="samples/apps/reactor-ide/ReactorIde.csproj">
232232
<Platform Solution="*|ARM64" Project="ARM64" />
233233
<Platform Solution="*|x64" Project="x64" />
234234
</Project>
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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

Comments
 (0)