|
1 | 1 | # Wave Terminal Focus System - Layout State Flow |
2 | 2 |
|
3 | | -This document explains how focus state changes in the layout system (`layoutState.focusedNodeId`) propagate through the application to update both the visual focus ring and physical DOM focus. |
| 3 | +This document explains how focus state changes in the layout system propagate through the application to update both the visual focus ring and physical DOM focus. |
4 | 4 |
|
5 | 5 | ## Overview |
6 | 6 |
|
7 | | -When layout operations modify `layoutState.focusedNodeId` (in ~10+ places throughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts)), a carefully orchestrated chain of updates occurs that ultimately results in: |
| 7 | +When layout operations modify focus state, a straightforward chain of updates occurs: |
8 | 8 | 1. **Visual feedback** - The focus ring updates immediately |
9 | 9 | 2. **Physical DOM focus** - The terminal (or other view) receives actual browser focus |
10 | 10 |
|
11 | | -This is a "house of cards" architecture that works through reactive atom updates and clever React hooks. |
| 11 | +The system uses local atoms as the source of truth with async persistence to the backend. |
12 | 12 |
|
13 | | -## The Complete Flow |
| 13 | +## The Flow |
14 | 14 |
|
15 | | -### 1. Setting focusedNodeId in LayoutTree |
| 15 | +### 1. Setting Focus in Layout Operations |
16 | 16 |
|
17 | | -Throughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts), operations set the focused node: |
| 17 | +Throughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts), operations directly mutate `layoutState.focusedNodeId`: |
18 | 18 |
|
19 | | -**Example from insertNode** ([`layoutTree.ts:283-294`](../frontend/layout/lib/layoutTree.ts:283-294)): |
20 | 19 | ```typescript |
21 | | -} else { |
22 | | - const insertLoc = findNextInsertLocation(layoutState.rootNode, DEFAULT_MAX_CHILDREN); |
23 | | - addChildAt(insertLoc.node, insertLoc.index, action.node); |
24 | | - if (action.magnified) { |
25 | | - layoutState.magnifiedNodeId = action.node.id; |
26 | | - layoutState.focusedNodeId = action.node.id; // ← Setting focusedNodeId |
27 | | - } |
| 20 | +// Example from insertNode |
| 21 | +if (action.magnified) { |
| 22 | + layoutState.magnifiedNodeId = action.node.id; |
| 23 | + layoutState.focusedNodeId = action.node.id; |
28 | 24 | } |
29 | 25 | if (action.focused) { |
30 | | - layoutState.focusedNodeId = action.node.id; // ← Or here |
| 26 | + layoutState.focusedNodeId = action.node.id; |
31 | 27 | } |
32 | | -layoutState.generation++; // ← CRITICAL: Triggers commit |
33 | 28 | ``` |
34 | 29 |
|
35 | | -**Other locations that set focusedNodeId:** |
36 | | -- [`layoutTree.ts:288`](../frontend/layout/lib/layoutTree.ts:288) - insertNode (magnified) |
37 | | -- [`layoutTree.ts:292`](../frontend/layout/lib/layoutTree.ts:292) - insertNode (focused) |
38 | | -- [`layoutTree.ts:313`](../frontend/layout/lib/layoutTree.ts:313) - insertNodeAtIndex (magnified) |
39 | | -- [`layoutTree.ts:317`](../frontend/layout/lib/layoutTree.ts:317) - insertNodeAtIndex (focused) |
40 | | -- [`layoutTree.ts:370-371`](../frontend/layout/lib/layoutTree.ts:370-371) - deleteNode (clearing) |
41 | | -- [`layoutTree.ts:402`](../frontend/layout/lib/layoutTree.ts:402) - focusNode action |
42 | | -- [`layoutTree.ts:419`](../frontend/layout/lib/layoutTree.ts:419) - magnifyNodeToggle |
43 | | -- [`layoutTree.ts:427`](../frontend/layout/lib/layoutTree.ts:427) - clearTree (clearing) |
44 | | -- [`layoutTree.ts:454`](../frontend/layout/lib/layoutTree.ts:454) - replaceNode |
45 | | -- [`layoutTree.ts:498`](../frontend/layout/lib/layoutTree.ts:498) - splitHorizontal |
46 | | -- [`layoutTree.ts:540`](../frontend/layout/lib/layoutTree.ts:540) - splitVertical |
47 | | - |
48 | | -**The Critical Part:** Every time `focusedNodeId` is set, `generation++` follows. This increment is the signal that triggers the entire propagation chain. |
| 30 | +This happens in ~10 places: insertNode, insertNodeAtIndex, deleteNode, focusNode, magnifyNodeToggle, etc. |
49 | 31 |
|
50 | | -### 2. Generation Increment Commits to WaveObject |
| 32 | +### 2. Committing to Local Atom |
51 | 33 |
|
52 | | -The `treeStateAtom` in [`layoutAtom.ts`](../frontend/layout/lib/layoutAtom.ts) is a bidirectional atom that syncs with the backend WaveObject. |
| 34 | +The [`LayoutModel.treeReducer()`](../frontend/layout/lib/layoutModel.ts:547) commits changes: |
53 | 35 |
|
54 | | -**The Write Path** ([`layoutAtom.ts:37-56`](../frontend/layout/lib/layoutAtom.ts:37-56)): |
55 | 36 | ```typescript |
56 | | -(get, set, value) => { |
57 | | - if (get(generationAtom) < value.generation) { // ← Check if generation increased |
58 | | - const stateAtom = getLayoutStateAtomFromTab(tabAtom, get); |
59 | | - if (!stateAtom) return; |
60 | | - const waveObjVal = get(stateAtom); |
61 | | - if (waveObjVal == null) return; |
62 | | - |
63 | | - // Write to backend WaveObject |
64 | | - waveObjVal.rootnode = value.rootNode; |
65 | | - waveObjVal.magnifiednodeid = value.magnifiedNodeId; |
66 | | - waveObjVal.focusednodeid = value.focusedNodeId; // ← focusedNodeId commits here |
67 | | - waveObjVal.leaforder = value.leafOrder; |
68 | | - waveObjVal.pendingbackendactions = value?.pendingBackendActions?.length |
69 | | - ? value.pendingBackendActions |
70 | | - : undefined; |
71 | | - set(generationAtom, value.generation); |
72 | | - set(stateAtom, waveObjVal); // ← Triggers WaveObject update |
| 37 | +treeReducer(action: LayoutTreeAction, setState = true): boolean { |
| 38 | + // Mutate tree state |
| 39 | + focusNode(this.treeState, action); |
| 40 | + |
| 41 | + if (setState) { |
| 42 | + this.updateTree(); // Compute leafOrder, etc. |
| 43 | + this.setter(this.localTreeStateAtom, { ...this.treeState }); // Sync update |
| 44 | + this.persistToBackend(); // Async persistence |
73 | 45 | } |
74 | 46 | } |
75 | 47 | ``` |
76 | 48 |
|
77 | | -**Without `generation++`, the changes stay local and never propagate!** |
| 49 | +The key is `{ ...this.treeState }` creates a new object reference, triggering Jotai reactivity. |
78 | 50 |
|
79 | | -### 3. WaveObject Update Triggers Atom Recalculation |
| 51 | +### 3. Derived Atoms Recalculate |
80 | 52 |
|
81 | | -When the WaveObject is updated via `set(stateAtom, waveObjVal)`, all atoms that depend on it recalculate. |
| 53 | +Each block's `NodeModel` has an `isFocused` atom: |
82 | 54 |
|
83 | | -**The Read Path** ([`layoutAtom.ts:24-35`](../frontend/layout/lib/layoutAtom.ts:24-35)): |
84 | | -```typescript |
85 | | -(get) => { |
86 | | - const stateAtom = getLayoutStateAtomFromTab(tabAtom, get); |
87 | | - if (!stateAtom) return; |
88 | | - const layoutStateData = get(stateAtom); // ← Reads from WaveObject |
89 | | - const layoutTreeState: LayoutTreeState = { |
90 | | - rootNode: layoutStateData?.rootnode, |
91 | | - focusedNodeId: layoutStateData?.focusednodeid, // ← Gets new focusedNodeId |
92 | | - magnifiedNodeId: layoutStateData?.magnifiednodeid, |
93 | | - pendingBackendActions: layoutStateData?.pendingbackendactions, |
94 | | - generation: get(generationAtom), |
95 | | - }; |
96 | | - return layoutTreeState; |
97 | | -} |
98 | | -``` |
99 | | - |
100 | | -The WaveObject acts as the "source of truth" that drives the reactive chain. |
101 | | - |
102 | | -### 4. isFocused Atoms Recalculate |
103 | | - |
104 | | -Each block's `NodeModel` has an `isFocused` atom that derives from `treeStateAtom`. |
105 | | - |
106 | | -**NodeModel Creation** ([`layoutModel.ts:936-941`](../frontend/layout/lib/layoutModel.ts:936-941)): |
107 | 55 | ```typescript |
108 | 56 | isFocused: atom((get) => { |
109 | | - const treeState = get(this.treeStateAtom); // ← Depends on treeStateAtom |
110 | | - const isFocused = treeState.focusedNodeId === nodeid; // ← Compare with this node's ID |
| 57 | + const treeState = get(this.localTreeStateAtom); |
| 58 | + const isFocused = treeState.focusedNodeId === nodeid; |
111 | 59 | const waveAIFocused = get(atoms.waveAIFocusedAtom); |
112 | 60 | return isFocused && !waveAIFocused; |
113 | 61 | }) |
114 | 62 | ``` |
115 | 63 |
|
116 | | -When `treeStateAtom` updates, all `isFocused` atoms recalculate. Only the atom for the node matching `focusedNodeId` returns `true`. |
| 64 | +When `localTreeStateAtom` updates, all `isFocused` atoms recalculate. Only the matching node returns `true`. |
117 | 65 |
|
118 | | -### 5. Visual Focus Ring Updates |
| 66 | +### 4. React Components Re-render |
119 | 67 |
|
120 | | -React components consume the `isFocused` atom and re-render when it changes. |
| 68 | +**Visual Focus Ring** - Components subscribe to `isFocused`: |
121 | 69 |
|
122 | | -**Block Component** ([`block.tsx:142`](../frontend/app/block/block.tsx:142)): |
123 | 70 | ```typescript |
124 | | -const isFocused = useAtomValue(nodeModel.isFocused); // ← Subscribes to isFocused atom |
| 71 | +const isFocused = useAtomValue(nodeModel.isFocused); |
125 | 72 | ``` |
126 | 73 |
|
127 | | -The `isFocused` value is passed to child components and CSS classes, causing the focus ring to appear/disappear immediately. |
128 | | - |
129 | | -### 6. Physical DOM Focus via Two-Step Effect |
| 74 | +CSS classes update immediately, showing the focus ring. |
130 | 75 |
|
131 | | -This is where it gets clever (and fragile). Physical DOM focus is achieved through a cascade of two `useLayoutEffect` hooks. |
| 76 | +**Physical DOM Focus** - Two-step effect chain: |
132 | 77 |
|
133 | | -**Step 1: isFocused Change Triggers blockClicked** ([`block.tsx:147-149`](../frontend/app/block/block.tsx:147-149)): |
134 | 78 | ```typescript |
| 79 | +// Step 1: isFocused → blockClicked |
135 | 80 | useLayoutEffect(() => { |
136 | | - setBlockClicked(isFocused); // When isFocused changes to true, trigger blockClicked |
| 81 | + setBlockClicked(isFocused); |
137 | 82 | }, [isFocused]); |
138 | | -``` |
139 | 83 |
|
140 | | -**Step 2: blockClicked Triggers Physical Focus** ([`block.tsx:151-163`](../frontend/app/block/block.tsx:151-163)): |
141 | | -```typescript |
| 84 | +// Step 2: blockClicked → physical focus |
142 | 85 | useLayoutEffect(() => { |
143 | | - if (!blockClicked) { |
144 | | - return; |
145 | | - } |
146 | | - setBlockClicked(false); // Reset for next time |
| 86 | + if (!blockClicked) return; |
| 87 | + setBlockClicked(false); |
147 | 88 | const focusWithin = focusedBlockId() == nodeModel.blockId; |
148 | 89 | if (!focusWithin) { |
149 | | - setFocusTarget(); // ← PHYSICAL DOM FOCUS HAPPENS HERE |
150 | | - } |
151 | | - if (!isFocused) { |
152 | | - nodeModel.focusNode(); // Update layout state if needed |
| 90 | + setFocusTarget(); // Calls viewModel.giveFocus() |
153 | 91 | } |
154 | 92 | }, [blockClicked, isFocused]); |
155 | 93 | ``` |
156 | 94 |
|
157 | | -**Why two effects?** This separates the visual update (immediate) from the physical focus (coordinated). It also provides a single point where focus granting can be controlled (e.g., skipped if there's a selection). |
| 95 | +The terminal's `giveFocus()` method grants actual browser focus: |
158 | 96 |
|
159 | | -**Step 3: setFocusTarget Delegates to ViewModel** ([`block.tsx:211-217`](../frontend/app/block/block.tsx:211-217)): |
160 | 97 | ```typescript |
161 | | -const setFocusTarget = useCallback(() => { |
162 | | - const ok = viewModel?.giveFocus?.(); // Try view-specific focus first |
163 | | - if (ok) { |
164 | | - return; |
| 98 | +giveFocus(): boolean { |
| 99 | + if (termMode == "term" && this.termRef?.current?.terminal) { |
| 100 | + this.termRef.current.terminal.focus(); |
| 101 | + return true; |
165 | 102 | } |
166 | | - focusElemRef.current?.focus({ preventScroll: true }); // Fallback to dummy input |
167 | | -}, []); |
| 103 | + return false; |
| 104 | +} |
168 | 105 | ``` |
169 | 106 |
|
170 | | -**Step 4: Terminal's giveFocus Grants XTerm Focus** ([`term.tsx:414-427`](../frontend/app/view/term/term.tsx:414-427)): |
| 107 | +### 5. Background Persistence |
| 108 | + |
| 109 | +While the UI updates synchronously, persistence happens asynchronously: |
| 110 | + |
171 | 111 | ```typescript |
172 | | -giveFocus(): boolean { |
173 | | - if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) { |
174 | | - return true; // Search panel handles focus |
175 | | - } |
176 | | - let termMode = globalStore.get(this.termMode); |
177 | | - if (termMode == "term") { |
178 | | - if (this.termRef?.current?.terminal) { |
179 | | - this.termRef.current.terminal.focus(); // ← XTerm gets actual browser focus |
180 | | - return true; |
181 | | - } |
182 | | - } |
183 | | - return false; |
| 112 | +private persistToBackend() { |
| 113 | + // Debounced (100ms) to avoid excessive writes |
| 114 | + setTimeout(() => { |
| 115 | + waveObj.rootnode = this.treeState.rootNode; |
| 116 | + waveObj.focusednodeid = this.treeState.focusedNodeId; |
| 117 | + waveObj.magnifiednodeid = this.treeState.magnifiedNodeId; |
| 118 | + waveObj.leaforder = this.treeState.leafOrder; |
| 119 | + this.setter(this.waveObjectAtom, waveObj); |
| 120 | + }, 100); |
184 | 121 | } |
185 | 122 | ``` |
186 | 123 |
|
| 124 | +The WaveObject is used purely for persistence (tab restore, uncaching). |
| 125 | + |
187 | 126 | ## The Complete Chain |
188 | 127 |
|
189 | 128 | ``` |
| 129 | +User action |
| 130 | + ↓ |
190 | 131 | layoutState.focusedNodeId = nodeId |
191 | | - ↓ |
192 | | -layoutState.generation++ |
193 | | - ↓ |
194 | | -treeStateAtom setter (checks generation) |
195 | | - ↓ |
196 | | -WaveObject.focusednodeid = nodeId (commit) |
197 | | - ↓ |
198 | | -WaveObject update notification |
199 | | - ↓ |
200 | | -treeStateAtom getter runs |
201 | | - ↓ |
202 | | -All isFocused atoms recalculate |
203 | | - ↓ |
204 | | -React components re-render |
205 | | - ↓ |
206 | | -┌──────────────────────────┬──────────────────────────┐ |
207 | | -│ │ │ |
208 | | -│ Visual Focus Ring │ Physical DOM Focus │ |
209 | | -│ (immediate) │ (coordinated) │ |
210 | | -│ │ │ |
211 | | -│ CSS updates based on │ useLayoutEffect #1: │ |
212 | | -│ isFocused value │ isFocused → blockClicked│ |
213 | | -│ │ │ |
214 | | -│ │ useLayoutEffect #2: │ |
215 | | -│ │ blockClicked → setFocusTarget│ |
216 | | -│ │ │ |
217 | | -│ │ setFocusTarget() │ |
218 | | -│ │ ↓ │ |
219 | | -│ │ viewModel.giveFocus() │ |
220 | | -│ │ ↓ │ |
221 | | -│ │ terminal.focus() │ |
222 | | -│ │ │ |
223 | | -└──────────────────────────┴──────────────────────────┘ |
| 132 | + ↓ |
| 133 | +setter(localTreeStateAtom, { ...treeState }) |
| 134 | + ↓ |
| 135 | +isFocused atoms recalculate |
| 136 | + ↓ |
| 137 | +React re-renders |
| 138 | + ↓ |
| 139 | +┌────────────────────┬────────────────────┐ |
| 140 | +│ Visual Ring │ Physical Focus │ |
| 141 | +│ (immediate CSS) │ (2-step effect) │ |
| 142 | +└────────────────────┴────────────────────┘ |
| 143 | + ↓ |
| 144 | +persistToBackend() (async, debounced) |
224 | 145 | ``` |
225 | 146 |
|
226 | | -## User-Initiated Focus |
| 147 | +## Key Points |
227 | 148 |
|
228 | | -When a user clicks a block, the flow is slightly different (see [`focus.md`](./focus.md) for details): |
| 149 | +1. **Local atoms** - `localTreeStateAtom` is the source of truth during runtime |
| 150 | +2. **Synchronous updates** - UI changes happen immediately in one React tick |
| 151 | +3. **Async persistence** - Backend writes are fire-and-forget with debouncing |
| 152 | +4. **Two-step focus** - Separates visual (instant) from physical (coordinated) DOM focus |
| 153 | +5. **View delegation** - Each view implements `giveFocus()` for custom focus behavior |
| 154 | + |
| 155 | +## User-Initiated Focus |
229 | 156 |
|
230 | | -1. **`onFocusCapture`** fires on mousedown → immediately calls `nodeModel.focusNode()` |
231 | | -2. This updates the layout state (visual focus ring updates) |
232 | | -3. **`onClick`** fires after click → sets `blockClicked = true` |
233 | | -4. The two-step effect chain grants physical DOM focus |
| 157 | +When a user clicks a block: |
234 | 158 |
|
235 | | -This ensures the focus ring updates instantly on mousedown, while physical focus waits until after the click completes (protecting selections). |
| 159 | +1. **`onFocusCapture`** (mousedown) → calls `nodeModel.focusNode()` → visual focus ring appears |
| 160 | +2. **`onClick`** → sets `blockClicked = true` → two-step effect chain → physical DOM focus |
236 | 161 |
|
237 | | -## Key Takeaways |
| 162 | +This ensures visual feedback is instant while protecting selections. |
238 | 163 |
|
239 | | -1. **`generation++` is critical** - Without it, changes never commit to the WaveObject |
240 | | -2. **WaveObject is the hub** - All state flows through the backend WaveObject, enabling persistence and sync |
241 | | -3. **Reactive atoms propagate changes** - Jotai atoms automatically update when dependencies change |
242 | | -4. **Two-step effect for physical focus** - Using `blockClicked` as a trigger separates visual from physical updates |
243 | | -5. **View-specific focus** - Each view type (terminal, editor, etc.) implements its own `giveFocus()` method |
| 164 | +## Backend Actions |
244 | 165 |
|
245 | | -## Why This Architecture? |
| 166 | +On initialization or backend updates, queued actions are processed: |
246 | 167 |
|
247 | | -This seemingly complex flow provides several benefits: |
248 | | -- **Persistence**: Changes automatically sync to the backend |
249 | | -- **Consistency**: Single source of truth for focus state |
250 | | -- **Flexibility**: Views can customize focus behavior via `giveFocus()` |
251 | | -- **Performance**: Visual updates are immediate while physical focus is deferred |
252 | | -- **Protection**: The two-step approach allows for conditional focus granting (e.g., preserving selections) |
| 168 | +```typescript |
| 169 | +if (initialState.pendingBackendActions?.length) { |
| 170 | + fireAndForget(() => this.processPendingBackendActions()); |
| 171 | +} |
| 172 | +``` |
253 | 173 |
|
254 | | -However, it is indeed a "house of cards" - each piece depends on the previous one working correctly. Understanding this flow is crucial for debugging focus-related issues. |
| 174 | +Backend can queue layout operations (create blocks, etc.) via `PendingBackendActions`. |
0 commit comments