Skip to content

Commit 31f3299

Browse files
committed
check in some starts for the new focus manager
1 parent 1492fa6 commit 31f3299

5 files changed

Lines changed: 1258 additions & 0 deletions

File tree

aiprompts/focus-layout.md

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# Wave Terminal Focus System - Layout State Flow
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.
4+
5+
## Overview
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:
8+
1. **Visual feedback** - The focus ring updates immediately
9+
2. **Physical DOM focus** - The terminal (or other view) receives actual browser focus
10+
11+
This is a "house of cards" architecture that works through reactive atom updates and clever React hooks.
12+
13+
## The Complete Flow
14+
15+
### 1. Setting focusedNodeId in LayoutTree
16+
17+
Throughout [`layoutTree.ts`](../frontend/layout/lib/layoutTree.ts), operations set the focused node:
18+
19+
**Example from insertNode** ([`layoutTree.ts:283-294`](../frontend/layout/lib/layoutTree.ts:283-294)):
20+
```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+
}
28+
}
29+
if (action.focused) {
30+
layoutState.focusedNodeId = action.node.id; // ← Or here
31+
}
32+
layoutState.generation++; // ← CRITICAL: Triggers commit
33+
```
34+
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.
49+
50+
### 2. Generation Increment Commits to WaveObject
51+
52+
The `treeStateAtom` in [`layoutAtom.ts`](../frontend/layout/lib/layoutAtom.ts) is a bidirectional atom that syncs with the backend WaveObject.
53+
54+
**The Write Path** ([`layoutAtom.ts:37-56`](../frontend/layout/lib/layoutAtom.ts:37-56)):
55+
```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
73+
}
74+
}
75+
```
76+
77+
**Without `generation++`, the changes stay local and never propagate!**
78+
79+
### 3. WaveObject Update Triggers Atom Recalculation
80+
81+
When the WaveObject is updated via `set(stateAtom, waveObjVal)`, all atoms that depend on it recalculate.
82+
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+
```typescript
108+
isFocused: atom((get) => {
109+
const treeState = get(this.treeStateAtom); // ← Depends on treeStateAtom
110+
const isFocused = treeState.focusedNodeId === nodeid; // ← Compare with this node's ID
111+
const waveAIFocused = get(atoms.waveAIFocusedAtom);
112+
return isFocused && !waveAIFocused;
113+
})
114+
```
115+
116+
When `treeStateAtom` updates, all `isFocused` atoms recalculate. Only the atom for the node matching `focusedNodeId` returns `true`.
117+
118+
### 5. Visual Focus Ring Updates
119+
120+
React components consume the `isFocused` atom and re-render when it changes.
121+
122+
**Block Component** ([`block.tsx:142`](../frontend/app/block/block.tsx:142)):
123+
```typescript
124+
const isFocused = useAtomValue(nodeModel.isFocused); // ← Subscribes to isFocused atom
125+
```
126+
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
130+
131+
This is where it gets clever (and fragile). Physical DOM focus is achieved through a cascade of two `useLayoutEffect` hooks.
132+
133+
**Step 1: isFocused Change Triggers blockClicked** ([`block.tsx:147-149`](../frontend/app/block/block.tsx:147-149)):
134+
```typescript
135+
useLayoutEffect(() => {
136+
setBlockClicked(isFocused); // When isFocused changes to true, trigger blockClicked
137+
}, [isFocused]);
138+
```
139+
140+
**Step 2: blockClicked Triggers Physical Focus** ([`block.tsx:151-163`](../frontend/app/block/block.tsx:151-163)):
141+
```typescript
142+
useLayoutEffect(() => {
143+
if (!blockClicked) {
144+
return;
145+
}
146+
setBlockClicked(false); // Reset for next time
147+
const focusWithin = focusedBlockId() == nodeModel.blockId;
148+
if (!focusWithin) {
149+
setFocusTarget(); // ← PHYSICAL DOM FOCUS HAPPENS HERE
150+
}
151+
if (!isFocused) {
152+
nodeModel.focusNode(); // Update layout state if needed
153+
}
154+
}, [blockClicked, isFocused]);
155+
```
156+
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).
158+
159+
**Step 3: setFocusTarget Delegates to ViewModel** ([`block.tsx:211-217`](../frontend/app/block/block.tsx:211-217)):
160+
```typescript
161+
const setFocusTarget = useCallback(() => {
162+
const ok = viewModel?.giveFocus?.(); // Try view-specific focus first
163+
if (ok) {
164+
return;
165+
}
166+
focusElemRef.current?.focus({ preventScroll: true }); // Fallback to dummy input
167+
}, []);
168+
```
169+
170+
**Step 4: Terminal's giveFocus Grants XTerm Focus** ([`term.tsx:414-427`](../frontend/app/view/term/term.tsx:414-427)):
171+
```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;
184+
}
185+
```
186+
187+
## The Complete Chain
188+
189+
```
190+
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+
└──────────────────────────┴──────────────────────────┘
224+
```
225+
226+
## User-Initiated Focus
227+
228+
When a user clicks a block, the flow is slightly different (see [`focus.md`](./focus.md) for details):
229+
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
234+
235+
This ensures the focus ring updates instantly on mousedown, while physical focus waits until after the click completes (protecting selections).
236+
237+
## Key Takeaways
238+
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
244+
245+
## Why This Architecture?
246+
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)
253+
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.

0 commit comments

Comments
 (0)