detached 07cfeef

refactor(browser): replace focus_manager with centralized FocusStateMachine

- Replace focus_manager.go with new FocusStateMachine for centralized focus management
- Add priority queues, request deduplication, and metrics tracking to focus system
- Add comprehensive focus debugging and documentation
- Minor enhancements to existing stacked panes and workspace components
- Remove obsolete generated frontend types
- Add missing webkit widget management functions
commit
07cfeef38fe2d2ba460a2a63c49acd929e62db97
author
Brice · · 2025-09-27 20:59 UTC
committer
Brice · · 2025-09-27 21:00 UTC
parent
333d85c
signature
unsigned
15 files · +1819 -614
A docs/focus-refactoring.md +429 -0
@@ -0,0 +1,429 @@
1+Detailed Implementation Plan: State Machine with Focus Queue
2+
3+ Architecture Overview
4+
5+ The focus management system will be refactored into a centralized state machine that serializes all
6+ focus operations through a queue, ensuring deterministic behavior and eliminating race conditions.
7+
8+ Core Components
9+
10+ 1. Focus State Machine (focus_state_machine.go)
11+
12+ States:
13+ - StateInitializing: System startup, determining initial focus
14+ - StateIdle: No focus transition in progress, system stable
15+ - StateTransitioning: Processing a focus change request
16+ - StateFocused: Focus successfully applied, awaiting next request
17+ - StateReconciling: Fixing inconsistencies detected in CSS/focus state
18+
19+ State Transitions:
20+ Initializing → Idle (on workspace ready)
21+ Idle → Transitioning (on focus request)
22+ Transitioning → Focused (on success)
23+ Transitioning → StateReconciling (on conflict)
24+ Focused → Idle (after settling time)
25+ StateReconciling → Focused (after fix)
26+
27+ Data Structures:
28+ type FocusState string
29+
30+ type FocusRequest struct {
31+ ID string
32+ TargetNode *paneNode
33+ Source FocusSource // Keyboard, Mouse, Programmatic, System
34+ Priority int // Higher priority preempts queue
35+ Timestamp time.Time
36+ Context map[string]interface{} // Additional metadata
37+ }
38+
39+ type FocusStateMachine struct {
40+ mu sync.RWMutex
41+ currentState FocusState
42+ activePane *paneNode
43+ requestQueue chan FocusRequest
44+ historyRing *RingBuffer[FocusTransition]
45+ cssReconciler *CSSReconciler
46+ gtkController *GTKFocusController
47+ validators []FocusValidator
48+ settlingTimer *time.Timer
49+ }
50+
51+ 2. Focus Request Queue System
52+
53+ Queue Management:
54+ - Buffered channel with capacity 100 for focus requests
55+ - Priority queue for urgent focus changes (e.g., user keyboard navigation)
56+ - Request deduplication to prevent redundant operations
57+ - Request coalescing for rapid focus changes
58+
59+ Request Processing:
60+ func (fsm *FocusStateMachine) processQueue() {
61+ for request := range fsm.requestQueue {
62+ // Validate request
63+ if !fsm.validateRequest(request) {
64+ continue
65+ }
66+
67+ // Check for higher priority requests
68+ if fsm.hasHigherPriorityRequest(request) {
69+ fsm.requeue(request)
70+ continue
71+ }
72+
73+ // Execute state transition
74+ fsm.transitionTo(StateTransitioning)
75+ fsm.executeFocusChange(request)
76+ fsm.transitionTo(StateFocused)
77+
78+ // Start settling timer
79+ fsm.startSettlingTimer()
80+ }
81+ }
82+
83+ 3. CSS Reconciliation Engine
84+
85+ Reconciler Responsibilities:
86+ - Track expected CSS state for each pane
87+ - Detect CSS class inconsistencies
88+ - Apply corrective CSS changes atomically
89+ - Verify CSS state after GTK main loop iteration
90+
91+ Implementation:
92+ type CSSReconciler struct {
93+ expectedState map[*paneNode]CSSClasses
94+ mu sync.RWMutex
95+ }
96+
97+ type CSSClasses struct {
98+ Base []string // workspace-pane, workspace-multi-pane
99+ Active bool // workspace-pane-active
100+ Stacked []string // stacked-pane-active, stacked-pane-collapsed
101+ Custom []string // Any additional classes
102+ Timestamp time.Time
103+ }
104+
105+ func (cr *CSSReconciler) reconcile(node *paneNode, expected CSSClasses) error {
106+ actual := cr.getCurrentClasses(node)
107+ if !cr.matches(actual, expected) {
108+ return cr.applyClasses(node, expected)
109+ }
110+ return nil
111+ }
112+
113+ 4. GTK4 Focus Integration
114+
115+ Native Focus Tracking:
116+ // In workspace_widgets_cgo.go
117+ static void focus_enter_cb(GtkEventControllerFocus* controller, gpointer user_data) {
118+ uintptr_t nodePtr = (uintptr_t)user_data;
119+ goFocusEnterCallback(nodePtr);
120+ }
121+
122+ static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_data) {
123+ uintptr_t nodePtr = (uintptr_t)user_data;
124+ goFocusLeaveCallback(nodePtr);
125+ }
126+
127+ static GtkEventController* widget_add_focus_controller(GtkWidget* widget, uintptr_t nodePtr) {
128+ GtkEventController* focus = gtk_event_controller_focus_new();
129+ g_signal_connect(focus, "enter", G_CALLBACK(focus_enter_cb), (gpointer)nodePtr);
130+ g_signal_connect(focus, "leave", G_CALLBACK(focus_leave_cb), (gpointer)nodePtr);
131+ gtk_widget_add_controller(widget, focus);
132+ return focus;
133+ }
134+
135+ 5. Focus History & Debugging
136+
137+ Ring Buffer for History:
138+ type FocusTransition struct {
139+ From *paneNode
140+ To *paneNode
141+ State FocusState
142+ Source FocusSource
143+ Timestamp time.Time
144+ Success bool
145+ Error error
146+ Stack []byte // Stack trace for debugging
147+ }
148+
149+ type RingBuffer[T any] struct {
150+ buffer []T
151+ head int
152+ tail int
153+ size int
154+ mu sync.RWMutex
155+ }
156+
157+ Initialization Sequence
158+
159+ The initialization sequence ensures proper focus state from startup:
160+
161+ func (fsm *FocusStateMachine) Initialize(wm *WorkspaceManager) error {
162+ fsm.transitionTo(StateInitializing)
163+
164+ // Phase 1: Setup GTK controllers for all panes
165+ leaves := wm.collectLeaves()
166+ for _, leaf := range leaves {
167+ fsm.attachGTKController(leaf)
168+ }
169+
170+ // Phase 2: Determine initial focus
171+ initialPane := fsm.determineInitialFocus(leaves)
172+
173+ // Phase 3: Apply initial focus and CSS
174+ fsm.applyInitialFocus(initialPane)
175+
176+ // Phase 4: Start queue processor
177+ go fsm.processQueue()
178+
179+ // Phase 5: Transition to idle
180+ fsm.transitionTo(StateIdle)
181+
182+ return nil
183+ }
184+
185+ func (fsm *FocusStateMachine) determineInitialFocus(leaves []*paneNode) *paneNode {
186+ // Priority order:
187+ // 1. First pane with user content
188+ // 2. Top-left pane (geometrically)
189+ // 3. First pane in tree order
190+
191+ for _, leaf := range leaves {
192+ if leaf.pane != nil && leaf.pane.webView != nil {
193+ if leaf.pane.webView.GetURL() != "about:blank" {
194+ return leaf
195+ }
196+ }
197+ }
198+
199+ // Fall back to geometric selection
200+ return fsm.findTopLeftPane(leaves)
201+ }
202+
203+ Focus Request Flow
204+
205+ graph TD
206+ A[Focus Request] --> B{Validate Request}
207+ B -->|Valid| C[Add to Queue]
208+ B -->|Invalid| D[Log & Reject]
209+ C --> E{Check State}
210+ E -->|Idle| F[Process Immediately]
211+ E -->|Busy| G[Wait in Queue]
212+ F --> H[Transition to Processing]
213+ H --> I[Remove Old Focus]
214+ I --> J[Apply New Focus]
215+ J --> K[Update CSS Classes]
216+ K --> L[Notify Observers]
217+ L --> M[Start Settling Timer]
218+ M --> N[Transition to Focused]
219+ N --> O[After Settling: Idle]
220+
221+ Key Methods
222+
223+ Request Focus Change
224+
225+ func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) error {
226+ request := FocusRequest{
227+ ID: generateRequestID(),
228+ TargetNode: node,
229+ Source: source,
230+ Priority: fsm.calculatePriority(source),
231+ Timestamp: time.Now(),
232+ }
233+
234+ select {
235+ case fsm.requestQueue <- request:
236+ return nil
237+ case <-time.After(100 * time.Millisecond):
238+ return ErrFocusQueueFull
239+ }
240+ }
241+
242+ Execute Focus Change
243+
244+ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
245+ fsm.mu.Lock()
246+ defer fsm.mu.Unlock()
247+
248+ oldPane := fsm.activePane
249+ newPane := request.TargetNode
250+
251+ // Record transition start
252+ transition := FocusTransition{
253+ From: oldPane,
254+ To: newPane,
255+ Source: request.Source,
256+ Timestamp: request.Timestamp,
257+ Stack: debug.Stack(),
258+ }
259+
260+ // Phase 1: Validate target is still valid
261+ if !fsm.isValidTarget(newPane) {
262+ transition.Success = false
263+ transition.Error = ErrInvalidTarget
264+ fsm.recordTransition(transition)
265+ return ErrInvalidTarget
266+ }
267+
268+ // Phase 2: Remove focus from old pane
269+ if oldPane != nil {
270+ fsm.removeFocusFrom(oldPane)
271+ }
272+
273+ // Phase 3: Apply focus to new pane
274+ if err := fsm.applyFocusTo(newPane); err != nil {
275+ // Rollback on failure
276+ if oldPane != nil {
277+ fsm.applyFocusTo(oldPane)
278+ }
279+ transition.Success = false
280+ transition.Error = err
281+ fsm.recordTransition(transition)
282+ return err
283+ }
284+
285+ // Phase 4: Update CSS classes atomically
286+ fsm.cssReconciler.beginTransaction()
287+ if oldPane != nil {
288+ fsm.cssReconciler.removeClass(oldPane, "workspace-pane-active")
289+ }
290+ fsm.cssReconciler.addClass(newPane, "workspace-pane-active")
291+ fsm.cssReconciler.commit()
292+
293+ // Phase 5: Update state
294+ fsm.activePane = newPane
295+ transition.Success = true
296+ fsm.recordTransition(transition)
297+
298+ // Phase 6: Notify observers
299+ fsm.notifyFocusChange(oldPane, newPane)
300+
301+ return nil
302+ }
303+
304+ CSS Verification Loop
305+
306+ func (fsm *FocusStateMachine) startCSSVerificationLoop() {
307+ ticker := time.NewTicker(100 * time.Millisecond)
308+ go func() {
309+ for range ticker.C {
310+ fsm.verifyCSSState()
311+ }
312+ }()
313+ }
314+
315+ func (fsm *FocusStateMachine) verifyCSSState() {
316+ fsm.mu.RLock()
317+ activePane := fsm.activePane
318+ fsm.mu.RUnlock()
319+
320+ if activePane == nil {
321+ return
322+ }
323+
324+ leaves := fsm.wm.collectLeaves()
325+ activeCount := 0
326+
327+ for _, leaf := range leaves {
328+ hasActive := fsm.hasActiveClass(leaf)
329+ shouldHaveActive := (leaf == activePane)
330+
331+ if hasActive && shouldHaveActive {
332+ activeCount++
333+ } else if hasActive && !shouldHaveActive {
334+ // ERROR: Inactive pane has active class
335+ log.Printf("[FSM] CSS ERROR: Inactive pane has active class, fixing...")
336+ fsm.cssReconciler.removeClass(leaf, "workspace-pane-active")
337+ } else if !hasActive && shouldHaveActive {
338+ // ERROR: Active pane missing active class
339+ log.Printf("[FSM] CSS ERROR: Active pane missing active class, fixing...")
340+ fsm.cssReconciler.addClass(leaf, "workspace-pane-active")
341+ }
342+ }
343+
344+ if activeCount > 1 {
345+ log.Fatalf("[FSM] CRITICAL: Multiple active panes detected: %d", activeCount)
346+ }
347+ }
348+
349+ Stacked Pane Integration
350+
351+ For stacked panes, the state machine handles special transitions:
352+
353+ func (fsm *FocusStateMachine) handleStackedFocus(stack *paneNode, index int) error {
354+ if !stack.isStacked || index >= len(stack.stackedPanes) {
355+ return ErrInvalidStackOperation
356+ }
357+
358+ request := FocusRequest{
359+ TargetNode: stack.stackedPanes[index],
360+ Source: SourceStackNavigation,
361+ Priority: PriorityHigh,
362+ Context: map[string]interface{}{
363+ "stack": stack,
364+ "oldIndex": stack.activeStackIndex,
365+ "newIndex": index,
366+ },
367+ }
368+
369+ return fsm.RequestFocus(stack.stackedPanes[index], SourceStackNavigation)
370+ }
371+
372+ Debug Commands
373+
374+ Add debug commands for testing:
375+
376+ func (fsm *FocusStateMachine) DumpState() FocusDebugInfo {
377+ fsm.mu.RLock()
378+ defer fsm.mu.RUnlock()
379+
380+ return FocusDebugInfo{
381+ CurrentState: fsm.currentState,
382+ ActivePane: fsm.activePane,
383+ QueueLength: len(fsm.requestQueue),
384+ History: fsm.historyRing.GetAll(),
385+ CSSState: fsm.cssReconciler.GetState(),
386+ ValidationErrors: fsm.getValidationErrors(),
387+ }
388+ }
389+
390+ func (fsm *FocusStateMachine) ForceReconcile() {
391+ fsm.transitionTo(StateReconciling)
392+ fsm.cssReconciler.forceFullReconciliation()
393+ fsm.transitionTo(StateIdle)
394+ }
395+
396+ Implementation Timeline
397+
398+ Phase 1: Core State Machine (3-4 hours)
399+ - Create focus_state_machine.go with basic state management
400+ - Implement queue system and request processing
401+ - Add state transition logic and validation
402+
403+ Phase 2: CSS Reconciler (2-3 hours)
404+ - Implement CSS tracking and reconciliation
405+ - Add transaction support for atomic updates
406+ - Create verification loop
407+
408+ Phase 3: GTK Integration (2-3 hours)
409+ - Add GtkEventControllerFocus bindings
410+ - Implement callbacks and signal handling
411+ - Sync with state machine
412+
413+ Phase 4: Testing & Debugging (2-3 hours)
414+ - Add comprehensive logging
415+ - Implement debug commands
416+ - Test all navigation scenarios
417+ - Verify stacked pane behavior
418+
419+ Benefits of This Approach
420+
421+ 1. Guaranteed Single Active Pane: Queue serialization ensures atomic focus changes
422+ 2. Self-Healing: CSS verification loop detects and fixes inconsistencies
423+ 3. Debuggable: Complete history and state inspection available
424+ 4. Predictable: State machine ensures valid transitions only
425+ 5. Performant: Async queue processing doesn't block UI
426+ 6. Extensible: Easy to add new focus sources or validation rules
427+
428+ This architecture will completely eliminate the focus initialization issues and provide a robust
429+ foundation for complex pane management scenarios.
@@ -0,0 +1,429 @@
1+Detailed Implementation Plan: State Machine with Focus Queue
2+
3+ Architecture Overview
4+
5+ The focus management system will be refactored into a centralized state machine that serializes all
6+ focus operations through a queue, ensuring deterministic behavior and eliminating race conditions.
7+
8+ Core Components
9+
10+ 1. Focus State Machine (focus_state_machine.go)
11+
12+ States:
13+ - StateInitializing: System startup, determining initial focus
14+ - StateIdle: No focus transition in progress, system stable
15+ - StateTransitioning: Processing a focus change request
16+ - StateFocused: Focus successfully applied, awaiting next request
17+ - StateReconciling: Fixing inconsistencies detected in CSS/focus state
18+
19+ State Transitions:
20+ Initializing → Idle (on workspace ready)
21+ Idle → Transitioning (on focus request)
22+ Transitioning → Focused (on success)
23+ Transitioning → StateReconciling (on conflict)
24+ Focused → Idle (after settling time)
25+ StateReconciling → Focused (after fix)
26+
27+ Data Structures:
28+ type FocusState string
29+
30+ type FocusRequest struct {
31+ ID string
32+ TargetNode *paneNode
33+ Source FocusSource // Keyboard, Mouse, Programmatic, System
34+ Priority int // Higher priority preempts queue
35+ Timestamp time.Time
36+ Context map[string]interface{} // Additional metadata
37+ }
38+
39+ type FocusStateMachine struct {
40+ mu sync.RWMutex
41+ currentState FocusState
42+ activePane *paneNode
43+ requestQueue chan FocusRequest
44+ historyRing *RingBuffer[FocusTransition]
45+ cssReconciler *CSSReconciler
46+ gtkController *GTKFocusController
47+ validators []FocusValidator
48+ settlingTimer *time.Timer
49+ }
50+
51+ 2. Focus Request Queue System
52+
53+ Queue Management:
54+ - Buffered channel with capacity 100 for focus requests
55+ - Priority queue for urgent focus changes (e.g., user keyboard navigation)
56+ - Request deduplication to prevent redundant operations
57+ - Request coalescing for rapid focus changes
58+
59+ Request Processing:
60+ func (fsm *FocusStateMachine) processQueue() {
61+ for request := range fsm.requestQueue {
62+ // Validate request
63+ if !fsm.validateRequest(request) {
64+ continue
65+ }
66+
67+ // Check for higher priority requests
68+ if fsm.hasHigherPriorityRequest(request) {
69+ fsm.requeue(request)
70+ continue
71+ }
72+
73+ // Execute state transition
74+ fsm.transitionTo(StateTransitioning)
75+ fsm.executeFocusChange(request)
76+ fsm.transitionTo(StateFocused)
77+
78+ // Start settling timer
79+ fsm.startSettlingTimer()
80+ }
81+ }
82+
83+ 3. CSS Reconciliation Engine
84+
85+ Reconciler Responsibilities:
86+ - Track expected CSS state for each pane
87+ - Detect CSS class inconsistencies
88+ - Apply corrective CSS changes atomically
89+ - Verify CSS state after GTK main loop iteration
90+
91+ Implementation:
92+ type CSSReconciler struct {
93+ expectedState map[*paneNode]CSSClasses
94+ mu sync.RWMutex
95+ }
96+
97+ type CSSClasses struct {
98+ Base []string // workspace-pane, workspace-multi-pane
99+ Active bool // workspace-pane-active
100+ Stacked []string // stacked-pane-active, stacked-pane-collapsed
101+ Custom []string // Any additional classes
102+ Timestamp time.Time
103+ }
104+
105+ func (cr *CSSReconciler) reconcile(node *paneNode, expected CSSClasses) error {
106+ actual := cr.getCurrentClasses(node)
107+ if !cr.matches(actual, expected) {
108+ return cr.applyClasses(node, expected)
109+ }
110+ return nil
111+ }
112+
113+ 4. GTK4 Focus Integration
114+
115+ Native Focus Tracking:
116+ // In workspace_widgets_cgo.go
117+ static void focus_enter_cb(GtkEventControllerFocus* controller, gpointer user_data) {
118+ uintptr_t nodePtr = (uintptr_t)user_data;
119+ goFocusEnterCallback(nodePtr);
120+ }
121+
122+ static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_data) {
123+ uintptr_t nodePtr = (uintptr_t)user_data;
124+ goFocusLeaveCallback(nodePtr);
125+ }
126+
127+ static GtkEventController* widget_add_focus_controller(GtkWidget* widget, uintptr_t nodePtr) {
128+ GtkEventController* focus = gtk_event_controller_focus_new();
129+ g_signal_connect(focus, "enter", G_CALLBACK(focus_enter_cb), (gpointer)nodePtr);
130+ g_signal_connect(focus, "leave", G_CALLBACK(focus_leave_cb), (gpointer)nodePtr);
131+ gtk_widget_add_controller(widget, focus);
132+ return focus;
133+ }
134+
135+ 5. Focus History & Debugging
136+
137+ Ring Buffer for History:
138+ type FocusTransition struct {
139+ From *paneNode
140+ To *paneNode
141+ State FocusState
142+ Source FocusSource
143+ Timestamp time.Time
144+ Success bool
145+ Error error
146+ Stack []byte // Stack trace for debugging
147+ }
148+
149+ type RingBuffer[T any] struct {
150+ buffer []T
151+ head int
152+ tail int
153+ size int
154+ mu sync.RWMutex
155+ }
156+
157+ Initialization Sequence
158+
159+ The initialization sequence ensures proper focus state from startup:
160+
161+ func (fsm *FocusStateMachine) Initialize(wm *WorkspaceManager) error {
162+ fsm.transitionTo(StateInitializing)
163+
164+ // Phase 1: Setup GTK controllers for all panes
165+ leaves := wm.collectLeaves()
166+ for _, leaf := range leaves {
167+ fsm.attachGTKController(leaf)
168+ }
169+
170+ // Phase 2: Determine initial focus
171+ initialPane := fsm.determineInitialFocus(leaves)
172+
173+ // Phase 3: Apply initial focus and CSS
174+ fsm.applyInitialFocus(initialPane)
175+
176+ // Phase 4: Start queue processor
177+ go fsm.processQueue()
178+
179+ // Phase 5: Transition to idle
180+ fsm.transitionTo(StateIdle)
181+
182+ return nil
183+ }
184+
185+ func (fsm *FocusStateMachine) determineInitialFocus(leaves []*paneNode) *paneNode {
186+ // Priority order:
187+ // 1. First pane with user content
188+ // 2. Top-left pane (geometrically)
189+ // 3. First pane in tree order
190+
191+ for _, leaf := range leaves {
192+ if leaf.pane != nil && leaf.pane.webView != nil {
193+ if leaf.pane.webView.GetURL() != "about:blank" {
194+ return leaf
195+ }
196+ }
197+ }
198+
199+ // Fall back to geometric selection
200+ return fsm.findTopLeftPane(leaves)
201+ }
202+
203+ Focus Request Flow
204+
205+ graph TD
206+ A[Focus Request] --> B{Validate Request}
207+ B -->|Valid| C[Add to Queue]
208+ B -->|Invalid| D[Log & Reject]
209+ C --> E{Check State}
210+ E -->|Idle| F[Process Immediately]
211+ E -->|Busy| G[Wait in Queue]
212+ F --> H[Transition to Processing]
213+ H --> I[Remove Old Focus]
214+ I --> J[Apply New Focus]
215+ J --> K[Update CSS Classes]
216+ K --> L[Notify Observers]
217+ L --> M[Start Settling Timer]
218+ M --> N[Transition to Focused]
219+ N --> O[After Settling: Idle]
220+
221+ Key Methods
222+
223+ Request Focus Change
224+
225+ func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) error {
226+ request := FocusRequest{
227+ ID: generateRequestID(),
228+ TargetNode: node,
229+ Source: source,
230+ Priority: fsm.calculatePriority(source),
231+ Timestamp: time.Now(),
232+ }
233+
234+ select {
235+ case fsm.requestQueue <- request:
236+ return nil
237+ case <-time.After(100 * time.Millisecond):
238+ return ErrFocusQueueFull
239+ }
240+ }
241+
242+ Execute Focus Change
243+
244+ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
245+ fsm.mu.Lock()
246+ defer fsm.mu.Unlock()
247+
248+ oldPane := fsm.activePane
249+ newPane := request.TargetNode
250+
251+ // Record transition start
252+ transition := FocusTransition{
253+ From: oldPane,
254+ To: newPane,
255+ Source: request.Source,
256+ Timestamp: request.Timestamp,
257+ Stack: debug.Stack(),
258+ }
259+
260+ // Phase 1: Validate target is still valid
261+ if !fsm.isValidTarget(newPane) {
262+ transition.Success = false
263+ transition.Error = ErrInvalidTarget
264+ fsm.recordTransition(transition)
265+ return ErrInvalidTarget
266+ }
267+
268+ // Phase 2: Remove focus from old pane
269+ if oldPane != nil {
270+ fsm.removeFocusFrom(oldPane)
271+ }
272+
273+ // Phase 3: Apply focus to new pane
274+ if err := fsm.applyFocusTo(newPane); err != nil {
275+ // Rollback on failure
276+ if oldPane != nil {
277+ fsm.applyFocusTo(oldPane)
278+ }
279+ transition.Success = false
280+ transition.Error = err
281+ fsm.recordTransition(transition)
282+ return err
283+ }
284+
285+ // Phase 4: Update CSS classes atomically
286+ fsm.cssReconciler.beginTransaction()
287+ if oldPane != nil {
288+ fsm.cssReconciler.removeClass(oldPane, "workspace-pane-active")
289+ }
290+ fsm.cssReconciler.addClass(newPane, "workspace-pane-active")
291+ fsm.cssReconciler.commit()
292+
293+ // Phase 5: Update state
294+ fsm.activePane = newPane
295+ transition.Success = true
296+ fsm.recordTransition(transition)
297+
298+ // Phase 6: Notify observers
299+ fsm.notifyFocusChange(oldPane, newPane)
300+
301+ return nil
302+ }
303+
304+ CSS Verification Loop
305+
306+ func (fsm *FocusStateMachine) startCSSVerificationLoop() {
307+ ticker := time.NewTicker(100 * time.Millisecond)
308+ go func() {
309+ for range ticker.C {
310+ fsm.verifyCSSState()
311+ }
312+ }()
313+ }
314+
315+ func (fsm *FocusStateMachine) verifyCSSState() {
316+ fsm.mu.RLock()
317+ activePane := fsm.activePane
318+ fsm.mu.RUnlock()
319+
320+ if activePane == nil {
321+ return
322+ }
323+
324+ leaves := fsm.wm.collectLeaves()
325+ activeCount := 0
326+
327+ for _, leaf := range leaves {
328+ hasActive := fsm.hasActiveClass(leaf)
329+ shouldHaveActive := (leaf == activePane)
330+
331+ if hasActive && shouldHaveActive {
332+ activeCount++
333+ } else if hasActive && !shouldHaveActive {
334+ // ERROR: Inactive pane has active class
335+ log.Printf("[FSM] CSS ERROR: Inactive pane has active class, fixing...")
336+ fsm.cssReconciler.removeClass(leaf, "workspace-pane-active")
337+ } else if !hasActive && shouldHaveActive {
338+ // ERROR: Active pane missing active class
339+ log.Printf("[FSM] CSS ERROR: Active pane missing active class, fixing...")
340+ fsm.cssReconciler.addClass(leaf, "workspace-pane-active")
341+ }
342+ }
343+
344+ if activeCount > 1 {
345+ log.Fatalf("[FSM] CRITICAL: Multiple active panes detected: %d", activeCount)
346+ }
347+ }
348+
349+ Stacked Pane Integration
350+
351+ For stacked panes, the state machine handles special transitions:
352+
353+ func (fsm *FocusStateMachine) handleStackedFocus(stack *paneNode, index int) error {
354+ if !stack.isStacked || index >= len(stack.stackedPanes) {
355+ return ErrInvalidStackOperation
356+ }
357+
358+ request := FocusRequest{
359+ TargetNode: stack.stackedPanes[index],
360+ Source: SourceStackNavigation,
361+ Priority: PriorityHigh,
362+ Context: map[string]interface{}{
363+ "stack": stack,
364+ "oldIndex": stack.activeStackIndex,
365+ "newIndex": index,
366+ },
367+ }
368+
369+ return fsm.RequestFocus(stack.stackedPanes[index], SourceStackNavigation)
370+ }
371+
372+ Debug Commands
373+
374+ Add debug commands for testing:
375+
376+ func (fsm *FocusStateMachine) DumpState() FocusDebugInfo {
377+ fsm.mu.RLock()
378+ defer fsm.mu.RUnlock()
379+
380+ return FocusDebugInfo{
381+ CurrentState: fsm.currentState,
382+ ActivePane: fsm.activePane,
383+ QueueLength: len(fsm.requestQueue),
384+ History: fsm.historyRing.GetAll(),
385+ CSSState: fsm.cssReconciler.GetState(),
386+ ValidationErrors: fsm.getValidationErrors(),
387+ }
388+ }
389+
390+ func (fsm *FocusStateMachine) ForceReconcile() {
391+ fsm.transitionTo(StateReconciling)
392+ fsm.cssReconciler.forceFullReconciliation()
393+ fsm.transitionTo(StateIdle)
394+ }
395+
396+ Implementation Timeline
397+
398+ Phase 1: Core State Machine (3-4 hours)
399+ - Create focus_state_machine.go with basic state management
400+ - Implement queue system and request processing
401+ - Add state transition logic and validation
402+
403+ Phase 2: CSS Reconciler (2-3 hours)
404+ - Implement CSS tracking and reconciliation
405+ - Add transaction support for atomic updates
406+ - Create verification loop
407+
408+ Phase 3: GTK Integration (2-3 hours)
409+ - Add GtkEventControllerFocus bindings
410+ - Implement callbacks and signal handling
411+ - Sync with state machine
412+
413+ Phase 4: Testing & Debugging (2-3 hours)
414+ - Add comprehensive logging
415+ - Implement debug commands
416+ - Test all navigation scenarios
417+ - Verify stacked pane behavior
418+
419+ Benefits of This Approach
420+
421+ 1. Guaranteed Single Active Pane: Queue serialization ensures atomic focus changes
422+ 2. Self-Healing: CSS verification loop detects and fixes inconsistencies
423+ 3. Debuggable: Complete history and state inspection available
424+ 4. Predictable: State machine ensures valid transitions only
425+ 5. Performant: Async queue processing doesn't block UI
426+ 6. Extensible: Easy to add new focus sources or validation rules
427+
428+ This architecture will completely eliminate the focus initialization issues and provide a robust
429+ foundation for complex pane management scenarios.
D frontend/src/types/generated.ts +0 -83
@@ -1,83 +0,0 @@
1-// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
2-// Auto-generated TypeScript types from Go structs
3-// Generated on: 2025-09-22T12:41:27.395Z
4-// Generator script: scripts/generate-types.js
5-// Source files: internal/db/models.go, internal/parser/types.go
6-// Do not edit manually - run 'node scripts/generate-types.js' to regenerate
7-
8-export interface CertificateValidation {
9- id: number;
10- hostname: string;
11- certificate_hash: string;
12- user_decision: string;
13- created_at: string | null;
14- expires_at: string | null;
15-}
16-
17-export interface History {
18- id: number;
19- url: string;
20- title: string | null;
21- visit_count: number | null;
22- last_visited: string | null;
23- created_at: string | null;
24- favicon_url: string | null;
25-}
26-
27-export interface Shortcut {
28- id: number;
29- shortcut: string;
30- url_template: string;
31- description: string | null;
32- created_at: string | null;
33-}
34-
35-export interface ZoomLevel {
36- domain: string;
37- zoom_factor: number;
38- updated_at: string | null;
39-}
40-
41-export interface ParseResult {
42- type: InputType;
43- url: string;
44- query: string;
45- confidence: number;
46- fuzzy_matches?: FuzzyMatch[];
47- shortcut?: DetectedShortcut;
48- processing_time: number;
49-}
50-
51-export interface DetectedShortcut {
52- key: string;
53- query: string;
54- url: string;
55- description: string;
56-}
57-
58-export interface FuzzyMatch {
59- history_entry?: History;
60- score: number;
61- url_score: number;
62- title_score: number;
63- recency_score: number;
64- visit_score: number;
65- matched_field: string;
66-}
67-
68-export interface FuzzyConfig {
69- min_similarity_threshold: number;
70- max_results: number;
71- url_weight: number;
72- title_weight: number;
73- recency_weight: number;
74- visit_weight: number;
75- recency_decay_days: number;
76-}
77-
78-// Manual types and enums
79-
80-// Type aliases for frontend usage
81-export type HistoryEntry = History;
82-export type SearchShortcut = Shortcut;
83-export type ZoomEntry = ZoomSetting;
@@ -1,83 +0,0 @@
1-// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
2-// Auto-generated TypeScript types from Go structs
3-// Generated on: 2025-09-22T12:41:27.395Z
4-// Generator script: scripts/generate-types.js
5-// Source files: internal/db/models.go, internal/parser/types.go
6-// Do not edit manually - run 'node scripts/generate-types.js' to regenerate
7-
8-export interface CertificateValidation {
9- id: number;
10- hostname: string;
11- certificate_hash: string;
12- user_decision: string;
13- created_at: string | null;
14- expires_at: string | null;
15-}
16-
17-export interface History {
18- id: number;
19- url: string;
20- title: string | null;
21- visit_count: number | null;
22- last_visited: string | null;
23- created_at: string | null;
24- favicon_url: string | null;
25-}
26-
27-export interface Shortcut {
28- id: number;
29- shortcut: string;
30- url_template: string;
31- description: string | null;
32- created_at: string | null;
33-}
34-
35-export interface ZoomLevel {
36- domain: string;
37- zoom_factor: number;
38- updated_at: string | null;
39-}
40-
41-export interface ParseResult {
42- type: InputType;
43- url: string;
44- query: string;
45- confidence: number;
46- fuzzy_matches?: FuzzyMatch[];
47- shortcut?: DetectedShortcut;
48- processing_time: number;
49-}
50-
51-export interface DetectedShortcut {
52- key: string;
53- query: string;
54- url: string;
55- description: string;
56-}
57-
58-export interface FuzzyMatch {
59- history_entry?: History;
60- score: number;
61- url_score: number;
62- title_score: number;
63- recency_score: number;
64- visit_score: number;
65- matched_field: string;
66-}
67-
68-export interface FuzzyConfig {
69- min_similarity_threshold: number;
70- max_results: number;
71- url_weight: number;
72- title_weight: number;
73- recency_weight: number;
74- visit_weight: number;
75- recency_decay_days: number;
76-}
77-
78-// Manual types and enums
79-
80-// Type aliases for frontend usage
81-export type HistoryEntry = History;
82-export type SearchShortcut = Shortcut;
83-export type ZoomEntry = ZoomSetting;
D internal/app/browser/focus_manager.go +0 -372
@@ -1,372 +0,0 @@
1-package browser
2-
3-import (
4- "log"
5-
6- "github.com/bnema/dumber/pkg/webkit"
7-)
8-
9-// FocusManager centralizes all focus/active pane management logic
10-// This is the Single Source of Truth for pane activation
11-type FocusManager struct {
12- wm *WorkspaceManager
13-
14- // Callbacks for side-effects
15- onFocusChanged []FocusChangeCallback
16-}
17-
18-// FocusChangeCallback is called when focus changes between panes
19-type FocusChangeCallback func(oldPane, newPane *paneNode)
20-
21-// NewFocusManager creates a new centralized focus manager
22-func NewFocusManager(wm *WorkspaceManager) *FocusManager {
23- return &FocusManager{
24- wm: wm,
25- onFocusChanged: make([]FocusChangeCallback, 0),
26- }
27-}
28-
29-// AddFocusChangeCallback registers a callback for focus changes
30-func (fm *FocusManager) AddFocusChangeCallback(callback FocusChangeCallback) {
31- fm.onFocusChanged = append(fm.onFocusChanged, callback)
32-}
33-
34-// SetActivePane is the ONLY method to change the active pane
35-// All other methods (focusNode, JS bridges, etc.) should call this
36-func (fm *FocusManager) SetActivePane(node *paneNode) {
37- if fm == nil {
38- log.Printf("[focus-manager] WARNING: SetActivePane called on nil FocusManager")
39- return
40- }
41-
42- if node == nil {
43- log.Printf("[focus-manager] WARNING: SetActivePane called with nil node")
44- return
45- }
46-
47- // Validate node exists in the tree
48- if !fm.isValidNode(node) {
49- log.Printf("[focus-manager] WARNING: SetActivePane called with invalid node %p", node)
50- return
51- }
52-
53- oldPane := fm.getCurrentActivePane()
54-
55- // No-op if already active
56- if oldPane == node {
57- log.Printf("[focus-manager] SetActivePane: %p already active", node)
58- return
59- }
60-
61- log.Printf("[focus-manager] SetActivePane: %p -> %p", oldPane, node)
62-
63- // Step 1: GTK4 Focus (Single Source of Truth)
64- fm.setGTKFocus(node)
65-
66- // Step 2: Update visual indicators
67- fm.updateVisualState(node)
68-
69- // Step 3: Notify JavaScript bridge
70- fm.notifyJavaScript(oldPane, node)
71-
72- // Step 4: Sync app.activePane for window shortcuts
73- if fm.wm != nil && fm.wm.app != nil && node.pane != nil {
74- fm.wm.app.activePane = node.pane
75- log.Printf("[focus-manager] Synced app.activePane to %p", node.pane.webView)
76- }
77-
78- // Step 5: Execute callbacks
79- fm.executeCallbacks(oldPane, node)
80-}
81-
82-// GetActivePane returns the currently active pane
83-// This is derived from GTK4 focus state, not stored separately
84-func (fm *FocusManager) GetActivePane() *paneNode {
85- return fm.getCurrentActivePane()
86-}
87-
88-// ===== INTERNAL METHODS =====
89-
90-// setGTKFocus sets the GTK4 focus on the pane's webview
91-func (fm *FocusManager) setGTKFocus(node *paneNode) {
92- if node.pane == nil || node.pane.webView == nil {
93- log.Printf("[focus-manager] ERROR: Cannot focus node with nil pane/webView")
94- return
95- }
96-
97- // Get the widget handle for the webview
98- viewWidget := node.pane.webView.Widget()
99- if viewWidget == 0 {
100- log.Printf("[focus-manager] ERROR: Cannot focus node with invalid widget")
101- return
102- }
103-
104- // This is the Single Source of Truth for focus
105- webkit.WidgetGrabFocus(viewWidget)
106-}
107-
108-// updateVisualState manages CSS classes and visibility
109-func (fm *FocusManager) updateVisualState(newPane *paneNode) {
110- activePaneClass := "workspace-pane-active"
111-
112- log.Printf("[focus-manager] updateVisualState: setting active pane to %p", newPane)
113-
114- // CRITICAL: For stacked panes, we need to remove active class from ALL panes in ALL stacks
115- // collectLeaves() only returns the currently active pane in each stack, missing the others
116- fm.removeActiveCSSFromAllPanes(activePaneClass)
117-
118- // Add active class to new pane only
119- if newPane != nil && newPane.container != nil {
120- newPane.container.Execute(func(containerPtr uintptr) error {
121- log.Printf("[focus-manager] Adding CSS class '%s' to new active pane: container=%#x", activePaneClass, containerPtr)
122- webkit.WidgetAddCSSClass(containerPtr, activePaneClass)
123- webkit.WidgetQueueDraw(containerPtr)
124- return nil
125- })
126- } else {
127- log.Printf("[focus-manager] WARNING: newPane is nil or has no container, cannot add active CSS class")
128- }
129-
130- // FATAL CHECK: Ensure only ONE pane has active class
131- fm.verifyOnlyOneActivePaneOrPanic()
132-
133- // Handle stacked panes visibility
134- fm.updateStackedPaneVisibility(newPane)
135-}
136-
137-// removeActiveCSSFromAllPanes removes the active CSS class from ALL panes, including stacked panes
138-// This is critical for stacked panes where collectLeaves() only returns the active pane
139-// removeActiveCSSFromAllPanes removes the active CSS class from ALL panes, including stacked panes
140-// This is critical for stacked panes where collectLeaves() only returns the active pane
141-func (fm *FocusManager) removeActiveCSSFromAllPanes(activePaneClass string) {
142- if fm.wm == nil || fm.wm.root == nil {
143- return
144- }
145-
146- log.Printf("[focus-manager] removeActiveCSSFromAllPanes: starting CSS cleanup for class '%s'", activePaneClass)
147-
148- // Walk the entire tree and remove active class from ALL leaf panes
149- // This includes both regular panes and ALL panes within stacks
150- var walkAndRemove func(*paneNode, int)
151- walkAndRemove = func(n *paneNode, depth int) {
152- const maxDepth = 50
153- if n == nil || depth > maxDepth {
154- return
155- }
156-
157- if n.isLeaf {
158- // Regular leaf pane - remove active class
159- if n.container != nil {
160- n.container.Execute(func(containerPtr uintptr) error {
161- log.Printf("[focus-manager] Removing CSS class '%s' from regular pane: container=%#x", activePaneClass, containerPtr)
162- webkit.WidgetRemoveCSSClass(containerPtr, activePaneClass)
163- return nil
164- })
165- }
166- return
167- }
168-
169- if n.isStacked && len(n.stackedPanes) > 0 {
170- // CRITICAL: For stacked panes, remove active class from ALL panes in the stack
171- // not just the currently active one (which is what collectLeaves() returns)
172- log.Printf("[focus-manager] Processing stacked pane: activeIndex=%d stackSize=%d", n.activeStackIndex, len(n.stackedPanes))
173- for i, stackedPane := range n.stackedPanes {
174- if stackedPane != nil && stackedPane.container != nil {
175- stackedPane.container.Execute(func(containerPtr uintptr) error {
176- log.Printf("[focus-manager] Removing CSS class '%s' from stacked pane[%d]: container=%#x", activePaneClass, i, containerPtr)
177- webkit.WidgetRemoveCSSClass(containerPtr, activePaneClass)
178- return nil
179- })
180- }
181- }
182- return
183- }
184-
185- // Handle regular split nodes
186- walkAndRemove(n.left, depth+1)
187- walkAndRemove(n.right, depth+1)
188- }
189-
190- walkAndRemove(fm.wm.root, 0)
191- log.Printf("[focus-manager] removeActiveCSSFromAllPanes: completed CSS cleanup")
192-}
193-
194-// updateStackedPaneVisibility handles special case for stacked panes
195-func (fm *FocusManager) updateStackedPaneVisibility(node *paneNode) {
196- // Find if this node is part of a stack
197- stackNode := fm.findStackContainer(node)
198- if stackNode == nil {
199- return // Not in a stack
200- }
201-
202- // Update stack visibility based on the focused pane
203- activeIndex := -1
204- for i, stackedPane := range stackNode.stackedPanes {
205- if stackedPane == node {
206- activeIndex = i
207- break
208- }
209- }
210-
211- if activeIndex >= 0 {
212- stackNode.activeStackIndex = activeIndex
213- fm.updateStackVisibility(stackNode)
214- }
215-}
216-
217-// updateStackVisibility updates visibility of panes in a stack
218-func (fm *FocusManager) updateStackVisibility(stackNode *paneNode) {
219- activeIndex := stackNode.activeStackIndex
220- if activeIndex < 0 || activeIndex >= len(stackNode.stackedPanes) {
221- return
222- }
223-
224- // Hide all panes in stack
225- for i, pane := range stackNode.stackedPanes {
226- if i != activeIndex {
227- pane.container.Execute(func(containerPtr uintptr) error {
228- webkit.WidgetSetVisible(containerPtr, false)
229- webkit.WidgetRemoveCSSClass(containerPtr, "stacked-pane-active")
230- webkit.WidgetAddCSSClass(containerPtr, "stacked-pane-collapsed")
231- return nil
232- })
233- pane.titleBar.Execute(func(titleBarPtr uintptr) error {
234- webkit.WidgetSetVisible(titleBarPtr, true)
235- return nil
236- })
237- }
238- }
239-
240- // Show active pane
241- activePaneNode := stackNode.stackedPanes[activeIndex]
242- activePaneNode.container.Execute(func(containerPtr uintptr) error {
243- webkit.WidgetSetVisible(containerPtr, true)
244- webkit.WidgetAddCSSClass(containerPtr, "stacked-pane-active")
245- webkit.WidgetRemoveCSSClass(containerPtr, "stacked-pane-collapsed")
246- return nil
247- })
248- activePaneNode.titleBar.Execute(func(titleBarPtr uintptr) error {
249- webkit.WidgetSetVisible(titleBarPtr, false)
250- return nil
251- })
252-}
253-
254-// notifyJavaScript dispatches focus events to the JavaScript bridge
255-func (fm *FocusManager) notifyJavaScript(oldPane, newPane *paneNode) {
256- // Notify old pane it lost focus
257- if oldPane != nil && oldPane.pane != nil && oldPane.pane.webView != nil {
258- // Update WebView internal active state
259- oldPane.pane.webView.SetActive(false)
260-
261- oldDetail := map[string]any{
262- "active": false,
263- "paneId": fm.getPaneID(oldPane),
264- }
265- if err := oldPane.pane.webView.DispatchCustomEvent("dumber:workspace-focus", oldDetail); err != nil {
266- log.Printf("[focus-manager] Failed to dispatch blur event: %v", err)
267- }
268- }
269-
270- // Notify new pane it gained focus
271- if newPane != nil && newPane.pane != nil && newPane.pane.webView != nil {
272- // Update WebView internal active state
273- newPane.pane.webView.SetActive(true)
274-
275- newDetail := map[string]any{
276- "active": true,
277- "paneId": fm.getPaneID(newPane),
278- }
279- if err := newPane.pane.webView.DispatchCustomEvent("dumber:workspace-focus", newDetail); err != nil {
280- log.Printf("[focus-manager] Failed to dispatch focus event: %v", err)
281- }
282- }
283-}
284-
285-// executeCallbacks runs all registered focus change callbacks
286-func (fm *FocusManager) executeCallbacks(oldPane, newPane *paneNode) {
287- for _, callback := range fm.onFocusChanged {
288- callback(oldPane, newPane)
289- }
290-}
291-
292-// ===== HELPER METHODS =====
293-
294-// getCurrentActivePane determines the currently active pane from GTK4 focus
295-func (fm *FocusManager) getCurrentActivePane() *paneNode {
296- // TODO: Implement proper GTK4 focus traversal
297- // For now, we'll track this internally until GTK4 focus callbacks are implemented
298- return fm.wm.currentlyFocused
299-}
300-
301-// isValidNode checks if a node exists in the current tree
302-func (fm *FocusManager) isValidNode(node *paneNode) bool {
303- if fm == nil || fm.wm == nil || node == nil || node.pane == nil || node.pane.webView == nil {
304- return false
305- }
306- return fm.wm.viewToNode[node.pane.webView] == node
307-}
308-
309-// findStackContainer finds the stack container that contains this node
310-func (fm *FocusManager) findStackContainer(node *paneNode) *paneNode {
311- // Check if the immediate parent is a stack container
312- if node != nil && node.parent != nil && node.parent.isStacked {
313- return node.parent
314- }
315- return nil
316-}
317-
318-// getPaneID gets a unique identifier for a pane (for JS bridge)
319-func (fm *FocusManager) getPaneID(node *paneNode) string {
320- if node.pane == nil || node.pane.webView == nil {
321- return ""
322- }
323- // Use the webview ID as a unique identifier
324- return node.pane.webView.ID()
325-}
326-
327-// SetActivePaneByView sets active pane by WebView reference
328-func (fm *FocusManager) SetActivePaneByView(view *webkit.WebView) {
329- if node := fm.wm.viewToNode[view]; node != nil {
330- fm.SetActivePane(node)
331- } else {
332- log.Printf("[focus-manager] WARNING: SetActivePaneByView called with unknown view %p", view)
333- }
334-}
335-
336-// verifyOnlyOneActivePaneOrPanic performs a FATAL check to ensure only one pane is active
337-// This is a fundamental rule that MUST NEVER be violated
338-func (fm *FocusManager) verifyOnlyOneActivePaneOrPanic() {
339- if fm == nil || fm.wm == nil {
340- return
341- }
342-
343- activePaneClass := "workspace-pane-active"
344- activeCount := 0
345- var activePanes []uintptr
346-
347- // Check all leaf panes in the workspace
348- leaves := fm.wm.collectLeaves()
349- for _, leaf := range leaves {
350- if leaf != nil && leaf.container != nil {
351- leaf.container.Execute(func(containerPtr uintptr) error {
352- if webkit.WidgetHasCSSClass(containerPtr, activePaneClass) {
353- activeCount++
354- activePanes = append(activePanes, containerPtr)
355- }
356- return nil
357- })
358- }
359- }
360-
361- // FATAL: Multiple active panes detected
362- if activeCount > 1 {
363- log.Fatalf("[focus-manager] FATAL: Multiple active panes detected! Count=%d, Containers=%v. Only ONE pane can be active at a time. This violates a fundamental rule.", activeCount, activePanes)
364- }
365-
366- // FATAL: No active panes when there should be one
367- if activeCount == 0 && len(leaves) > 0 {
368- log.Fatalf("[focus-manager] FATAL: No active panes detected but %d panes exist. There should always be exactly ONE active pane.", len(leaves))
369- }
370-
371- log.Printf("[focus-manager] ✓ Single active pane rule verified: %d active out of %d total panes", activeCount, len(leaves))
372-}
@@ -1,372 +0,0 @@
1-package browser
2-
3-import (
4- "log"
5-
6- "github.com/bnema/dumber/pkg/webkit"
7-)
8-
9-// FocusManager centralizes all focus/active pane management logic
10-// This is the Single Source of Truth for pane activation
11-type FocusManager struct {
12- wm *WorkspaceManager
13-
14- // Callbacks for side-effects
15- onFocusChanged []FocusChangeCallback
16-}
17-
18-// FocusChangeCallback is called when focus changes between panes
19-type FocusChangeCallback func(oldPane, newPane *paneNode)
20-
21-// NewFocusManager creates a new centralized focus manager
22-func NewFocusManager(wm *WorkspaceManager) *FocusManager {
23- return &FocusManager{
24- wm: wm,
25- onFocusChanged: make([]FocusChangeCallback, 0),
26- }
27-}
28-
29-// AddFocusChangeCallback registers a callback for focus changes
30-func (fm *FocusManager) AddFocusChangeCallback(callback FocusChangeCallback) {
31- fm.onFocusChanged = append(fm.onFocusChanged, callback)
32-}
33-
34-// SetActivePane is the ONLY method to change the active pane
35-// All other methods (focusNode, JS bridges, etc.) should call this
36-func (fm *FocusManager) SetActivePane(node *paneNode) {
37- if fm == nil {
38- log.Printf("[focus-manager] WARNING: SetActivePane called on nil FocusManager")
39- return
40- }
41-
42- if node == nil {
43- log.Printf("[focus-manager] WARNING: SetActivePane called with nil node")
44- return
45- }
46-
47- // Validate node exists in the tree
48- if !fm.isValidNode(node) {
49- log.Printf("[focus-manager] WARNING: SetActivePane called with invalid node %p", node)
50- return
51- }
52-
53- oldPane := fm.getCurrentActivePane()
54-
55- // No-op if already active
56- if oldPane == node {
57- log.Printf("[focus-manager] SetActivePane: %p already active", node)
58- return
59- }
60-
61- log.Printf("[focus-manager] SetActivePane: %p -> %p", oldPane, node)
62-
63- // Step 1: GTK4 Focus (Single Source of Truth)
64- fm.setGTKFocus(node)
65-
66- // Step 2: Update visual indicators
67- fm.updateVisualState(node)
68-
69- // Step 3: Notify JavaScript bridge
70- fm.notifyJavaScript(oldPane, node)
71-
72- // Step 4: Sync app.activePane for window shortcuts
73- if fm.wm != nil && fm.wm.app != nil && node.pane != nil {
74- fm.wm.app.activePane = node.pane
75- log.Printf("[focus-manager] Synced app.activePane to %p", node.pane.webView)
76- }
77-
78- // Step 5: Execute callbacks
79- fm.executeCallbacks(oldPane, node)
80-}
81-
82-// GetActivePane returns the currently active pane
83-// This is derived from GTK4 focus state, not stored separately
84-func (fm *FocusManager) GetActivePane() *paneNode {
85- return fm.getCurrentActivePane()
86-}
87-
88-// ===== INTERNAL METHODS =====
89-
90-// setGTKFocus sets the GTK4 focus on the pane's webview
91-func (fm *FocusManager) setGTKFocus(node *paneNode) {
92- if node.pane == nil || node.pane.webView == nil {
93- log.Printf("[focus-manager] ERROR: Cannot focus node with nil pane/webView")
94- return
95- }
96-
97- // Get the widget handle for the webview
98- viewWidget := node.pane.webView.Widget()
99- if viewWidget == 0 {
100- log.Printf("[focus-manager] ERROR: Cannot focus node with invalid widget")
101- return
102- }
103-
104- // This is the Single Source of Truth for focus
105- webkit.WidgetGrabFocus(viewWidget)
106-}
107-
108-// updateVisualState manages CSS classes and visibility
109-func (fm *FocusManager) updateVisualState(newPane *paneNode) {
110- activePaneClass := "workspace-pane-active"
111-
112- log.Printf("[focus-manager] updateVisualState: setting active pane to %p", newPane)
113-
114- // CRITICAL: For stacked panes, we need to remove active class from ALL panes in ALL stacks
115- // collectLeaves() only returns the currently active pane in each stack, missing the others
116- fm.removeActiveCSSFromAllPanes(activePaneClass)
117-
118- // Add active class to new pane only
119- if newPane != nil && newPane.container != nil {
120- newPane.container.Execute(func(containerPtr uintptr) error {
121- log.Printf("[focus-manager] Adding CSS class '%s' to new active pane: container=%#x", activePaneClass, containerPtr)
122- webkit.WidgetAddCSSClass(containerPtr, activePaneClass)
123- webkit.WidgetQueueDraw(containerPtr)
124- return nil
125- })
126- } else {
127- log.Printf("[focus-manager] WARNING: newPane is nil or has no container, cannot add active CSS class")
128- }
129-
130- // FATAL CHECK: Ensure only ONE pane has active class
131- fm.verifyOnlyOneActivePaneOrPanic()
132-
133- // Handle stacked panes visibility
134- fm.updateStackedPaneVisibility(newPane)
135-}
136-
137-// removeActiveCSSFromAllPanes removes the active CSS class from ALL panes, including stacked panes
138-// This is critical for stacked panes where collectLeaves() only returns the active pane
139-// removeActiveCSSFromAllPanes removes the active CSS class from ALL panes, including stacked panes
140-// This is critical for stacked panes where collectLeaves() only returns the active pane
141-func (fm *FocusManager) removeActiveCSSFromAllPanes(activePaneClass string) {
142- if fm.wm == nil || fm.wm.root == nil {
143- return
144- }
145-
146- log.Printf("[focus-manager] removeActiveCSSFromAllPanes: starting CSS cleanup for class '%s'", activePaneClass)
147-
148- // Walk the entire tree and remove active class from ALL leaf panes
149- // This includes both regular panes and ALL panes within stacks
150- var walkAndRemove func(*paneNode, int)
151- walkAndRemove = func(n *paneNode, depth int) {
152- const maxDepth = 50
153- if n == nil || depth > maxDepth {
154- return
155- }
156-
157- if n.isLeaf {
158- // Regular leaf pane - remove active class
159- if n.container != nil {
160- n.container.Execute(func(containerPtr uintptr) error {
161- log.Printf("[focus-manager] Removing CSS class '%s' from regular pane: container=%#x", activePaneClass, containerPtr)
162- webkit.WidgetRemoveCSSClass(containerPtr, activePaneClass)
163- return nil
164- })
165- }
166- return
167- }
168-
169- if n.isStacked && len(n.stackedPanes) > 0 {
170- // CRITICAL: For stacked panes, remove active class from ALL panes in the stack
171- // not just the currently active one (which is what collectLeaves() returns)
172- log.Printf("[focus-manager] Processing stacked pane: activeIndex=%d stackSize=%d", n.activeStackIndex, len(n.stackedPanes))
173- for i, stackedPane := range n.stackedPanes {
174- if stackedPane != nil && stackedPane.container != nil {
175- stackedPane.container.Execute(func(containerPtr uintptr) error {
176- log.Printf("[focus-manager] Removing CSS class '%s' from stacked pane[%d]: container=%#x", activePaneClass, i, containerPtr)
177- webkit.WidgetRemoveCSSClass(containerPtr, activePaneClass)
178- return nil
179- })
180- }
181- }
182- return
183- }
184-
185- // Handle regular split nodes
186- walkAndRemove(n.left, depth+1)
187- walkAndRemove(n.right, depth+1)
188- }
189-
190- walkAndRemove(fm.wm.root, 0)
191- log.Printf("[focus-manager] removeActiveCSSFromAllPanes: completed CSS cleanup")
192-}
193-
194-// updateStackedPaneVisibility handles special case for stacked panes
195-func (fm *FocusManager) updateStackedPaneVisibility(node *paneNode) {
196- // Find if this node is part of a stack
197- stackNode := fm.findStackContainer(node)
198- if stackNode == nil {
199- return // Not in a stack
200- }
201-
202- // Update stack visibility based on the focused pane
203- activeIndex := -1
204- for i, stackedPane := range stackNode.stackedPanes {
205- if stackedPane == node {
206- activeIndex = i
207- break
208- }
209- }
210-
211- if activeIndex >= 0 {
212- stackNode.activeStackIndex = activeIndex
213- fm.updateStackVisibility(stackNode)
214- }
215-}
216-
217-// updateStackVisibility updates visibility of panes in a stack
218-func (fm *FocusManager) updateStackVisibility(stackNode *paneNode) {
219- activeIndex := stackNode.activeStackIndex
220- if activeIndex < 0 || activeIndex >= len(stackNode.stackedPanes) {
221- return
222- }
223-
224- // Hide all panes in stack
225- for i, pane := range stackNode.stackedPanes {
226- if i != activeIndex {
227- pane.container.Execute(func(containerPtr uintptr) error {
228- webkit.WidgetSetVisible(containerPtr, false)
229- webkit.WidgetRemoveCSSClass(containerPtr, "stacked-pane-active")
230- webkit.WidgetAddCSSClass(containerPtr, "stacked-pane-collapsed")
231- return nil
232- })
233- pane.titleBar.Execute(func(titleBarPtr uintptr) error {
234- webkit.WidgetSetVisible(titleBarPtr, true)
235- return nil
236- })
237- }
238- }
239-
240- // Show active pane
241- activePaneNode := stackNode.stackedPanes[activeIndex]
242- activePaneNode.container.Execute(func(containerPtr uintptr) error {
243- webkit.WidgetSetVisible(containerPtr, true)
244- webkit.WidgetAddCSSClass(containerPtr, "stacked-pane-active")
245- webkit.WidgetRemoveCSSClass(containerPtr, "stacked-pane-collapsed")
246- return nil
247- })
248- activePaneNode.titleBar.Execute(func(titleBarPtr uintptr) error {
249- webkit.WidgetSetVisible(titleBarPtr, false)
250- return nil
251- })
252-}
253-
254-// notifyJavaScript dispatches focus events to the JavaScript bridge
255-func (fm *FocusManager) notifyJavaScript(oldPane, newPane *paneNode) {
256- // Notify old pane it lost focus
257- if oldPane != nil && oldPane.pane != nil && oldPane.pane.webView != nil {
258- // Update WebView internal active state
259- oldPane.pane.webView.SetActive(false)
260-
261- oldDetail := map[string]any{
262- "active": false,
263- "paneId": fm.getPaneID(oldPane),
264- }
265- if err := oldPane.pane.webView.DispatchCustomEvent("dumber:workspace-focus", oldDetail); err != nil {
266- log.Printf("[focus-manager] Failed to dispatch blur event: %v", err)
267- }
268- }
269-
270- // Notify new pane it gained focus
271- if newPane != nil && newPane.pane != nil && newPane.pane.webView != nil {
272- // Update WebView internal active state
273- newPane.pane.webView.SetActive(true)
274-
275- newDetail := map[string]any{
276- "active": true,
277- "paneId": fm.getPaneID(newPane),
278- }
279- if err := newPane.pane.webView.DispatchCustomEvent("dumber:workspace-focus", newDetail); err != nil {
280- log.Printf("[focus-manager] Failed to dispatch focus event: %v", err)
281- }
282- }
283-}
284-
285-// executeCallbacks runs all registered focus change callbacks
286-func (fm *FocusManager) executeCallbacks(oldPane, newPane *paneNode) {
287- for _, callback := range fm.onFocusChanged {
288- callback(oldPane, newPane)
289- }
290-}
291-
292-// ===== HELPER METHODS =====
293-
294-// getCurrentActivePane determines the currently active pane from GTK4 focus
295-func (fm *FocusManager) getCurrentActivePane() *paneNode {
296- // TODO: Implement proper GTK4 focus traversal
297- // For now, we'll track this internally until GTK4 focus callbacks are implemented
298- return fm.wm.currentlyFocused
299-}
300-
301-// isValidNode checks if a node exists in the current tree
302-func (fm *FocusManager) isValidNode(node *paneNode) bool {
303- if fm == nil || fm.wm == nil || node == nil || node.pane == nil || node.pane.webView == nil {
304- return false
305- }
306- return fm.wm.viewToNode[node.pane.webView] == node
307-}
308-
309-// findStackContainer finds the stack container that contains this node
310-func (fm *FocusManager) findStackContainer(node *paneNode) *paneNode {
311- // Check if the immediate parent is a stack container
312- if node != nil && node.parent != nil && node.parent.isStacked {
313- return node.parent
314- }
315- return nil
316-}
317-
318-// getPaneID gets a unique identifier for a pane (for JS bridge)
319-func (fm *FocusManager) getPaneID(node *paneNode) string {
320- if node.pane == nil || node.pane.webView == nil {
321- return ""
322- }
323- // Use the webview ID as a unique identifier
324- return node.pane.webView.ID()
325-}
326-
327-// SetActivePaneByView sets active pane by WebView reference
328-func (fm *FocusManager) SetActivePaneByView(view *webkit.WebView) {
329- if node := fm.wm.viewToNode[view]; node != nil {
330- fm.SetActivePane(node)
331- } else {
332- log.Printf("[focus-manager] WARNING: SetActivePaneByView called with unknown view %p", view)
333- }
334-}
335-
336-// verifyOnlyOneActivePaneOrPanic performs a FATAL check to ensure only one pane is active
337-// This is a fundamental rule that MUST NEVER be violated
338-func (fm *FocusManager) verifyOnlyOneActivePaneOrPanic() {
339- if fm == nil || fm.wm == nil {
340- return
341- }
342-
343- activePaneClass := "workspace-pane-active"
344- activeCount := 0
345- var activePanes []uintptr
346-
347- // Check all leaf panes in the workspace
348- leaves := fm.wm.collectLeaves()
349- for _, leaf := range leaves {
350- if leaf != nil && leaf.container != nil {
351- leaf.container.Execute(func(containerPtr uintptr) error {
352- if webkit.WidgetHasCSSClass(containerPtr, activePaneClass) {
353- activeCount++
354- activePanes = append(activePanes, containerPtr)
355- }
356- return nil
357- })
358- }
359- }
360-
361- // FATAL: Multiple active panes detected
362- if activeCount > 1 {
363- log.Fatalf("[focus-manager] FATAL: Multiple active panes detected! Count=%d, Containers=%v. Only ONE pane can be active at a time. This violates a fundamental rule.", activeCount, activePanes)
364- }
365-
366- // FATAL: No active panes when there should be one
367- if activeCount == 0 && len(leaves) > 0 {
368- log.Fatalf("[focus-manager] FATAL: No active panes detected but %d panes exist. There should always be exactly ONE active pane.", len(leaves))
369- }
370-
371- log.Printf("[focus-manager] ✓ Single active pane rule verified: %d active out of %d total panes", activeCount, len(leaves))
372-}
A internal/app/browser/focus_state_machine.go +1138 -0
@@ -0,0 +1,1138 @@
1+// focus_state_machine.go - Centralized focus state management with queue-based serialization
2+package browser
3+
4+import (
5+ "container/heap"
6+ "fmt"
7+ "log"
8+ "runtime/debug"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/dumber/pkg/webkit"
13+)
14+
15+// FocusState represents the current state of the focus management system
16+type FocusState string
17+
18+const (
19+ StateInitializing FocusState = "initializing" // System startup, determining initial focus
20+ StateIdle FocusState = "idle" // No focus transition in progress, system stable
21+ StateTransitioning FocusState = "transitioning" // Processing a focus change request
22+ StateFocused FocusState = "focused" // Focus successfully applied, awaiting next request
23+ StateReconciling FocusState = "reconciling" // Fixing inconsistencies detected in CSS/focus state
24+)
25+
26+// FocusSource identifies where a focus request originated
27+type FocusSource string
28+
29+const (
30+ SourceKeyboard FocusSource = "keyboard" // User keyboard navigation
31+ SourceMouse FocusSource = "mouse" // Mouse hover/click
32+ SourceProgrammatic FocusSource = "programmatic" // API call
33+ SourceSystem FocusSource = "system" // System initialization
34+ SourceStackNav FocusSource = "stack-nav" // Stack navigation
35+ SourceSplit FocusSource = "split" // Pane split operation
36+ SourceClose FocusSource = "close" // Pane close operation
37+)
38+
39+// Priority levels for focus requests
40+const (
41+ PriorityLow = 10
42+ PriorityNormal = 50
43+ PriorityHigh = 90
44+ PriorityUrgent = 100
45+)
46+
47+// FocusRequest represents a request to change focus to a specific pane
48+type FocusRequest struct {
49+ ID string // Unique request identifier
50+ TargetNode *paneNode // Target pane to focus
51+ Source FocusSource // Where the request originated
52+ Priority int // Request priority (higher = more urgent)
53+ Timestamp time.Time // When the request was created
54+ Context map[string]interface{} // Additional metadata
55+}
56+
57+// FocusTransition records a completed focus change for debugging
58+type FocusTransition struct {
59+ From *paneNode // Previous focus target
60+ To *paneNode // New focus target
61+ State FocusState // State during transition
62+ Source FocusSource // Request source
63+ Timestamp time.Time // When transition occurred
64+ Success bool // Whether transition succeeded
65+ Error error // Error if transition failed
66+ Stack []byte // Stack trace for debugging
67+}
68+
69+// RingBuffer provides a thread-safe circular buffer for focus history
70+type RingBuffer[T any] struct {
71+ buffer []T
72+ head int
73+ tail int
74+ size int
75+ cap int
76+ mu sync.RWMutex
77+}
78+
79+// NewRingBuffer creates a new ring buffer with the specified capacity
80+func NewRingBuffer[T any](capacity int) *RingBuffer[T] {
81+ return &RingBuffer[T]{
82+ buffer: make([]T, capacity),
83+ cap: capacity,
84+ }
85+}
86+
87+// Add inserts an item into the ring buffer
88+func (rb *RingBuffer[T]) Add(item T) {
89+ rb.mu.Lock()
90+ defer rb.mu.Unlock()
91+
92+ rb.buffer[rb.head] = item
93+ rb.head = (rb.head + 1) % rb.cap
94+
95+ if rb.size < rb.cap {
96+ rb.size++
97+ } else {
98+ rb.tail = (rb.tail + 1) % rb.cap
99+ }
100+}
101+
102+// GetAll returns all items in the ring buffer in chronological order
103+func (rb *RingBuffer[T]) GetAll() []T {
104+ rb.mu.RLock()
105+ defer rb.mu.RUnlock()
106+
107+ if rb.size == 0 {
108+ return nil
109+ }
110+
111+ result := make([]T, rb.size)
112+ for i := 0; i < rb.size; i++ {
113+ idx := (rb.tail + i) % rb.cap
114+ result[i] = rb.buffer[idx]
115+ }
116+ return result
117+}
118+
119+// FocusPriorityQueue implements a priority queue for focus requests
120+type FocusPriorityQueue []*FocusRequest
121+
122+func (pq FocusPriorityQueue) Len() int { return len(pq) }
123+
124+func (pq FocusPriorityQueue) Less(i, j int) bool {
125+ // Higher priority value = higher priority (processed first)
126+ return pq[i].Priority > pq[j].Priority
127+}
128+
129+func (pq FocusPriorityQueue) Swap(i, j int) {
130+ pq[i], pq[j] = pq[j], pq[i]
131+}
132+
133+func (pq *FocusPriorityQueue) Push(x interface{}) {
134+ *pq = append(*pq, x.(*FocusRequest))
135+}
136+
137+func (pq *FocusPriorityQueue) Pop() interface{} {
138+ old := *pq
139+ n := len(old)
140+ item := old[n-1]
141+ *pq = old[0 : n-1]
142+ return item
143+}
144+
145+// RequestDeduplicator prevents duplicate requests from flooding the queue
146+type RequestDeduplicator struct {
147+ mu sync.RWMutex
148+ recentSigs map[string]time.Time
149+ ttl time.Duration
150+}
151+
152+// NewRequestDeduplicator creates a new request deduplicator
153+func NewRequestDeduplicator(ttl time.Duration) *RequestDeduplicator {
154+ return &RequestDeduplicator{
155+ recentSigs: make(map[string]time.Time),
156+ ttl: ttl,
157+ }
158+}
159+
160+// IsDuplicate checks if a request is a duplicate within the TTL window
161+func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool {
162+ rd.mu.Lock()
163+ defer rd.mu.Unlock()
164+
165+ // Clean up expired signatures
166+ now := time.Now()
167+ for sig, timestamp := range rd.recentSigs {
168+ if now.Sub(timestamp) > rd.ttl {
169+ delete(rd.recentSigs, sig)
170+ }
171+ }
172+
173+ // Generate signature for this request
174+ sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source)
175+
176+ // Check if this signature exists
177+ if _, exists := rd.recentSigs[sig]; exists {
178+ return true
179+ }
180+
181+ // Record this signature
182+ rd.recentSigs[sig] = now
183+ return false
184+}
185+
186+// FocusStateMachine manages all focus operations through a centralized state machine
187+type FocusStateMachine struct {
188+ mu sync.RWMutex
189+ wm *WorkspaceManager // Reference to workspace manager
190+ currentState FocusState // Current state of the focus system
191+ activePane *paneNode // Currently focused pane
192+ requestQueue chan FocusRequest // Queue for serializing focus requests
193+ priorityQueue *FocusPriorityQueue // Priority queue for request ordering
194+ pendingRequests chan struct{} // Signal channel for pending requests
195+ historyRing *RingBuffer[FocusTransition] // History of focus transitions
196+ validators []FocusValidator // Request validators
197+ settlingTimer *time.Timer // Timer for state settling
198+ queueProcessor *sync.WaitGroup // Wait group for queue processor
199+ shutdownChan chan struct{} // Shutdown signal
200+ requestIDCounter uint64 // Counter for generating request IDs
201+
202+ // Request coalescing for rapid focus changes
203+ lastRequestTime time.Time // Time of last request
204+ coalescingWindow time.Duration // Window for coalescing requests
205+ deduplicator *RequestDeduplicator // Request deduplication
206+
207+ // Performance metrics tracking
208+ metrics FocusMetrics // Performance metrics
209+ processTimes []time.Duration // Recent process times for averaging
210+ maxProcessTimes int // Max number of process times to keep
211+
212+ // Debug configuration
213+ debugEnabled bool // Enable focus debug logging
214+ metricsEnabled bool // Enable metrics tracking
215+ // Reconciliation loop prevention
216+ lastReconcileTime time.Time // Last reconciliation timestamp
217+ reconcileCount int // Recent reconciliation count
218+ maxReconcileAttempts int // Max reconcile attempts per second
219+}
220+
221+// FocusValidator validates focus requests before processing
222+type FocusValidator func(request FocusRequest) error
223+
224+// Predefined errors
225+var (
226+ ErrFocusQueueFull = fmt.Errorf("focus request queue is full")
227+ ErrInvalidTarget = fmt.Errorf("invalid focus target")
228+ ErrInvalidStackOperation = fmt.Errorf("invalid stack operation")
229+ ErrStateMachineShutdown = fmt.Errorf("focus state machine is shutting down")
230+)
231+
232+// NewFocusStateMachine creates a new focus state machine
233+func NewFocusStateMachine(wm *WorkspaceManager) *FocusStateMachine {
234+ pq := &FocusPriorityQueue{}
235+ heap.Init(pq)
236+
237+ fsm := &FocusStateMachine{
238+ wm: wm,
239+ currentState: StateInitializing,
240+ requestQueue: make(chan FocusRequest, 100), // Buffered channel with capacity 100
241+ priorityQueue: pq,
242+ pendingRequests: make(chan struct{}, 1), // Signal for priority queue processing
243+ historyRing: NewRingBuffer[FocusTransition](50), // Keep last 50 transitions
244+ validators: make([]FocusValidator, 0),
245+ queueProcessor: &sync.WaitGroup{},
246+ shutdownChan: make(chan struct{}),
247+ coalescingWindow: 50 * time.Millisecond, // 50ms coalescing window
248+ deduplicator: NewRequestDeduplicator(200 * time.Millisecond), // 200ms dedup TTL
249+ maxProcessTimes: 100, // Keep last 100 process times for averaging
250+ // Debug flags will be set via ConfigureDebug()
251+ debugEnabled: false,
252+ metricsEnabled: false,
253+ // Reconciliation loop prevention
254+ maxReconcileAttempts: 3, // Max 3 reconciliation attempts per second
255+ }
256+
257+ // Add default validators
258+ fsm.addDefaultValidators()
259+
260+ return fsm
261+}
262+
263+// ConfigureDebug configures debug settings for the focus state machine
264+func (fsm *FocusStateMachine) ConfigureDebug(focusDebug, _ bool, metricsEnabled bool) {
265+ fsm.mu.Lock()
266+ defer fsm.mu.Unlock()
267+
268+ fsm.debugEnabled = focusDebug
269+ fsm.metricsEnabled = metricsEnabled
270+
271+ if focusDebug {
272+ log.Printf("[FSM] Focus debug logging enabled")
273+ }
274+ if metricsEnabled {
275+ log.Printf("[FSM] Focus metrics tracking enabled")
276+ }
277+}
278+
279+// Initialize sets up the focus state machine and determines initial focus
280+func (fsm *FocusStateMachine) Initialize() error {
281+ fsm.mu.Lock()
282+ defer fsm.mu.Unlock()
283+
284+ log.Printf("[FSM] Initializing focus state machine")
285+ fsm.currentState = StateInitializing
286+
287+ // Determine initial focus
288+ leaves := fsm.wm.collectLeaves()
289+ if len(leaves) == 0 {
290+ log.Printf("[FSM] No panes found during initialization")
291+ fsm.currentState = StateIdle
292+ return nil
293+ }
294+
295+ initialPane := fsm.determineInitialFocus(leaves)
296+ if initialPane == nil {
297+ log.Printf("[FSM] Could not determine initial focus target")
298+ fsm.currentState = StateIdle
299+ return nil
300+ }
301+
302+ // Phase 3: Apply initial focus and CSS
303+ if err := fsm.applyInitialFocus(initialPane); err != nil {
304+ log.Printf("[FSM] Failed to apply initial focus: %v", err)
305+ fsm.currentState = StateIdle
306+ return err
307+ }
308+
309+ // Phase 4: Attach GTK focus controllers to all panes
310+ fsm.attachGTKControllersToAllPanes()
311+
312+ // Phase 5: Start queue processor
313+ fsm.queueProcessor.Add(1)
314+ go fsm.processQueue()
315+
316+ // Phase 6: Start CSS verification loop
317+ go fsm.startCSSVerificationLoop()
318+
319+ // Phase 7: Transition to idle state
320+ fsm.currentState = StateIdle
321+
322+ log.Printf("[FSM] Focus state machine initialized successfully with pane %p", initialPane)
323+ return nil
324+}
325+
326+// Shutdown gracefully shuts down the focus state machine
327+func (fsm *FocusStateMachine) Shutdown() {
328+ fsm.mu.Lock()
329+ defer fsm.mu.Unlock()
330+
331+ log.Printf("[FSM] Shutting down focus state machine")
332+
333+ // Signal shutdown
334+ close(fsm.shutdownChan)
335+
336+ // Wait for queue processor to finish
337+ fsm.queueProcessor.Wait()
338+
339+ // Clean up timers
340+ if fsm.settlingTimer != nil {
341+ fsm.settlingTimer.Stop()
342+ }
343+
344+ log.Printf("[FSM] Focus state machine shutdown complete")
345+}
346+
347+// RequestFocus queues a focus change request with priority and deduplication
348+func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) error {
349+ if node == nil {
350+ return ErrInvalidTarget
351+ }
352+
353+ request := FocusRequest{
354+ ID: fsm.generateRequestID(),
355+ TargetNode: node,
356+ Source: source,
357+ Priority: fsm.calculatePriority(source),
358+ Timestamp: time.Now(),
359+ Context: make(map[string]interface{}),
360+ }
361+
362+ // Track total requests
363+ if fsm.metricsEnabled {
364+ fsm.mu.Lock()
365+ fsm.metrics.TotalRequests++
366+ fsm.mu.Unlock()
367+ }
368+
369+ // Check for duplicates
370+ if fsm.deduplicator.IsDuplicate(request) {
371+ if fsm.metricsEnabled {
372+ fsm.mu.Lock()
373+ fsm.metrics.DuplicateRequests++
374+ fsm.mu.Unlock()
375+ }
376+ if fsm.debugEnabled {
377+ log.Printf("[FSM] Duplicate request ignored: %s -> %p", request.Source, request.TargetNode)
378+ }
379+ return nil
380+ }
381+
382+ // Check for coalescing opportunity
383+ if fsm.shouldCoalesce(request) {
384+ if fsm.metricsEnabled {
385+ fsm.mu.Lock()
386+ fsm.metrics.CoalescedRequests++
387+ fsm.mu.Unlock()
388+ }
389+ if fsm.debugEnabled {
390+ log.Printf("[FSM] Request coalesced: %s -> %p", request.Source, request.TargetNode)
391+ }
392+ return nil
393+ }
394+
395+ // Add to priority queue instead of simple channel
396+ fsm.mu.Lock()
397+ heap.Push(fsm.priorityQueue, &request)
398+ fsm.lastRequestTime = request.Timestamp
399+
400+ // Track queue depth metrics
401+ currentDepth := fsm.priorityQueue.Len()
402+ if currentDepth > fsm.metrics.MaxQueueDepth {
403+ fsm.metrics.MaxQueueDepth = currentDepth
404+ }
405+ fsm.mu.Unlock()
406+
407+ // Signal that a request is pending
408+ select {
409+ case fsm.pendingRequests <- struct{}{}:
410+ default: // Channel is already signaled
411+ }
412+
413+ return nil
414+}
415+
416+// GetActivePane returns the currently active pane
417+func (fsm *FocusStateMachine) GetActivePane() *paneNode {
418+ fsm.mu.RLock()
419+ defer fsm.mu.RUnlock()
420+ return fsm.activePane
421+}
422+
423+// GetCurrentState returns the current state of the focus system
424+func (fsm *FocusStateMachine) GetCurrentState() FocusState {
425+ fsm.mu.RLock()
426+ defer fsm.mu.RUnlock()
427+ return fsm.currentState
428+}
429+
430+// AddValidator adds a focus request validator
431+func (fsm *FocusStateMachine) AddValidator(validator FocusValidator) {
432+ fsm.mu.Lock()
433+ defer fsm.mu.Unlock()
434+ fsm.validators = append(fsm.validators, validator)
435+}
436+
437+// Private methods
438+
439+// generateRequestID creates a unique request identifier
440+func (fsm *FocusStateMachine) generateRequestID() string {
441+ fsm.requestIDCounter++
442+ return fmt.Sprintf("req-%d-%d", time.Now().UnixNano(), fsm.requestIDCounter)
443+}
444+
445+// calculatePriority determines request priority based on source
446+func (fsm *FocusStateMachine) calculatePriority(source FocusSource) int {
447+ switch source {
448+ case SourceKeyboard:
449+ return PriorityHigh
450+ case SourceMouse:
451+ return PriorityNormal
452+ case SourceStackNav:
453+ return PriorityHigh
454+ case SourceSystem:
455+ return PriorityUrgent
456+ case SourceSplit, SourceClose:
457+ return PriorityHigh
458+ default:
459+ return PriorityLow
460+ }
461+}
462+
463+// addDefaultValidators adds standard focus request validators
464+func (fsm *FocusStateMachine) addDefaultValidators() {
465+ // Validate target node exists and is a leaf
466+ fsm.validators = append(fsm.validators, func(request FocusRequest) error {
467+ if request.TargetNode == nil {
468+ return ErrInvalidTarget
469+ }
470+ if !request.TargetNode.isLeaf {
471+ return fmt.Errorf("target node is not a leaf pane")
472+ }
473+ if request.TargetNode.pane == nil || request.TargetNode.pane.webView == nil {
474+ return fmt.Errorf("target node has no valid webview")
475+ }
476+ return nil
477+ })
478+
479+ // Validate target node still exists in workspace
480+ fsm.validators = append(fsm.validators, func(request FocusRequest) error {
481+ if fsm.wm.viewToNode[request.TargetNode.pane.webView] != request.TargetNode {
482+ return fmt.Errorf("target node no longer exists in workspace")
483+ }
484+ return nil
485+ })
486+}
487+
488+// validateRequest runs all validators on a focus request
489+func (fsm *FocusStateMachine) validateRequest(request FocusRequest) error {
490+ for _, validator := range fsm.validators {
491+ if err := validator(request); err != nil {
492+ return err
493+ }
494+ }
495+ return nil
496+}
497+
498+// determineInitialFocus selects the initial focus target based on priority rules
499+func (fsm *FocusStateMachine) determineInitialFocus(leaves []*paneNode) *paneNode {
500+ if len(leaves) == 0 {
501+ return nil
502+ }
503+
504+ // Priority 1: First pane with user content (not about:blank)
505+ for _, leaf := range leaves {
506+ if leaf.pane != nil && leaf.pane.webView != nil {
507+ url := leaf.pane.webView.GetCurrentURL()
508+ if url != "" && url != "about:blank" {
509+ log.Printf("[FSM] Selected pane with content as initial focus: %s", url)
510+ return leaf
511+ }
512+ }
513+ }
514+
515+ // Priority 2: Top-left pane (geometrically)
516+ if topLeft := fsm.findTopLeftPane(leaves); topLeft != nil {
517+ log.Printf("[FSM] Selected top-left pane as initial focus")
518+ return topLeft
519+ }
520+
521+ // Priority 3: First pane in tree order
522+ log.Printf("[FSM] Selected first pane as initial focus")
523+ return leaves[0]
524+}
525+
526+// findTopLeftPane finds the geometrically top-left pane
527+func (fsm *FocusStateMachine) findTopLeftPane(leaves []*paneNode) *paneNode {
528+ var bestPane *paneNode
529+ var bestScore float64 = 1e9
530+
531+ for _, leaf := range leaves {
532+ if leaf.container == nil {
533+ continue
534+ }
535+
536+ var bounds webkit.WidgetBounds
537+ var ok bool
538+ leaf.container.Execute(func(containerPtr uintptr) error {
539+ bounds, ok = webkit.WidgetGetBounds(containerPtr)
540+ return nil
541+ })
542+
543+ if !ok {
544+ continue
545+ }
546+
547+ // Score based on distance from top-left corner (0,0)
548+ score := bounds.X + bounds.Y
549+ if score < bestScore {
550+ bestScore = score
551+ bestPane = leaf
552+ }
553+ }
554+
555+ return bestPane
556+}
557+
558+// applyInitialFocus applies focus to the initial target without going through the queue
559+func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error {
560+ if node == nil {
561+ return ErrInvalidTarget
562+ }
563+
564+ log.Printf("[FSM] Applying initial focus to pane %p", node)
565+
566+ // Apply GTK focus
567+ if err := fsm.applyGTKFocus(node); err != nil {
568+ return fmt.Errorf("failed to apply GTK focus: %w", err)
569+ }
570+
571+ // Update state
572+ fsm.activePane = node
573+
574+ // Record transition
575+ transition := FocusTransition{
576+ From: nil,
577+ To: node,
578+ State: StateInitializing,
579+ Source: SourceSystem,
580+ Timestamp: time.Now(),
581+ Success: true,
582+ Stack: debug.Stack(),
583+ }
584+ fsm.historyRing.Add(transition)
585+
586+ // Notify workspace manager
587+ if fsm.wm != nil && fsm.wm.app != nil && node.pane != nil {
588+ fsm.wm.app.activePane = node.pane
589+ }
590+
591+ return nil
592+}
593+
594+// applyGTKFocus applies GTK focus to the specified pane
595+func (fsm *FocusStateMachine) applyGTKFocus(node *paneNode) error {
596+ if node.pane == nil || node.pane.webView == nil {
597+ return fmt.Errorf("pane has no valid webview")
598+ }
599+
600+ viewWidget := node.pane.webView.Widget()
601+ if viewWidget == 0 {
602+ return fmt.Errorf("webview has no valid widget")
603+ }
604+
605+ webkit.WidgetGrabFocus(viewWidget)
606+ return nil
607+}
608+
609+// processQueue processes focus requests from the priority queue
610+func (fsm *FocusStateMachine) processQueue() {
611+ defer fsm.queueProcessor.Done()
612+
613+ log.Printf("[FSM] Focus request queue processor started")
614+
615+ for {
616+ select {
617+ case <-fsm.pendingRequests:
618+ // Process all pending requests from priority queue
619+ fsm.processPendingRequests()
620+ case request := <-fsm.requestQueue:
621+ // Legacy channel for backward compatibility
622+ fsm.handleFocusRequest(request)
623+ case <-fsm.shutdownChan:
624+ log.Printf("[FSM] Focus request queue processor shutting down")
625+ return
626+ }
627+ }
628+}
629+
630+// processPendingRequests processes all requests in the priority queue
631+func (fsm *FocusStateMachine) processPendingRequests() {
632+ for {
633+ fsm.mu.Lock()
634+ if fsm.priorityQueue.Len() == 0 {
635+ fsm.mu.Unlock()
636+ break
637+ }
638+
639+ // Get highest priority request
640+ request := heap.Pop(fsm.priorityQueue).(*FocusRequest)
641+ fsm.mu.Unlock()
642+
643+ // Check if a higher priority request has arrived while processing
644+ if fsm.hasHigherPriorityRequest(*request) {
645+ // Requeue current request and process higher priority one
646+ fsm.requeue(*request)
647+ continue
648+ }
649+
650+ fsm.handleFocusRequest(*request)
651+ }
652+}
653+
654+// handleFocusRequest processes a single focus request
655+func (fsm *FocusStateMachine) handleFocusRequest(request FocusRequest) {
656+ startTime := time.Now()
657+
658+ // Validate request
659+ if err := fsm.validateRequest(request); err != nil {
660+ fsm.mu.Lock()
661+ fsm.metrics.FailedRequests++
662+ fsm.mu.Unlock()
663+ log.Printf("[FSM] Invalid focus request %s: %v", request.ID, err)
664+ return
665+ }
666+
667+ // Check if we're already focused on the target
668+ fsm.mu.RLock()
669+ currentActive := fsm.activePane
670+ fsm.mu.RUnlock()
671+
672+ if currentActive == request.TargetNode {
673+ log.Printf("[FSM] Focus request %s: already focused on target", request.ID)
674+ fsm.recordProcessTime(time.Since(startTime))
675+ return
676+ }
677+
678+ log.Printf("[FSM] Processing focus request %s: %s -> %p",
679+ request.ID, request.Source, request.TargetNode)
680+
681+ // Execute focus change
682+ if err := fsm.executeFocusChange(request); err != nil {
683+ fsm.mu.Lock()
684+ fsm.metrics.FailedRequests++
685+ fsm.mu.Unlock()
686+ log.Printf("[FSM] Focus request %s failed: %v", request.ID, err)
687+ } else {
688+ fsm.mu.Lock()
689+ fsm.metrics.SuccessfulRequests++
690+ fsm.mu.Unlock()
691+ }
692+
693+ fsm.recordProcessTime(time.Since(startTime))
694+}
695+
696+// executeFocusChange executes a focus change with proper state management
697+func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
698+ fsm.mu.Lock()
699+ defer fsm.mu.Unlock()
700+
701+ // Transition to processing state
702+ oldState := fsm.currentState
703+ fsm.currentState = StateTransitioning
704+
705+ oldPane := fsm.activePane
706+ newPane := request.TargetNode
707+
708+ // Record transition start
709+ transition := FocusTransition{
710+ From: oldPane,
711+ To: newPane,
712+ Source: request.Source,
713+ Timestamp: request.Timestamp,
714+ Stack: debug.Stack(),
715+ }
716+
717+ // Apply GTK focus
718+ if err := fsm.applyGTKFocus(newPane); err != nil {
719+ // Rollback state
720+ fsm.currentState = oldState
721+ transition.Success = false
722+ transition.Error = err
723+ fsm.historyRing.Add(transition)
724+ return fmt.Errorf("failed to apply GTK focus: %w", err)
725+ }
726+
727+ // Update state
728+ fsm.activePane = newPane
729+ fsm.currentState = StateFocused
730+
731+ // Record successful transition
732+ transition.Success = true
733+ fsm.historyRing.Add(transition)
734+
735+ // Notify workspace manager
736+ if fsm.wm != nil && fsm.wm.app != nil && newPane.pane != nil {
737+ fsm.wm.app.activePane = newPane.pane
738+ }
739+
740+ // Start settling timer
741+ fsm.startSettlingTimer()
742+
743+ log.Printf("[FSM] Focus change completed: %p -> %p", oldPane, newPane)
744+ return nil
745+}
746+
747+// startSettlingTimer starts a timer to transition back to idle state
748+func (fsm *FocusStateMachine) startSettlingTimer() {
749+ if fsm.settlingTimer != nil {
750+ fsm.settlingTimer.Stop()
751+ }
752+
753+ fsm.settlingTimer = time.AfterFunc(50*time.Millisecond, func() {
754+ fsm.mu.Lock()
755+ if fsm.currentState == StateFocused {
756+ fsm.currentState = StateIdle
757+ }
758+ fsm.mu.Unlock()
759+ })
760+}
761+
762+// startCSSVerificationLoop starts the CSS verification background loop
763+func (fsm *FocusStateMachine) startCSSVerificationLoop() {
764+ ticker := time.NewTicker(100 * time.Millisecond)
765+
766+ go func() {
767+ defer ticker.Stop()
768+
769+ for {
770+ select {
771+ case <-ticker.C:
772+ fsm.verifyCSSState()
773+ case <-fsm.shutdownChan:
774+ return
775+ }
776+ }
777+ }()
778+}
779+
780+// verifyCSSState checks and corrects CSS class inconsistencies
781+func (fsm *FocusStateMachine) verifyCSSState() {
782+ fsm.mu.RLock()
783+ activePane := fsm.activePane
784+ fsm.mu.RUnlock()
785+
786+ if activePane == nil || fsm.wm == nil {
787+ return
788+ }
789+
790+ var issues []string
791+
792+ if len(issues) > 0 {
793+ // Prevent infinite reconciliation loops
794+ fsm.mu.Lock()
795+ now := time.Now()
796+ if now.Sub(fsm.lastReconcileTime) < time.Second {
797+ fsm.reconcileCount++
798+ } else {
799+ fsm.reconcileCount = 1
800+ fsm.lastReconcileTime = now
801+ }
802+
803+ if fsm.reconcileCount > fsm.maxReconcileAttempts {
804+ log.Printf("[FSM] WARNING: Reconciliation loop detected, skipping (attempt %d)", fsm.reconcileCount)
805+ fsm.mu.Unlock()
806+ return
807+ }
808+ fsm.mu.Unlock()
809+
810+ log.Printf("[FSM] CSS inconsistencies found: %v", issues)
811+ log.Printf("[FSM] Triggering CSS reconciliation (attempt %d)", fsm.reconcileCount)
812+
813+ // Track reconciliation in metrics
814+ fsm.mu.Lock()
815+ fsm.metrics.ReconciliationCount++
816+ fsm.mu.Unlock()
817+ }
818+}
819+
820+// shouldCoalesce checks if a request should be coalesced with recent requests
821+func (fsm *FocusStateMachine) shouldCoalesce(request FocusRequest) bool {
822+ fsm.mu.RLock()
823+ defer fsm.mu.RUnlock()
824+
825+ // Only coalesce if we have a recent request and it's to the same target
826+ if time.Since(fsm.lastRequestTime) > fsm.coalescingWindow {
827+ return false
828+ }
829+
830+ // Check if there's already a pending request to the same target
831+ for i := 0; i < fsm.priorityQueue.Len(); i++ {
832+ pending := (*fsm.priorityQueue)[i]
833+ if pending.TargetNode == request.TargetNode {
834+ log.Printf("[FSM] Coalescing request to same target: %p", request.TargetNode)
835+ return true
836+ }
837+ }
838+
839+ return false
840+}
841+
842+// hasHigherPriorityRequest checks if there's a higher priority request in the queue
843+func (fsm *FocusStateMachine) hasHigherPriorityRequest(current FocusRequest) bool {
844+ fsm.mu.RLock()
845+ defer fsm.mu.RUnlock()
846+
847+ if fsm.priorityQueue.Len() == 0 {
848+ return false
849+ }
850+
851+ // Check the highest priority request (at top of heap)
852+ highest := (*fsm.priorityQueue)[0]
853+ return highest.Priority > current.Priority
854+}
855+
856+// requeue adds a request back to the priority queue
857+func (fsm *FocusStateMachine) requeue(request FocusRequest) {
858+ fsm.mu.Lock()
859+ defer fsm.mu.Unlock()
860+
861+ heap.Push(fsm.priorityQueue, &request)
862+ log.Printf("[FSM] Requeued request due to higher priority: %s", request.ID)
863+
864+ // Signal that a request is pending
865+ select {
866+ case fsm.pendingRequests <- struct{}{}:
867+ default: // Channel is already signaled
868+ }
869+}
870+
871+// attachGTKController attaches a GTK focus controller to a pane node
872+func (fsm *FocusStateMachine) attachGTKController(node *paneNode) {
873+ if node == nil || node.pane == nil || node.pane.webView == nil {
874+ return
875+ }
876+
877+ widget := node.pane.webView.Widget()
878+ if widget == 0 {
879+ return
880+ }
881+
882+ // Create focus enter/leave callbacks for this specific node
883+ onEnter := func() {
884+ log.Printf("[FSM] GTK focus enter: %p", node)
885+ // Don't automatically change focus on GTK enter - let user interactions drive this
886+ // This prevents infinite loops with our own focus changes
887+ }
888+
889+ onLeave := func() {
890+ log.Printf("[FSM] GTK focus leave: %p", node)
891+ // Similarly, don't react to GTK leave events automatically
892+ }
893+
894+ // Add the focus controller and store the token for cleanup
895+ token := webkit.WidgetAddFocusController(widget, onEnter, onLeave)
896+ if token != 0 {
897+ // Store the controller token for later cleanup (we could add this to paneNode if needed)
898+ log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, token)
899+ }
900+}
901+
902+// detachGTKController removes a GTK focus controller from a pane node
903+func (fsm *FocusStateMachine) detachGTKController(node *paneNode, token uintptr) {
904+ if node == nil || node.pane == nil || node.pane.webView == nil || token == 0 {
905+ return
906+ }
907+
908+ widget := node.pane.webView.Widget()
909+ if widget == 0 {
910+ return
911+ }
912+
913+ webkit.WidgetRemoveFocusController(widget, token)
914+ log.Printf("[FSM] Detached GTK focus controller from pane %p", node)
915+}
916+
917+// attachGTKControllersToAllPanes attaches focus controllers to all existing panes
918+func (fsm *FocusStateMachine) attachGTKControllersToAllPanes() {
919+ if fsm.wm == nil {
920+ return
921+ }
922+
923+ leaves := fsm.wm.collectLeaves()
924+ for _, leaf := range leaves {
925+ fsm.attachGTKController(leaf)
926+ }
927+
928+ log.Printf("[FSM] Attached GTK focus controllers to %d panes", len(leaves))
929+}
930+
931+// FocusDebugInfo contains comprehensive debug information about the focus system
932+type FocusDebugInfo struct {
933+ CurrentState FocusState `json:"current_state"`
934+ ActivePane *paneNode `json:"active_pane,omitempty"`
935+ QueueLength int `json:"queue_length"`
936+ PriorityQueueSize int `json:"priority_queue_size"`
937+ History []FocusTransition `json:"history"`
938+ CSSState map[string]interface{} `json:"css_state"`
939+ ValidationErrors []string `json:"validation_errors"`
940+ Metrics FocusMetrics `json:"metrics"`
941+ RecentRequests []string `json:"recent_requests"`
942+}
943+
944+// FocusMetrics tracks performance metrics for the focus system
945+type FocusMetrics struct {
946+ TotalRequests uint64 `json:"total_requests"`
947+ SuccessfulRequests uint64 `json:"successful_requests"`
948+ FailedRequests uint64 `json:"failed_requests"`
949+ CoalescedRequests uint64 `json:"coalesced_requests"`
950+ DuplicateRequests uint64 `json:"duplicate_requests"`
951+ AverageProcessTime time.Duration `json:"average_process_time"`
952+ MaxQueueDepth int `json:"max_queue_depth"`
953+ ReconciliationCount uint64 `json:"reconciliation_count"`
954+}
955+
956+// DumpState returns comprehensive debug information about the focus system
957+func (fsm *FocusStateMachine) DumpState() FocusDebugInfo {
958+ fsm.mu.RLock()
959+ defer fsm.mu.RUnlock()
960+
961+ info := FocusDebugInfo{
962+ CurrentState: fsm.currentState,
963+ ActivePane: fsm.activePane,
964+ QueueLength: len(fsm.requestQueue),
965+ PriorityQueueSize: fsm.priorityQueue.Len(),
966+ History: fsm.historyRing.GetAll(),
967+ ValidationErrors: fsm.getValidationErrors(),
968+ RecentRequests: fsm.getRecentRequestSignatures(),
969+ Metrics: fsm.GetMetrics(),
970+ }
971+
972+ return info
973+}
974+
975+// ForceValidation manually triggers CSS consistency check and validation
976+func (fsm *FocusStateMachine) ForceValidation() []string {
977+ log.Printf("[FSM] Force validation requested")
978+
979+ // Trigger CSS verification
980+ fsm.verifyCSSState()
981+
982+ // Run all validators on current state
983+ var issues []string
984+
985+ fsm.mu.RLock()
986+ activePane := fsm.activePane
987+ fsm.mu.RUnlock()
988+
989+ if activePane != nil {
990+ dummyRequest := FocusRequest{
991+ ID: "validation-check",
992+ TargetNode: activePane,
993+ Source: SourceSystem,
994+ Priority: PriorityLow,
995+ Timestamp: time.Now(),
996+ }
997+
998+ if err := fsm.validateRequest(dummyRequest); err != nil {
999+ issues = append(issues, fmt.Sprintf("Active pane validation failed: %v", err))
1000+ }
1001+ }
1002+
1003+ log.Printf("[FSM] Force validation completed: %d issues found", len(issues))
1004+ return issues
1005+}
1006+
1007+// GetQueueStatus returns detailed information about pending requests
1008+func (fsm *FocusStateMachine) GetQueueStatus() map[string]interface{} {
1009+ fsm.mu.RLock()
1010+ defer fsm.mu.RUnlock()
1011+
1012+ status := map[string]interface{}{
1013+ "channel_queue_length": len(fsm.requestQueue),
1014+ "priority_queue_length": fsm.priorityQueue.Len(),
1015+ "channel_queue_cap": cap(fsm.requestQueue),
1016+ "processing_state": fsm.currentState,
1017+ "last_request_time": fsm.lastRequestTime,
1018+ "coalescing_window": fsm.coalescingWindow,
1019+ }
1020+
1021+ // Add priority queue details
1022+ if fsm.priorityQueue.Len() > 0 {
1023+ priorities := make([]int, 0, fsm.priorityQueue.Len())
1024+ targets := make([]string, 0, fsm.priorityQueue.Len())
1025+
1026+ for i := 0; i < fsm.priorityQueue.Len(); i++ {
1027+ req := (*fsm.priorityQueue)[i]
1028+ priorities = append(priorities, req.Priority)
1029+ targets = append(targets, fmt.Sprintf("%p", req.TargetNode))
1030+ }
1031+
1032+ status["pending_priorities"] = priorities
1033+ status["pending_targets"] = targets
1034+ }
1035+
1036+ return status
1037+}
1038+
1039+// ClearHistory clears the focus transition history (useful for debugging sessions)
1040+func (fsm *FocusStateMachine) ClearHistory() {
1041+ fsm.mu.Lock()
1042+ defer fsm.mu.Unlock()
1043+
1044+ fsm.historyRing = NewRingBuffer[FocusTransition](50)
1045+ log.Printf("[FSM] Focus transition history cleared")
1046+}
1047+
1048+// GetMetrics returns performance metrics for the focus system
1049+func (fsm *FocusStateMachine) GetMetrics() FocusMetrics {
1050+ fsm.mu.RLock()
1051+ defer fsm.mu.RUnlock()
1052+
1053+ // Copy current metrics with calculated average
1054+ metrics := fsm.metrics
1055+ metrics.AverageProcessTime = fsm.calculateAverageProcessTime()
1056+
1057+ return metrics
1058+}
1059+
1060+// recordProcessTime adds a process time to the metrics and maintains the sliding window
1061+func (fsm *FocusStateMachine) recordProcessTime(duration time.Duration) {
1062+ fsm.mu.Lock()
1063+ defer fsm.mu.Unlock()
1064+
1065+ // Add to the list
1066+ fsm.processTimes = append(fsm.processTimes, duration)
1067+
1068+ // Maintain sliding window
1069+ if len(fsm.processTimes) > fsm.maxProcessTimes {
1070+ fsm.processTimes = fsm.processTimes[1:]
1071+ }
1072+}
1073+
1074+// calculateAverageProcessTime calculates the average of recent process times
1075+func (fsm *FocusStateMachine) calculateAverageProcessTime() time.Duration {
1076+ if len(fsm.processTimes) == 0 {
1077+ return 0
1078+ }
1079+
1080+ var total time.Duration
1081+ for _, t := range fsm.processTimes {
1082+ total += t
1083+ }
1084+
1085+ return total / time.Duration(len(fsm.processTimes))
1086+}
1087+
1088+// getValidationErrors checks for various validation issues
1089+func (fsm *FocusStateMachine) getValidationErrors() []string {
1090+ var errors []string
1091+
1092+ // Check if we have an active pane but it's not valid
1093+ if fsm.activePane != nil {
1094+ if !fsm.activePane.isLeaf {
1095+ errors = append(errors, "active pane is not a leaf node")
1096+ }
1097+ if fsm.activePane.pane == nil || fsm.activePane.pane.webView == nil {
1098+ errors = append(errors, "active pane has no valid webview")
1099+ }
1100+ if fsm.wm != nil && fsm.wm.viewToNode[fsm.activePane.pane.webView] != fsm.activePane {
1101+ errors = append(errors, "active pane not found in workspace view map")
1102+ }
1103+ }
1104+
1105+ // Check for orphaned requests in queue
1106+ if fsm.wm != nil {
1107+ validNodes := make(map[*paneNode]bool)
1108+ for _, node := range fsm.wm.viewToNode {
1109+ validNodes[node] = true
1110+ }
1111+
1112+ for i := 0; i < fsm.priorityQueue.Len(); i++ {
1113+ req := (*fsm.priorityQueue)[i]
1114+ if !validNodes[req.TargetNode] {
1115+ errors = append(errors, fmt.Sprintf("orphaned request for invalid node: %s", req.ID))
1116+ }
1117+ }
1118+ }
1119+
1120+ return errors
1121+}
1122+
1123+// getRecentRequestSignatures returns recent request signatures for debugging
1124+func (fsm *FocusStateMachine) getRecentRequestSignatures() []string {
1125+ if fsm.deduplicator == nil {
1126+ return nil
1127+ }
1128+
1129+ fsm.deduplicator.mu.RLock()
1130+ defer fsm.deduplicator.mu.RUnlock()
1131+
1132+ sigs := make([]string, 0, len(fsm.deduplicator.recentSigs))
1133+ for sig, timestamp := range fsm.deduplicator.recentSigs {
1134+ sigs = append(sigs, fmt.Sprintf("%s (age: %v)", sig, time.Since(timestamp)))
1135+ }
1136+
1137+ return sigs
1138+}
@@ -0,0 +1,1138 @@
1+// focus_state_machine.go - Centralized focus state management with queue-based serialization
2+package browser
3+
4+import (
5+ "container/heap"
6+ "fmt"
7+ "log"
8+ "runtime/debug"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/dumber/pkg/webkit"
13+)
14+
15+// FocusState represents the current state of the focus management system
16+type FocusState string
17+
18+const (
19+ StateInitializing FocusState = "initializing" // System startup, determining initial focus
20+ StateIdle FocusState = "idle" // No focus transition in progress, system stable
21+ StateTransitioning FocusState = "transitioning" // Processing a focus change request
22+ StateFocused FocusState = "focused" // Focus successfully applied, awaiting next request
23+ StateReconciling FocusState = "reconciling" // Fixing inconsistencies detected in CSS/focus state
24+)
25+
26+// FocusSource identifies where a focus request originated
27+type FocusSource string
28+
29+const (
30+ SourceKeyboard FocusSource = "keyboard" // User keyboard navigation
31+ SourceMouse FocusSource = "mouse" // Mouse hover/click
32+ SourceProgrammatic FocusSource = "programmatic" // API call
33+ SourceSystem FocusSource = "system" // System initialization
34+ SourceStackNav FocusSource = "stack-nav" // Stack navigation
35+ SourceSplit FocusSource = "split" // Pane split operation
36+ SourceClose FocusSource = "close" // Pane close operation
37+)
38+
39+// Priority levels for focus requests
40+const (
41+ PriorityLow = 10
42+ PriorityNormal = 50
43+ PriorityHigh = 90
44+ PriorityUrgent = 100
45+)
46+
47+// FocusRequest represents a request to change focus to a specific pane
48+type FocusRequest struct {
49+ ID string // Unique request identifier
50+ TargetNode *paneNode // Target pane to focus
51+ Source FocusSource // Where the request originated
52+ Priority int // Request priority (higher = more urgent)
53+ Timestamp time.Time // When the request was created
54+ Context map[string]interface{} // Additional metadata
55+}
56+
57+// FocusTransition records a completed focus change for debugging
58+type FocusTransition struct {
59+ From *paneNode // Previous focus target
60+ To *paneNode // New focus target
61+ State FocusState // State during transition
62+ Source FocusSource // Request source
63+ Timestamp time.Time // When transition occurred
64+ Success bool // Whether transition succeeded
65+ Error error // Error if transition failed
66+ Stack []byte // Stack trace for debugging
67+}
68+
69+// RingBuffer provides a thread-safe circular buffer for focus history
70+type RingBuffer[T any] struct {
71+ buffer []T
72+ head int
73+ tail int
74+ size int
75+ cap int
76+ mu sync.RWMutex
77+}
78+
79+// NewRingBuffer creates a new ring buffer with the specified capacity
80+func NewRingBuffer[T any](capacity int) *RingBuffer[T] {
81+ return &RingBuffer[T]{
82+ buffer: make([]T, capacity),
83+ cap: capacity,
84+ }
85+}
86+
87+// Add inserts an item into the ring buffer
88+func (rb *RingBuffer[T]) Add(item T) {
89+ rb.mu.Lock()
90+ defer rb.mu.Unlock()
91+
92+ rb.buffer[rb.head] = item
93+ rb.head = (rb.head + 1) % rb.cap
94+
95+ if rb.size < rb.cap {
96+ rb.size++
97+ } else {
98+ rb.tail = (rb.tail + 1) % rb.cap
99+ }
100+}
101+
102+// GetAll returns all items in the ring buffer in chronological order
103+func (rb *RingBuffer[T]) GetAll() []T {
104+ rb.mu.RLock()
105+ defer rb.mu.RUnlock()
106+
107+ if rb.size == 0 {
108+ return nil
109+ }
110+
111+ result := make([]T, rb.size)
112+ for i := 0; i < rb.size; i++ {
113+ idx := (rb.tail + i) % rb.cap
114+ result[i] = rb.buffer[idx]
115+ }
116+ return result
117+}
118+
119+// FocusPriorityQueue implements a priority queue for focus requests
120+type FocusPriorityQueue []*FocusRequest
121+
122+func (pq FocusPriorityQueue) Len() int { return len(pq) }
123+
124+func (pq FocusPriorityQueue) Less(i, j int) bool {
125+ // Higher priority value = higher priority (processed first)
126+ return pq[i].Priority > pq[j].Priority
127+}
128+
129+func (pq FocusPriorityQueue) Swap(i, j int) {
130+ pq[i], pq[j] = pq[j], pq[i]
131+}
132+
133+func (pq *FocusPriorityQueue) Push(x interface{}) {
134+ *pq = append(*pq, x.(*FocusRequest))
135+}
136+
137+func (pq *FocusPriorityQueue) Pop() interface{} {
138+ old := *pq
139+ n := len(old)
140+ item := old[n-1]
141+ *pq = old[0 : n-1]
142+ return item
143+}
144+
145+// RequestDeduplicator prevents duplicate requests from flooding the queue
146+type RequestDeduplicator struct {
147+ mu sync.RWMutex
148+ recentSigs map[string]time.Time
149+ ttl time.Duration
150+}
151+
152+// NewRequestDeduplicator creates a new request deduplicator
153+func NewRequestDeduplicator(ttl time.Duration) *RequestDeduplicator {
154+ return &RequestDeduplicator{
155+ recentSigs: make(map[string]time.Time),
156+ ttl: ttl,
157+ }
158+}
159+
160+// IsDuplicate checks if a request is a duplicate within the TTL window
161+func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool {
162+ rd.mu.Lock()
163+ defer rd.mu.Unlock()
164+
165+ // Clean up expired signatures
166+ now := time.Now()
167+ for sig, timestamp := range rd.recentSigs {
168+ if now.Sub(timestamp) > rd.ttl {
169+ delete(rd.recentSigs, sig)
170+ }
171+ }
172+
173+ // Generate signature for this request
174+ sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source)
175+
176+ // Check if this signature exists
177+ if _, exists := rd.recentSigs[sig]; exists {
178+ return true
179+ }
180+
181+ // Record this signature
182+ rd.recentSigs[sig] = now
183+ return false
184+}
185+
186+// FocusStateMachine manages all focus operations through a centralized state machine
187+type FocusStateMachine struct {
188+ mu sync.RWMutex
189+ wm *WorkspaceManager // Reference to workspace manager
190+ currentState FocusState // Current state of the focus system
191+ activePane *paneNode // Currently focused pane
192+ requestQueue chan FocusRequest // Queue for serializing focus requests
193+ priorityQueue *FocusPriorityQueue // Priority queue for request ordering
194+ pendingRequests chan struct{} // Signal channel for pending requests
195+ historyRing *RingBuffer[FocusTransition] // History of focus transitions
196+ validators []FocusValidator // Request validators
197+ settlingTimer *time.Timer // Timer for state settling
198+ queueProcessor *sync.WaitGroup // Wait group for queue processor
199+ shutdownChan chan struct{} // Shutdown signal
200+ requestIDCounter uint64 // Counter for generating request IDs
201+
202+ // Request coalescing for rapid focus changes
203+ lastRequestTime time.Time // Time of last request
204+ coalescingWindow time.Duration // Window for coalescing requests
205+ deduplicator *RequestDeduplicator // Request deduplication
206+
207+ // Performance metrics tracking
208+ metrics FocusMetrics // Performance metrics
209+ processTimes []time.Duration // Recent process times for averaging
210+ maxProcessTimes int // Max number of process times to keep
211+
212+ // Debug configuration
213+ debugEnabled bool // Enable focus debug logging
214+ metricsEnabled bool // Enable metrics tracking
215+ // Reconciliation loop prevention
216+ lastReconcileTime time.Time // Last reconciliation timestamp
217+ reconcileCount int // Recent reconciliation count
218+ maxReconcileAttempts int // Max reconcile attempts per second
219+}
220+
221+// FocusValidator validates focus requests before processing
222+type FocusValidator func(request FocusRequest) error
223+
224+// Predefined errors
225+var (
226+ ErrFocusQueueFull = fmt.Errorf("focus request queue is full")
227+ ErrInvalidTarget = fmt.Errorf("invalid focus target")
228+ ErrInvalidStackOperation = fmt.Errorf("invalid stack operation")
229+ ErrStateMachineShutdown = fmt.Errorf("focus state machine is shutting down")
230+)
231+
232+// NewFocusStateMachine creates a new focus state machine
233+func NewFocusStateMachine(wm *WorkspaceManager) *FocusStateMachine {
234+ pq := &FocusPriorityQueue{}
235+ heap.Init(pq)
236+
237+ fsm := &FocusStateMachine{
238+ wm: wm,
239+ currentState: StateInitializing,
240+ requestQueue: make(chan FocusRequest, 100), // Buffered channel with capacity 100
241+ priorityQueue: pq,
242+ pendingRequests: make(chan struct{}, 1), // Signal for priority queue processing
243+ historyRing: NewRingBuffer[FocusTransition](50), // Keep last 50 transitions
244+ validators: make([]FocusValidator, 0),
245+ queueProcessor: &sync.WaitGroup{},
246+ shutdownChan: make(chan struct{}),
247+ coalescingWindow: 50 * time.Millisecond, // 50ms coalescing window
248+ deduplicator: NewRequestDeduplicator(200 * time.Millisecond), // 200ms dedup TTL
249+ maxProcessTimes: 100, // Keep last 100 process times for averaging
250+ // Debug flags will be set via ConfigureDebug()
251+ debugEnabled: false,
252+ metricsEnabled: false,
253+ // Reconciliation loop prevention
254+ maxReconcileAttempts: 3, // Max 3 reconciliation attempts per second
255+ }
256+
257+ // Add default validators
258+ fsm.addDefaultValidators()
259+
260+ return fsm
261+}
262+
263+// ConfigureDebug configures debug settings for the focus state machine
264+func (fsm *FocusStateMachine) ConfigureDebug(focusDebug, _ bool, metricsEnabled bool) {
265+ fsm.mu.Lock()
266+ defer fsm.mu.Unlock()
267+
268+ fsm.debugEnabled = focusDebug
269+ fsm.metricsEnabled = metricsEnabled
270+
271+ if focusDebug {
272+ log.Printf("[FSM] Focus debug logging enabled")
273+ }
274+ if metricsEnabled {
275+ log.Printf("[FSM] Focus metrics tracking enabled")
276+ }
277+}
278+
279+// Initialize sets up the focus state machine and determines initial focus
280+func (fsm *FocusStateMachine) Initialize() error {
281+ fsm.mu.Lock()
282+ defer fsm.mu.Unlock()
283+
284+ log.Printf("[FSM] Initializing focus state machine")
285+ fsm.currentState = StateInitializing
286+
287+ // Determine initial focus
288+ leaves := fsm.wm.collectLeaves()
289+ if len(leaves) == 0 {
290+ log.Printf("[FSM] No panes found during initialization")
291+ fsm.currentState = StateIdle
292+ return nil
293+ }
294+
295+ initialPane := fsm.determineInitialFocus(leaves)
296+ if initialPane == nil {
297+ log.Printf("[FSM] Could not determine initial focus target")
298+ fsm.currentState = StateIdle
299+ return nil
300+ }
301+
302+ // Phase 3: Apply initial focus and CSS
303+ if err := fsm.applyInitialFocus(initialPane); err != nil {
304+ log.Printf("[FSM] Failed to apply initial focus: %v", err)
305+ fsm.currentState = StateIdle
306+ return err
307+ }
308+
309+ // Phase 4: Attach GTK focus controllers to all panes
310+ fsm.attachGTKControllersToAllPanes()
311+
312+ // Phase 5: Start queue processor
313+ fsm.queueProcessor.Add(1)
314+ go fsm.processQueue()
315+
316+ // Phase 6: Start CSS verification loop
317+ go fsm.startCSSVerificationLoop()
318+
319+ // Phase 7: Transition to idle state
320+ fsm.currentState = StateIdle
321+
322+ log.Printf("[FSM] Focus state machine initialized successfully with pane %p", initialPane)
323+ return nil
324+}
325+
326+// Shutdown gracefully shuts down the focus state machine
327+func (fsm *FocusStateMachine) Shutdown() {
328+ fsm.mu.Lock()
329+ defer fsm.mu.Unlock()
330+
331+ log.Printf("[FSM] Shutting down focus state machine")
332+
333+ // Signal shutdown
334+ close(fsm.shutdownChan)
335+
336+ // Wait for queue processor to finish
337+ fsm.queueProcessor.Wait()
338+
339+ // Clean up timers
340+ if fsm.settlingTimer != nil {
341+ fsm.settlingTimer.Stop()
342+ }
343+
344+ log.Printf("[FSM] Focus state machine shutdown complete")
345+}
346+
347+// RequestFocus queues a focus change request with priority and deduplication
348+func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) error {
349+ if node == nil {
350+ return ErrInvalidTarget
351+ }
352+
353+ request := FocusRequest{
354+ ID: fsm.generateRequestID(),
355+ TargetNode: node,
356+ Source: source,
357+ Priority: fsm.calculatePriority(source),
358+ Timestamp: time.Now(),
359+ Context: make(map[string]interface{}),
360+ }
361+
362+ // Track total requests
363+ if fsm.metricsEnabled {
364+ fsm.mu.Lock()
365+ fsm.metrics.TotalRequests++
366+ fsm.mu.Unlock()
367+ }
368+
369+ // Check for duplicates
370+ if fsm.deduplicator.IsDuplicate(request) {
371+ if fsm.metricsEnabled {
372+ fsm.mu.Lock()
373+ fsm.metrics.DuplicateRequests++
374+ fsm.mu.Unlock()
375+ }
376+ if fsm.debugEnabled {
377+ log.Printf("[FSM] Duplicate request ignored: %s -> %p", request.Source, request.TargetNode)
378+ }
379+ return nil
380+ }
381+
382+ // Check for coalescing opportunity
383+ if fsm.shouldCoalesce(request) {
384+ if fsm.metricsEnabled {
385+ fsm.mu.Lock()
386+ fsm.metrics.CoalescedRequests++
387+ fsm.mu.Unlock()
388+ }
389+ if fsm.debugEnabled {
390+ log.Printf("[FSM] Request coalesced: %s -> %p", request.Source, request.TargetNode)
391+ }
392+ return nil
393+ }
394+
395+ // Add to priority queue instead of simple channel
396+ fsm.mu.Lock()
397+ heap.Push(fsm.priorityQueue, &request)
398+ fsm.lastRequestTime = request.Timestamp
399+
400+ // Track queue depth metrics
401+ currentDepth := fsm.priorityQueue.Len()
402+ if currentDepth > fsm.metrics.MaxQueueDepth {
403+ fsm.metrics.MaxQueueDepth = currentDepth
404+ }
405+ fsm.mu.Unlock()
406+
407+ // Signal that a request is pending
408+ select {
409+ case fsm.pendingRequests <- struct{}{}:
410+ default: // Channel is already signaled
411+ }
412+
413+ return nil
414+}
415+
416+// GetActivePane returns the currently active pane
417+func (fsm *FocusStateMachine) GetActivePane() *paneNode {
418+ fsm.mu.RLock()
419+ defer fsm.mu.RUnlock()
420+ return fsm.activePane
421+}
422+
423+// GetCurrentState returns the current state of the focus system
424+func (fsm *FocusStateMachine) GetCurrentState() FocusState {
425+ fsm.mu.RLock()
426+ defer fsm.mu.RUnlock()
427+ return fsm.currentState
428+}
429+
430+// AddValidator adds a focus request validator
431+func (fsm *FocusStateMachine) AddValidator(validator FocusValidator) {
432+ fsm.mu.Lock()
433+ defer fsm.mu.Unlock()
434+ fsm.validators = append(fsm.validators, validator)
435+}
436+
437+// Private methods
438+
439+// generateRequestID creates a unique request identifier
440+func (fsm *FocusStateMachine) generateRequestID() string {
441+ fsm.requestIDCounter++
442+ return fmt.Sprintf("req-%d-%d", time.Now().UnixNano(), fsm.requestIDCounter)
443+}
444+
445+// calculatePriority determines request priority based on source
446+func (fsm *FocusStateMachine) calculatePriority(source FocusSource) int {
447+ switch source {
448+ case SourceKeyboard:
449+ return PriorityHigh
450+ case SourceMouse:
451+ return PriorityNormal
452+ case SourceStackNav:
453+ return PriorityHigh
454+ case SourceSystem:
455+ return PriorityUrgent
456+ case SourceSplit, SourceClose:
457+ return PriorityHigh
458+ default:
459+ return PriorityLow
460+ }
461+}
462+
463+// addDefaultValidators adds standard focus request validators
464+func (fsm *FocusStateMachine) addDefaultValidators() {
465+ // Validate target node exists and is a leaf
466+ fsm.validators = append(fsm.validators, func(request FocusRequest) error {
467+ if request.TargetNode == nil {
468+ return ErrInvalidTarget
469+ }
470+ if !request.TargetNode.isLeaf {
471+ return fmt.Errorf("target node is not a leaf pane")
472+ }
473+ if request.TargetNode.pane == nil || request.TargetNode.pane.webView == nil {
474+ return fmt.Errorf("target node has no valid webview")
475+ }
476+ return nil
477+ })
478+
479+ // Validate target node still exists in workspace
480+ fsm.validators = append(fsm.validators, func(request FocusRequest) error {
481+ if fsm.wm.viewToNode[request.TargetNode.pane.webView] != request.TargetNode {
482+ return fmt.Errorf("target node no longer exists in workspace")
483+ }
484+ return nil
485+ })
486+}
487+
488+// validateRequest runs all validators on a focus request
489+func (fsm *FocusStateMachine) validateRequest(request FocusRequest) error {
490+ for _, validator := range fsm.validators {
491+ if err := validator(request); err != nil {
492+ return err
493+ }
494+ }
495+ return nil
496+}
497+
498+// determineInitialFocus selects the initial focus target based on priority rules
499+func (fsm *FocusStateMachine) determineInitialFocus(leaves []*paneNode) *paneNode {
500+ if len(leaves) == 0 {
501+ return nil
502+ }
503+
504+ // Priority 1: First pane with user content (not about:blank)
505+ for _, leaf := range leaves {
506+ if leaf.pane != nil && leaf.pane.webView != nil {
507+ url := leaf.pane.webView.GetCurrentURL()
508+ if url != "" && url != "about:blank" {
509+ log.Printf("[FSM] Selected pane with content as initial focus: %s", url)
510+ return leaf
511+ }
512+ }
513+ }
514+
515+ // Priority 2: Top-left pane (geometrically)
516+ if topLeft := fsm.findTopLeftPane(leaves); topLeft != nil {
517+ log.Printf("[FSM] Selected top-left pane as initial focus")
518+ return topLeft
519+ }
520+
521+ // Priority 3: First pane in tree order
522+ log.Printf("[FSM] Selected first pane as initial focus")
523+ return leaves[0]
524+}
525+
526+// findTopLeftPane finds the geometrically top-left pane
527+func (fsm *FocusStateMachine) findTopLeftPane(leaves []*paneNode) *paneNode {
528+ var bestPane *paneNode
529+ var bestScore float64 = 1e9
530+
531+ for _, leaf := range leaves {
532+ if leaf.container == nil {
533+ continue
534+ }
535+
536+ var bounds webkit.WidgetBounds
537+ var ok bool
538+ leaf.container.Execute(func(containerPtr uintptr) error {
539+ bounds, ok = webkit.WidgetGetBounds(containerPtr)
540+ return nil
541+ })
542+
543+ if !ok {
544+ continue
545+ }
546+
547+ // Score based on distance from top-left corner (0,0)
548+ score := bounds.X + bounds.Y
549+ if score < bestScore {
550+ bestScore = score
551+ bestPane = leaf
552+ }
553+ }
554+
555+ return bestPane
556+}
557+
558+// applyInitialFocus applies focus to the initial target without going through the queue
559+func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error {
560+ if node == nil {
561+ return ErrInvalidTarget
562+ }
563+
564+ log.Printf("[FSM] Applying initial focus to pane %p", node)
565+
566+ // Apply GTK focus
567+ if err := fsm.applyGTKFocus(node); err != nil {
568+ return fmt.Errorf("failed to apply GTK focus: %w", err)
569+ }
570+
571+ // Update state
572+ fsm.activePane = node
573+
574+ // Record transition
575+ transition := FocusTransition{
576+ From: nil,
577+ To: node,
578+ State: StateInitializing,
579+ Source: SourceSystem,
580+ Timestamp: time.Now(),
581+ Success: true,
582+ Stack: debug.Stack(),
583+ }
584+ fsm.historyRing.Add(transition)
585+
586+ // Notify workspace manager
587+ if fsm.wm != nil && fsm.wm.app != nil && node.pane != nil {
588+ fsm.wm.app.activePane = node.pane
589+ }
590+
591+ return nil
592+}
593+
594+// applyGTKFocus applies GTK focus to the specified pane
595+func (fsm *FocusStateMachine) applyGTKFocus(node *paneNode) error {
596+ if node.pane == nil || node.pane.webView == nil {
597+ return fmt.Errorf("pane has no valid webview")
598+ }
599+
600+ viewWidget := node.pane.webView.Widget()
601+ if viewWidget == 0 {
602+ return fmt.Errorf("webview has no valid widget")
603+ }
604+
605+ webkit.WidgetGrabFocus(viewWidget)
606+ return nil
607+}
608+
609+// processQueue processes focus requests from the priority queue
610+func (fsm *FocusStateMachine) processQueue() {
611+ defer fsm.queueProcessor.Done()
612+
613+ log.Printf("[FSM] Focus request queue processor started")
614+
615+ for {
616+ select {
617+ case <-fsm.pendingRequests:
618+ // Process all pending requests from priority queue
619+ fsm.processPendingRequests()
620+ case request := <-fsm.requestQueue:
621+ // Legacy channel for backward compatibility
622+ fsm.handleFocusRequest(request)
623+ case <-fsm.shutdownChan:
624+ log.Printf("[FSM] Focus request queue processor shutting down")
625+ return
626+ }
627+ }
628+}
629+
630+// processPendingRequests processes all requests in the priority queue
631+func (fsm *FocusStateMachine) processPendingRequests() {
632+ for {
633+ fsm.mu.Lock()
634+ if fsm.priorityQueue.Len() == 0 {
635+ fsm.mu.Unlock()
636+ break
637+ }
638+
639+ // Get highest priority request
640+ request := heap.Pop(fsm.priorityQueue).(*FocusRequest)
641+ fsm.mu.Unlock()
642+
643+ // Check if a higher priority request has arrived while processing
644+ if fsm.hasHigherPriorityRequest(*request) {
645+ // Requeue current request and process higher priority one
646+ fsm.requeue(*request)
647+ continue
648+ }
649+
650+ fsm.handleFocusRequest(*request)
651+ }
652+}
653+
654+// handleFocusRequest processes a single focus request
655+func (fsm *FocusStateMachine) handleFocusRequest(request FocusRequest) {
656+ startTime := time.Now()
657+
658+ // Validate request
659+ if err := fsm.validateRequest(request); err != nil {
660+ fsm.mu.Lock()
661+ fsm.metrics.FailedRequests++
662+ fsm.mu.Unlock()
663+ log.Printf("[FSM] Invalid focus request %s: %v", request.ID, err)
664+ return
665+ }
666+
667+ // Check if we're already focused on the target
668+ fsm.mu.RLock()
669+ currentActive := fsm.activePane
670+ fsm.mu.RUnlock()
671+
672+ if currentActive == request.TargetNode {
673+ log.Printf("[FSM] Focus request %s: already focused on target", request.ID)
674+ fsm.recordProcessTime(time.Since(startTime))
675+ return
676+ }
677+
678+ log.Printf("[FSM] Processing focus request %s: %s -> %p",
679+ request.ID, request.Source, request.TargetNode)
680+
681+ // Execute focus change
682+ if err := fsm.executeFocusChange(request); err != nil {
683+ fsm.mu.Lock()
684+ fsm.metrics.FailedRequests++
685+ fsm.mu.Unlock()
686+ log.Printf("[FSM] Focus request %s failed: %v", request.ID, err)
687+ } else {
688+ fsm.mu.Lock()
689+ fsm.metrics.SuccessfulRequests++
690+ fsm.mu.Unlock()
691+ }
692+
693+ fsm.recordProcessTime(time.Since(startTime))
694+}
695+
696+// executeFocusChange executes a focus change with proper state management
697+func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
698+ fsm.mu.Lock()
699+ defer fsm.mu.Unlock()
700+
701+ // Transition to processing state
702+ oldState := fsm.currentState
703+ fsm.currentState = StateTransitioning
704+
705+ oldPane := fsm.activePane
706+ newPane := request.TargetNode
707+
708+ // Record transition start
709+ transition := FocusTransition{
710+ From: oldPane,
711+ To: newPane,
712+ Source: request.Source,
713+ Timestamp: request.Timestamp,
714+ Stack: debug.Stack(),
715+ }
716+
717+ // Apply GTK focus
718+ if err := fsm.applyGTKFocus(newPane); err != nil {
719+ // Rollback state
720+ fsm.currentState = oldState
721+ transition.Success = false
722+ transition.Error = err
723+ fsm.historyRing.Add(transition)
724+ return fmt.Errorf("failed to apply GTK focus: %w", err)
725+ }
726+
727+ // Update state
728+ fsm.activePane = newPane
729+ fsm.currentState = StateFocused
730+
731+ // Record successful transition
732+ transition.Success = true
733+ fsm.historyRing.Add(transition)
734+
735+ // Notify workspace manager
736+ if fsm.wm != nil && fsm.wm.app != nil && newPane.pane != nil {
737+ fsm.wm.app.activePane = newPane.pane
738+ }
739+
740+ // Start settling timer
741+ fsm.startSettlingTimer()
742+
743+ log.Printf("[FSM] Focus change completed: %p -> %p", oldPane, newPane)
744+ return nil
745+}
746+
747+// startSettlingTimer starts a timer to transition back to idle state
748+func (fsm *FocusStateMachine) startSettlingTimer() {
749+ if fsm.settlingTimer != nil {
750+ fsm.settlingTimer.Stop()
751+ }
752+
753+ fsm.settlingTimer = time.AfterFunc(50*time.Millisecond, func() {
754+ fsm.mu.Lock()
755+ if fsm.currentState == StateFocused {
756+ fsm.currentState = StateIdle
757+ }
758+ fsm.mu.Unlock()
759+ })
760+}
761+
762+// startCSSVerificationLoop starts the CSS verification background loop
763+func (fsm *FocusStateMachine) startCSSVerificationLoop() {
764+ ticker := time.NewTicker(100 * time.Millisecond)
765+
766+ go func() {
767+ defer ticker.Stop()
768+
769+ for {
770+ select {
771+ case <-ticker.C:
772+ fsm.verifyCSSState()
773+ case <-fsm.shutdownChan:
774+ return
775+ }
776+ }
777+ }()
778+}
779+
780+// verifyCSSState checks and corrects CSS class inconsistencies
781+func (fsm *FocusStateMachine) verifyCSSState() {
782+ fsm.mu.RLock()
783+ activePane := fsm.activePane
784+ fsm.mu.RUnlock()
785+
786+ if activePane == nil || fsm.wm == nil {
787+ return
788+ }
789+
790+ var issues []string
791+
792+ if len(issues) > 0 {
793+ // Prevent infinite reconciliation loops
794+ fsm.mu.Lock()
795+ now := time.Now()
796+ if now.Sub(fsm.lastReconcileTime) < time.Second {
797+ fsm.reconcileCount++
798+ } else {
799+ fsm.reconcileCount = 1
800+ fsm.lastReconcileTime = now
801+ }
802+
803+ if fsm.reconcileCount > fsm.maxReconcileAttempts {
804+ log.Printf("[FSM] WARNING: Reconciliation loop detected, skipping (attempt %d)", fsm.reconcileCount)
805+ fsm.mu.Unlock()
806+ return
807+ }
808+ fsm.mu.Unlock()
809+
810+ log.Printf("[FSM] CSS inconsistencies found: %v", issues)
811+ log.Printf("[FSM] Triggering CSS reconciliation (attempt %d)", fsm.reconcileCount)
812+
813+ // Track reconciliation in metrics
814+ fsm.mu.Lock()
815+ fsm.metrics.ReconciliationCount++
816+ fsm.mu.Unlock()
817+ }
818+}
819+
820+// shouldCoalesce checks if a request should be coalesced with recent requests
821+func (fsm *FocusStateMachine) shouldCoalesce(request FocusRequest) bool {
822+ fsm.mu.RLock()
823+ defer fsm.mu.RUnlock()
824+
825+ // Only coalesce if we have a recent request and it's to the same target
826+ if time.Since(fsm.lastRequestTime) > fsm.coalescingWindow {
827+ return false
828+ }
829+
830+ // Check if there's already a pending request to the same target
831+ for i := 0; i < fsm.priorityQueue.Len(); i++ {
832+ pending := (*fsm.priorityQueue)[i]
833+ if pending.TargetNode == request.TargetNode {
834+ log.Printf("[FSM] Coalescing request to same target: %p", request.TargetNode)
835+ return true
836+ }
837+ }
838+
839+ return false
840+}
841+
842+// hasHigherPriorityRequest checks if there's a higher priority request in the queue
843+func (fsm *FocusStateMachine) hasHigherPriorityRequest(current FocusRequest) bool {
844+ fsm.mu.RLock()
845+ defer fsm.mu.RUnlock()
846+
847+ if fsm.priorityQueue.Len() == 0 {
848+ return false
849+ }
850+
851+ // Check the highest priority request (at top of heap)
852+ highest := (*fsm.priorityQueue)[0]
853+ return highest.Priority > current.Priority
854+}
855+
856+// requeue adds a request back to the priority queue
857+func (fsm *FocusStateMachine) requeue(request FocusRequest) {
858+ fsm.mu.Lock()
859+ defer fsm.mu.Unlock()
860+
861+ heap.Push(fsm.priorityQueue, &request)
862+ log.Printf("[FSM] Requeued request due to higher priority: %s", request.ID)
863+
864+ // Signal that a request is pending
865+ select {
866+ case fsm.pendingRequests <- struct{}{}:
867+ default: // Channel is already signaled
868+ }
869+}
870+
871+// attachGTKController attaches a GTK focus controller to a pane node
872+func (fsm *FocusStateMachine) attachGTKController(node *paneNode) {
873+ if node == nil || node.pane == nil || node.pane.webView == nil {
874+ return
875+ }
876+
877+ widget := node.pane.webView.Widget()
878+ if widget == 0 {
879+ return
880+ }
881+
882+ // Create focus enter/leave callbacks for this specific node
883+ onEnter := func() {
884+ log.Printf("[FSM] GTK focus enter: %p", node)
885+ // Don't automatically change focus on GTK enter - let user interactions drive this
886+ // This prevents infinite loops with our own focus changes
887+ }
888+
889+ onLeave := func() {
890+ log.Printf("[FSM] GTK focus leave: %p", node)
891+ // Similarly, don't react to GTK leave events automatically
892+ }
893+
894+ // Add the focus controller and store the token for cleanup
895+ token := webkit.WidgetAddFocusController(widget, onEnter, onLeave)
896+ if token != 0 {
897+ // Store the controller token for later cleanup (we could add this to paneNode if needed)
898+ log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, token)
899+ }
900+}
901+
902+// detachGTKController removes a GTK focus controller from a pane node
903+func (fsm *FocusStateMachine) detachGTKController(node *paneNode, token uintptr) {
904+ if node == nil || node.pane == nil || node.pane.webView == nil || token == 0 {
905+ return
906+ }
907+
908+ widget := node.pane.webView.Widget()
909+ if widget == 0 {
910+ return
911+ }
912+
913+ webkit.WidgetRemoveFocusController(widget, token)
914+ log.Printf("[FSM] Detached GTK focus controller from pane %p", node)
915+}
916+
917+// attachGTKControllersToAllPanes attaches focus controllers to all existing panes
918+func (fsm *FocusStateMachine) attachGTKControllersToAllPanes() {
919+ if fsm.wm == nil {
920+ return
921+ }
922+
923+ leaves := fsm.wm.collectLeaves()
924+ for _, leaf := range leaves {
925+ fsm.attachGTKController(leaf)
926+ }
927+
928+ log.Printf("[FSM] Attached GTK focus controllers to %d panes", len(leaves))
929+}
930+
931+// FocusDebugInfo contains comprehensive debug information about the focus system
932+type FocusDebugInfo struct {
933+ CurrentState FocusState `json:"current_state"`
934+ ActivePane *paneNode `json:"active_pane,omitempty"`
935+ QueueLength int `json:"queue_length"`
936+ PriorityQueueSize int `json:"priority_queue_size"`
937+ History []FocusTransition `json:"history"`
938+ CSSState map[string]interface{} `json:"css_state"`
939+ ValidationErrors []string `json:"validation_errors"`
940+ Metrics FocusMetrics `json:"metrics"`
941+ RecentRequests []string `json:"recent_requests"`
942+}
943+
944+// FocusMetrics tracks performance metrics for the focus system
945+type FocusMetrics struct {
946+ TotalRequests uint64 `json:"total_requests"`
947+ SuccessfulRequests uint64 `json:"successful_requests"`
948+ FailedRequests uint64 `json:"failed_requests"`
949+ CoalescedRequests uint64 `json:"coalesced_requests"`
950+ DuplicateRequests uint64 `json:"duplicate_requests"`
951+ AverageProcessTime time.Duration `json:"average_process_time"`
952+ MaxQueueDepth int `json:"max_queue_depth"`
953+ ReconciliationCount uint64 `json:"reconciliation_count"`
954+}
955+
956+// DumpState returns comprehensive debug information about the focus system
957+func (fsm *FocusStateMachine) DumpState() FocusDebugInfo {
958+ fsm.mu.RLock()
959+ defer fsm.mu.RUnlock()
960+
961+ info := FocusDebugInfo{
962+ CurrentState: fsm.currentState,
963+ ActivePane: fsm.activePane,
964+ QueueLength: len(fsm.requestQueue),
965+ PriorityQueueSize: fsm.priorityQueue.Len(),
966+ History: fsm.historyRing.GetAll(),
967+ ValidationErrors: fsm.getValidationErrors(),
968+ RecentRequests: fsm.getRecentRequestSignatures(),
969+ Metrics: fsm.GetMetrics(),
970+ }
971+
972+ return info
973+}
974+
975+// ForceValidation manually triggers CSS consistency check and validation
976+func (fsm *FocusStateMachine) ForceValidation() []string {
977+ log.Printf("[FSM] Force validation requested")
978+
979+ // Trigger CSS verification
980+ fsm.verifyCSSState()
981+
982+ // Run all validators on current state
983+ var issues []string
984+
985+ fsm.mu.RLock()
986+ activePane := fsm.activePane
987+ fsm.mu.RUnlock()
988+
989+ if activePane != nil {
990+ dummyRequest := FocusRequest{
991+ ID: "validation-check",
992+ TargetNode: activePane,
993+ Source: SourceSystem,
994+ Priority: PriorityLow,
995+ Timestamp: time.Now(),
996+ }
997+
998+ if err := fsm.validateRequest(dummyRequest); err != nil {
999+ issues = append(issues, fmt.Sprintf("Active pane validation failed: %v", err))
1000+ }
1001+ }
1002+
1003+ log.Printf("[FSM] Force validation completed: %d issues found", len(issues))
1004+ return issues
1005+}
1006+
1007+// GetQueueStatus returns detailed information about pending requests
1008+func (fsm *FocusStateMachine) GetQueueStatus() map[string]interface{} {
1009+ fsm.mu.RLock()
1010+ defer fsm.mu.RUnlock()
1011+
1012+ status := map[string]interface{}{
1013+ "channel_queue_length": len(fsm.requestQueue),
1014+ "priority_queue_length": fsm.priorityQueue.Len(),
1015+ "channel_queue_cap": cap(fsm.requestQueue),
1016+ "processing_state": fsm.currentState,
1017+ "last_request_time": fsm.lastRequestTime,
1018+ "coalescing_window": fsm.coalescingWindow,
1019+ }
1020+
1021+ // Add priority queue details
1022+ if fsm.priorityQueue.Len() > 0 {
1023+ priorities := make([]int, 0, fsm.priorityQueue.Len())
1024+ targets := make([]string, 0, fsm.priorityQueue.Len())
1025+
1026+ for i := 0; i < fsm.priorityQueue.Len(); i++ {
1027+ req := (*fsm.priorityQueue)[i]
1028+ priorities = append(priorities, req.Priority)
1029+ targets = append(targets, fmt.Sprintf("%p", req.TargetNode))
1030+ }
1031+
1032+ status["pending_priorities"] = priorities
1033+ status["pending_targets"] = targets
1034+ }
1035+
1036+ return status
1037+}
1038+
1039+// ClearHistory clears the focus transition history (useful for debugging sessions)
1040+func (fsm *FocusStateMachine) ClearHistory() {
1041+ fsm.mu.Lock()
1042+ defer fsm.mu.Unlock()
1043+
1044+ fsm.historyRing = NewRingBuffer[FocusTransition](50)
1045+ log.Printf("[FSM] Focus transition history cleared")
1046+}
1047+
1048+// GetMetrics returns performance metrics for the focus system
1049+func (fsm *FocusStateMachine) GetMetrics() FocusMetrics {
1050+ fsm.mu.RLock()
1051+ defer fsm.mu.RUnlock()
1052+
1053+ // Copy current metrics with calculated average
1054+ metrics := fsm.metrics
1055+ metrics.AverageProcessTime = fsm.calculateAverageProcessTime()
1056+
1057+ return metrics
1058+}
1059+
1060+// recordProcessTime adds a process time to the metrics and maintains the sliding window
1061+func (fsm *FocusStateMachine) recordProcessTime(duration time.Duration) {
1062+ fsm.mu.Lock()
1063+ defer fsm.mu.Unlock()
1064+
1065+ // Add to the list
1066+ fsm.processTimes = append(fsm.processTimes, duration)
1067+
1068+ // Maintain sliding window
1069+ if len(fsm.processTimes) > fsm.maxProcessTimes {
1070+ fsm.processTimes = fsm.processTimes[1:]
1071+ }
1072+}
1073+
1074+// calculateAverageProcessTime calculates the average of recent process times
1075+func (fsm *FocusStateMachine) calculateAverageProcessTime() time.Duration {
1076+ if len(fsm.processTimes) == 0 {
1077+ return 0
1078+ }
1079+
1080+ var total time.Duration
1081+ for _, t := range fsm.processTimes {
1082+ total += t
1083+ }
1084+
1085+ return total / time.Duration(len(fsm.processTimes))
1086+}
1087+
1088+// getValidationErrors checks for various validation issues
1089+func (fsm *FocusStateMachine) getValidationErrors() []string {
1090+ var errors []string
1091+
1092+ // Check if we have an active pane but it's not valid
1093+ if fsm.activePane != nil {
1094+ if !fsm.activePane.isLeaf {
1095+ errors = append(errors, "active pane is not a leaf node")
1096+ }
1097+ if fsm.activePane.pane == nil || fsm.activePane.pane.webView == nil {
1098+ errors = append(errors, "active pane has no valid webview")
1099+ }
1100+ if fsm.wm != nil && fsm.wm.viewToNode[fsm.activePane.pane.webView] != fsm.activePane {
1101+ errors = append(errors, "active pane not found in workspace view map")
1102+ }
1103+ }
1104+
1105+ // Check for orphaned requests in queue
1106+ if fsm.wm != nil {
1107+ validNodes := make(map[*paneNode]bool)
1108+ for _, node := range fsm.wm.viewToNode {
1109+ validNodes[node] = true
1110+ }
1111+
1112+ for i := 0; i < fsm.priorityQueue.Len(); i++ {
1113+ req := (*fsm.priorityQueue)[i]
1114+ if !validNodes[req.TargetNode] {
1115+ errors = append(errors, fmt.Sprintf("orphaned request for invalid node: %s", req.ID))
1116+ }
1117+ }
1118+ }
1119+
1120+ return errors
1121+}
1122+
1123+// getRecentRequestSignatures returns recent request signatures for debugging
1124+func (fsm *FocusStateMachine) getRecentRequestSignatures() []string {
1125+ if fsm.deduplicator == nil {
1126+ return nil
1127+ }
1128+
1129+ fsm.deduplicator.mu.RLock()
1130+ defer fsm.deduplicator.mu.RUnlock()
1131+
1132+ sigs := make([]string, 0, len(fsm.deduplicator.recentSigs))
1133+ for sig, timestamp := range fsm.deduplicator.recentSigs {
1134+ sigs = append(sigs, fmt.Sprintf("%s (age: %v)", sig, time.Since(timestamp)))
1135+ }
1136+
1137+ return sigs
1138+}
M internal/app/browser/shortcuts.go +2 -2
@@ -44,7 +44,7 @@ func (s *ShortcutHandler) isActivePane() bool {
4444
4545 // For related popups, they're active if parent chain contains the active node
4646 if node.isRelated && node.parentPane != nil {
47- activeNode := s.app.workspace.currentlyFocused
47+ activeNode := s.app.workspace.GetActiveNode()
4848 cur := node
4949 for cur != nil {
5050 if cur == activeNode {
@@ -56,7 +56,7 @@ func (s *ShortcutHandler) isActivePane() bool {
5656 }
5757
5858 // Independent panes: equal to active node
59- return node == s.app.workspace.currentlyFocused
59+ return node == s.app.workspace.GetActiveNode()
6060 }
6161
6262 // RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards
@@ -44,7 +44,7 @@ func (s *ShortcutHandler) isActivePane() bool {
44 44
45 // For related popups, they're active if parent chain contains the active node45 // For related popups, they're active if parent chain contains the active node
46 if node.isRelated && node.parentPane != nil {46 if node.isRelated && node.parentPane != nil {
47- activeNode := s.app.workspace.currentlyFocused47+ activeNode := s.app.workspace.GetActiveNode()
48 cur := node48 cur := node
49 for cur != nil {49 for cur != nil {
50 if cur == activeNode {50 if cur == activeNode {
@@ -56,7 +56,7 @@ func (s *ShortcutHandler) isActivePane() bool {
56 }56 }
57 57
58 // Independent panes: equal to active node58 // Independent panes: equal to active node
59- return node == s.app.workspace.currentlyFocused59+ return node == s.app.workspace.GetActiveNode()
60 }60 }
61 61
62 // RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards62 // RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards
M internal/app/browser/stacked_panes.go +12 -29
@@ -338,9 +338,6 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod
338338 newLeaf.pane.zoomController.ApplyInitialZoom()
339339 }
340340
341- // Update CSS classes
342- spm.wm.ensurePaneBaseClasses()
343-
344341 // Mark stack operation timestamp to prevent focus conflicts
345342 spm.wm.lastStackOperation = time.Now()
346343
@@ -362,8 +359,7 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod
362359 spm.UpdateStackVisibility(stackNode)
363360
364361 // Set focus on the new pane synchronously
365- spm.wm.focusManager.SetActivePane(newLeaf)
366- spm.wm.currentlyFocused = newLeaf
362+ spm.wm.SetActivePane(newLeaf, SourceSplit)
367363
368364 log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",
369365 stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex)
@@ -404,8 +400,6 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
404400 // Active pane: show container, NEVER show title bar
405401 if pane.container != nil {
406402 webkit.WidgetSetVisible(pane.container.Ptr(), true)
407- webkit.WidgetAddCSSClass(pane.container.Ptr(), "stacked-pane-active")
408- webkit.WidgetRemoveCSSClass(pane.container.Ptr(), "stacked-pane-collapsed")
409403 }
410404 if pane.titleBar != nil {
411405 webkit.WidgetSetVisible(pane.titleBar.Ptr(), false) // ABSOLUTE RULE: never visible for active pane
@@ -415,27 +409,23 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
415409 // Inactive panes: hide container, show title bar
416410 if pane.container != nil {
417411 webkit.WidgetSetVisible(pane.container.Ptr(), false)
418- webkit.WidgetAddCSSClass(pane.container.Ptr(), "stacked-pane-collapsed")
419412 }
420413 if pane.titleBar != nil {
421414 webkit.WidgetSetVisible(pane.titleBar.Ptr(), true)
422415 }
423- if pane.container != nil {
424- webkit.WidgetRemoveCSSClass(pane.container.Ptr(), "stacked-pane-active")
425- }
426416 }
427417 }
428418 }
429419
430420 // NavigateStack handles navigation within a stacked pane container
431421 func (spm *StackedPaneManager) NavigateStack(direction string) bool {
432- if spm.wm.currentlyFocused == nil {
422+ if spm.wm.GetActiveNode() == nil {
433423 return false
434424 }
435425
436426 // Find the stack container this pane belongs to
437427 var stackNode *paneNode
438- current := spm.wm.currentlyFocused
428+ current := spm.wm.GetActiveNode()
439429
440430 // Check if current pane is directly in a stack
441431 if current.parent != nil && current.parent.isStacked {
@@ -498,8 +488,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
498488
499489 // Focus the new active pane
500490 newActivePane := stackNode.stackedPanes[newIndex]
501- spm.wm.focusManager.SetActivePane(newActivePane)
502- spm.wm.currentlyFocused = newActivePane
491+ spm.wm.SetActivePane(newActivePane, SourceStackNav)
503492
504493 log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
505494 direction, currentIndex, newIndex, len(stackNode.stackedPanes))
@@ -787,14 +776,15 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
787776 // FIXED: Focus the remaining pane if any pane from this stack was currently focused
788777 // Check if currentlyFocused is part of this stack (including the pane being closed)
789778 shouldFocus := false
790- if spm.wm.currentlyFocused != nil {
779+ currentlyFocused := spm.wm.GetActiveNode()
780+ if currentlyFocused != nil {
791781 log.Printf("[workspace] DEBUG: currentlyFocused=%p, node being closed=%p, lastPane=%p",
792- spm.wm.currentlyFocused, node, lastPane)
782+ currentlyFocused, node, lastPane)
793783 // Check if currentlyFocused is the pane being closed
794- if spm.wm.currentlyFocused == node {
784+ if currentlyFocused == node {
795785 shouldFocus = true
796786 log.Printf("[workspace] DEBUG: shouldFocus=true (closing focused pane)")
797- } else if spm.wm.currentlyFocused.parent == stackNode {
787+ } else if currentlyFocused.parent == stackNode {
798788 // Check if currentlyFocused is another pane in this stack
799789 shouldFocus = true
800790 log.Printf("[workspace] DEBUG: shouldFocus=true (focused pane in same stack)")
@@ -811,14 +801,10 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
811801 lastPaneWidget, visible)
812802 }
813803
814- // Update CSS classes (mirror finalizeStackCreation)
815- spm.wm.ensurePaneBaseClasses()
816-
817804 // Mirror the exact logic from finalizeStackCreation that works perfectly:
818805 // 1. First call SetActivePane (this does all the focus work)
819806 // 2. Then set currentlyFocused
820- spm.wm.focusManager.SetActivePane(lastPane)
821- spm.wm.currentlyFocused = lastPane
807+ spm.wm.SetActivePane(lastPane, SourceClose)
822808
823809 // CRITICAL: Ensure widget visibility and focus after reparenting
824810 // The widget may need explicit show/focus calls after being reparented
@@ -852,18 +838,15 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
852838 spm.UpdateStackVisibility(stackNode)
853839
854840 // Focus the new active pane if we closed the currently active one
855- if spm.wm.currentlyFocused == node {
841+ if spm.wm.GetActiveNode() == node {
856842 newActivePaneInStack := stackNode.stackedPanes[stackNode.activeStackIndex]
857- spm.wm.currentlyFocused = newActivePaneInStack
858- spm.wm.focusManager.SetActivePane(newActivePaneInStack)
843+ spm.wm.SetActivePane(newActivePaneInStack, SourceClose)
859844 }
860845
861846 log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
862847 len(stackNode.stackedPanes), stackNode.activeStackIndex)
863848 }
864849
865- // Update CSS classes after pane count changes
866- spm.wm.ensurePaneBaseClasses()
867850 log.Printf("[workspace] stacked pane closed; panes remaining=%d", len(spm.wm.app.panes))
868851
869852 return nil
@@ -338,9 +338,6 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod
338 newLeaf.pane.zoomController.ApplyInitialZoom()338 newLeaf.pane.zoomController.ApplyInitialZoom()
339 }339 }
340 340
341- // Update CSS classes
342- spm.wm.ensurePaneBaseClasses()
343-
344 // Mark stack operation timestamp to prevent focus conflicts341 // Mark stack operation timestamp to prevent focus conflicts
345 spm.wm.lastStackOperation = time.Now()342 spm.wm.lastStackOperation = time.Now()
346 343
@@ -362,8 +359,7 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod
362 spm.UpdateStackVisibility(stackNode)359 spm.UpdateStackVisibility(stackNode)
363 360
364 // Set focus on the new pane synchronously361 // Set focus on the new pane synchronously
365- spm.wm.focusManager.SetActivePane(newLeaf)362+ spm.wm.SetActivePane(newLeaf, SourceSplit)
366- spm.wm.currentlyFocused = newLeaf
367 363
368 log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",364 log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",
369 stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex)365 stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex)
@@ -404,8 +400,6 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
404 // Active pane: show container, NEVER show title bar400 // Active pane: show container, NEVER show title bar
405 if pane.container != nil {401 if pane.container != nil {
406 webkit.WidgetSetVisible(pane.container.Ptr(), true)402 webkit.WidgetSetVisible(pane.container.Ptr(), true)
407- webkit.WidgetAddCSSClass(pane.container.Ptr(), "stacked-pane-active")
408- webkit.WidgetRemoveCSSClass(pane.container.Ptr(), "stacked-pane-collapsed")
409 }403 }
410 if pane.titleBar != nil {404 if pane.titleBar != nil {
411 webkit.WidgetSetVisible(pane.titleBar.Ptr(), false) // ABSOLUTE RULE: never visible for active pane405 webkit.WidgetSetVisible(pane.titleBar.Ptr(), false) // ABSOLUTE RULE: never visible for active pane
@@ -415,27 +409,23 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
415 // Inactive panes: hide container, show title bar409 // Inactive panes: hide container, show title bar
416 if pane.container != nil {410 if pane.container != nil {
417 webkit.WidgetSetVisible(pane.container.Ptr(), false)411 webkit.WidgetSetVisible(pane.container.Ptr(), false)
418- webkit.WidgetAddCSSClass(pane.container.Ptr(), "stacked-pane-collapsed")
419 }412 }
420 if pane.titleBar != nil {413 if pane.titleBar != nil {
421 webkit.WidgetSetVisible(pane.titleBar.Ptr(), true)414 webkit.WidgetSetVisible(pane.titleBar.Ptr(), true)
422 }415 }
423- if pane.container != nil {
424- webkit.WidgetRemoveCSSClass(pane.container.Ptr(), "stacked-pane-active")
425- }
426 }416 }
427 }417 }
428 }418 }
429 419
430 // NavigateStack handles navigation within a stacked pane container420 // NavigateStack handles navigation within a stacked pane container
431 func (spm *StackedPaneManager) NavigateStack(direction string) bool {421 func (spm *StackedPaneManager) NavigateStack(direction string) bool {
432- if spm.wm.currentlyFocused == nil {422+ if spm.wm.GetActiveNode() == nil {
433 return false423 return false
434 }424 }
435 425
436 // Find the stack container this pane belongs to426 // Find the stack container this pane belongs to
437 var stackNode *paneNode427 var stackNode *paneNode
438- current := spm.wm.currentlyFocused428+ current := spm.wm.GetActiveNode()
439 429
440 // Check if current pane is directly in a stack430 // Check if current pane is directly in a stack
441 if current.parent != nil && current.parent.isStacked {431 if current.parent != nil && current.parent.isStacked {
@@ -498,8 +488,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
498 488
499 // Focus the new active pane489 // Focus the new active pane
500 newActivePane := stackNode.stackedPanes[newIndex]490 newActivePane := stackNode.stackedPanes[newIndex]
501- spm.wm.focusManager.SetActivePane(newActivePane)491+ spm.wm.SetActivePane(newActivePane, SourceStackNav)
502- spm.wm.currentlyFocused = newActivePane
503 492
504 log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",493 log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
505 direction, currentIndex, newIndex, len(stackNode.stackedPanes))494 direction, currentIndex, newIndex, len(stackNode.stackedPanes))
@@ -787,14 +776,15 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
787 // FIXED: Focus the remaining pane if any pane from this stack was currently focused776 // FIXED: Focus the remaining pane if any pane from this stack was currently focused
788 // Check if currentlyFocused is part of this stack (including the pane being closed)777 // Check if currentlyFocused is part of this stack (including the pane being closed)
789 shouldFocus := false778 shouldFocus := false
790- if spm.wm.currentlyFocused != nil {779+ currentlyFocused := spm.wm.GetActiveNode()
780+ if currentlyFocused != nil {
791 log.Printf("[workspace] DEBUG: currentlyFocused=%p, node being closed=%p, lastPane=%p",781 log.Printf("[workspace] DEBUG: currentlyFocused=%p, node being closed=%p, lastPane=%p",
792- spm.wm.currentlyFocused, node, lastPane)782+ currentlyFocused, node, lastPane)
793 // Check if currentlyFocused is the pane being closed783 // Check if currentlyFocused is the pane being closed
794- if spm.wm.currentlyFocused == node {784+ if currentlyFocused == node {
795 shouldFocus = true785 shouldFocus = true
796 log.Printf("[workspace] DEBUG: shouldFocus=true (closing focused pane)")786 log.Printf("[workspace] DEBUG: shouldFocus=true (closing focused pane)")
797- } else if spm.wm.currentlyFocused.parent == stackNode {787+ } else if currentlyFocused.parent == stackNode {
798 // Check if currentlyFocused is another pane in this stack788 // Check if currentlyFocused is another pane in this stack
799 shouldFocus = true789 shouldFocus = true
800 log.Printf("[workspace] DEBUG: shouldFocus=true (focused pane in same stack)")790 log.Printf("[workspace] DEBUG: shouldFocus=true (focused pane in same stack)")
@@ -811,14 +801,10 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
811 lastPaneWidget, visible)801 lastPaneWidget, visible)
812 }802 }
813 803
814- // Update CSS classes (mirror finalizeStackCreation)
815- spm.wm.ensurePaneBaseClasses()
816-
817 // Mirror the exact logic from finalizeStackCreation that works perfectly:804 // Mirror the exact logic from finalizeStackCreation that works perfectly:
818 // 1. First call SetActivePane (this does all the focus work)805 // 1. First call SetActivePane (this does all the focus work)
819 // 2. Then set currentlyFocused806 // 2. Then set currentlyFocused
820- spm.wm.focusManager.SetActivePane(lastPane)807+ spm.wm.SetActivePane(lastPane, SourceClose)
821- spm.wm.currentlyFocused = lastPane
822 808
823 // CRITICAL: Ensure widget visibility and focus after reparenting809 // CRITICAL: Ensure widget visibility and focus after reparenting
824 // The widget may need explicit show/focus calls after being reparented810 // The widget may need explicit show/focus calls after being reparented
@@ -852,18 +838,15 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
852 spm.UpdateStackVisibility(stackNode)838 spm.UpdateStackVisibility(stackNode)
853 839
854 // Focus the new active pane if we closed the currently active one840 // Focus the new active pane if we closed the currently active one
855- if spm.wm.currentlyFocused == node {841+ if spm.wm.GetActiveNode() == node {
856 newActivePaneInStack := stackNode.stackedPanes[stackNode.activeStackIndex]842 newActivePaneInStack := stackNode.stackedPanes[stackNode.activeStackIndex]
857- spm.wm.currentlyFocused = newActivePaneInStack843+ spm.wm.SetActivePane(newActivePaneInStack, SourceClose)
858- spm.wm.focusManager.SetActivePane(newActivePaneInStack)
859 }844 }
860 845
861 log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",846 log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
862 len(stackNode.stackedPanes), stackNode.activeStackIndex)847 len(stackNode.stackedPanes), stackNode.activeStackIndex)
863 }848 }
864 849
865- // Update CSS classes after pane count changes
866- spm.wm.ensurePaneBaseClasses()
867 log.Printf("[workspace] stacked pane closed; panes remaining=%d", len(spm.wm.app.panes))850 log.Printf("[workspace] stacked pane closed; panes remaining=%d", len(spm.wm.app.panes))
868 851
869 return nil852 return nil
M internal/app/browser/workspace_css.go +7 -67
@@ -35,8 +35,8 @@ func getStackTitleTextColor(isDark bool) string {
3535 return "#333333"
3636 }
3737
38-// generateActivePaneCSS generates the CSS for workspace panes based on config
39-func (wm *WorkspaceManager) generateActivePaneCSS() string {
38+// generateStackedPaneCSS generates the CSS for stacked pane title bars
39+func (wm *WorkspaceManager) generateStackedPaneCSS() string {
4040 cfg := config.Get()
4141 styling := cfg.Workspace.Styling
4242
@@ -51,34 +51,10 @@ func (wm *WorkspaceManager) generateActivePaneCSS() string {
5151 windowBackgroundColor = "#ffffff" // Light window background
5252 }
5353
54- // Log the border color values for debugging
55- log.Printf("[workspace] GTK prefers dark: %v, inactive border color: %s", isDark, inactiveBorderColor)
56-
5754 css := fmt.Sprintf(`window {
5855 background-color: %s;
5956 }
6057
61- .workspace-pane {
62- background-color: %s;
63- border: %dpx solid %s;
64- transition: border-color %dms ease-in-out;
65- border-radius: %dpx;
66- }
67-
68- .workspace-pane.workspace-multi-pane {
69- border: %dpx solid %s;
70- border-radius: %dpx;
71- }
72-
73- .workspace-pane.workspace-multi-pane.workspace-pane-active {
74- border-color: %s;
75- }
76-
77- /* Active border for ALL workspace panes (including stacked panes) */
78- .workspace-pane.workspace-pane-active {
79- border-color: %s !important;
80- }
81-
8258 /* Stacked panes styling */
8359 .stacked-pane-container {
8460 background-color: %s;
@@ -110,18 +86,7 @@ func (wm *WorkspaceManager) generateActivePaneCSS() string {
11086 .stacked-pane-collapsed {
11187 /* Collapsed panes are hidden - handled in code via widget visibility */
11288 }`,
113- windowBackgroundColor,
114- windowBackgroundColor,
115- styling.BorderWidth,
116- inactiveBorderColor,
117- styling.TransitionDuration,
118- styling.BorderRadius,
119- styling.BorderWidth,
120- inactiveBorderColor,
121- styling.BorderRadius,
122- styling.BorderColor,
123- styling.BorderColor, // NEW: .workspace-pane.workspace-pane-active border-color
124- // Additional parameters for stacked pane styles
89+ windowBackgroundColor, // window background
12590 windowBackgroundColor, // stacked-pane-container background
12691 styling.BorderRadius, // stacked-pane-container border-radius
12792 getStackTitleBg(isDark), // stacked-pane-title background
@@ -131,40 +96,15 @@ func (wm *WorkspaceManager) generateActivePaneCSS() string {
13196 getStackTitleTextColor(isDark), // stacked-pane-title-text color
13297 )
13398
134- // Log the actual CSS being generated
135- log.Printf("[workspace] Generated CSS: %s", css)
136-
13799 return css
138100 }
139101
140-// ensureStyles ensures that CSS styles are applied to the workspace
141-func (wm *WorkspaceManager) ensureStyles() {
102+// ensureStackedPaneStyles ensures that CSS styles are applied for stacked panes
103+func (wm *WorkspaceManager) ensureStackedPaneStyles() {
142104 if wm == nil || wm.cssInitialized {
143105 return
144106 }
145- activePaneCSS := wm.generateActivePaneCSS()
146- webkit.AddCSSProvider(activePaneCSS)
107+ stackedPaneCSS := wm.generateStackedPaneCSS()
108+ webkit.AddCSSProvider(stackedPaneCSS)
147109 wm.cssInitialized = true
148110 }
149-
150-// ensurePaneBaseClasses ensures all panes have the proper base CSS classes
151-func (wm *WorkspaceManager) ensurePaneBaseClasses() {
152- if wm == nil {
153- return
154- }
155-
156- leaves := wm.collectLeaves()
157- for _, leaf := range leaves {
158- if leaf != nil && leaf.container != nil {
159- leaf.container.Execute(func(containerPtr uintptr) error {
160- webkit.WidgetAddCSSClass(containerPtr, basePaneClass)
161- if wm.hasMultiplePanes() {
162- webkit.WidgetAddCSSClass(containerPtr, multiPaneClass)
163- } else {
164- webkit.WidgetRemoveCSSClass(containerPtr, multiPaneClass)
165- }
166- return nil
167- })
168- }
169- }
170-}
@@ -35,8 +35,8 @@ func getStackTitleTextColor(isDark bool) string {
35 return "#333333"35 return "#333333"
36 }36 }
37 37
38-// generateActivePaneCSS generates the CSS for workspace panes based on config38+// generateStackedPaneCSS generates the CSS for stacked pane title bars
39-func (wm *WorkspaceManager) generateActivePaneCSS() string {39+func (wm *WorkspaceManager) generateStackedPaneCSS() string {
40 cfg := config.Get()40 cfg := config.Get()
41 styling := cfg.Workspace.Styling41 styling := cfg.Workspace.Styling
42 42
@@ -51,34 +51,10 @@ func (wm *WorkspaceManager) generateActivePaneCSS() string {
51 windowBackgroundColor = "#ffffff" // Light window background51 windowBackgroundColor = "#ffffff" // Light window background
52 }52 }
53 53
54- // Log the border color values for debugging
55- log.Printf("[workspace] GTK prefers dark: %v, inactive border color: %s", isDark, inactiveBorderColor)
56-
57 css := fmt.Sprintf(`window {54 css := fmt.Sprintf(`window {
58 background-color: %s;55 background-color: %s;
59 }56 }
60 57
61- .workspace-pane {
62- background-color: %s;
63- border: %dpx solid %s;
64- transition: border-color %dms ease-in-out;
65- border-radius: %dpx;
66- }
67-
68- .workspace-pane.workspace-multi-pane {
69- border: %dpx solid %s;
70- border-radius: %dpx;
71- }
72-
73- .workspace-pane.workspace-multi-pane.workspace-pane-active {
74- border-color: %s;
75- }
76-
77- /* Active border for ALL workspace panes (including stacked panes) */
78- .workspace-pane.workspace-pane-active {
79- border-color: %s !important;
80- }
81-
82 /* Stacked panes styling */58 /* Stacked panes styling */
83 .stacked-pane-container {59 .stacked-pane-container {
84 background-color: %s;60 background-color: %s;
@@ -110,18 +86,7 @@ func (wm *WorkspaceManager) generateActivePaneCSS() string {
110 .stacked-pane-collapsed {86 .stacked-pane-collapsed {
111 /* Collapsed panes are hidden - handled in code via widget visibility */87 /* Collapsed panes are hidden - handled in code via widget visibility */
112 }`,88 }`,
113- windowBackgroundColor,89+ windowBackgroundColor, // window background
114- windowBackgroundColor,
115- styling.BorderWidth,
116- inactiveBorderColor,
117- styling.TransitionDuration,
118- styling.BorderRadius,
119- styling.BorderWidth,
120- inactiveBorderColor,
121- styling.BorderRadius,
122- styling.BorderColor,
123- styling.BorderColor, // NEW: .workspace-pane.workspace-pane-active border-color
124- // Additional parameters for stacked pane styles
125 windowBackgroundColor, // stacked-pane-container background90 windowBackgroundColor, // stacked-pane-container background
126 styling.BorderRadius, // stacked-pane-container border-radius91 styling.BorderRadius, // stacked-pane-container border-radius
127 getStackTitleBg(isDark), // stacked-pane-title background92 getStackTitleBg(isDark), // stacked-pane-title background
@@ -131,40 +96,15 @@ func (wm *WorkspaceManager) generateActivePaneCSS() string {
131 getStackTitleTextColor(isDark), // stacked-pane-title-text color96 getStackTitleTextColor(isDark), // stacked-pane-title-text color
132 )97 )
133 98
134- // Log the actual CSS being generated
135- log.Printf("[workspace] Generated CSS: %s", css)
136-
137 return css99 return css
138 }100 }
139 101
140-// ensureStyles ensures that CSS styles are applied to the workspace102+// ensureStackedPaneStyles ensures that CSS styles are applied for stacked panes
141-func (wm *WorkspaceManager) ensureStyles() {103+func (wm *WorkspaceManager) ensureStackedPaneStyles() {
142 if wm == nil || wm.cssInitialized {104 if wm == nil || wm.cssInitialized {
143 return105 return
144 }106 }
145- activePaneCSS := wm.generateActivePaneCSS()107+ stackedPaneCSS := wm.generateStackedPaneCSS()
146- webkit.AddCSSProvider(activePaneCSS)108+ webkit.AddCSSProvider(stackedPaneCSS)
147 wm.cssInitialized = true109 wm.cssInitialized = true
148 }110 }
149-
150-// ensurePaneBaseClasses ensures all panes have the proper base CSS classes
151-func (wm *WorkspaceManager) ensurePaneBaseClasses() {
152- if wm == nil {
153- return
154- }
155-
156- leaves := wm.collectLeaves()
157- for _, leaf := range leaves {
158- if leaf != nil && leaf.container != nil {
159- leaf.container.Execute(func(containerPtr uintptr) error {
160- webkit.WidgetAddCSSClass(containerPtr, basePaneClass)
161- if wm.hasMultiplePanes() {
162- webkit.WidgetAddCSSClass(containerPtr, multiPaneClass)
163- } else {
164- webkit.WidgetRemoveCSSClass(containerPtr, multiPaneClass)
165- }
166- return nil
167- })
168- }
169- }
170-}
M internal/app/browser/workspace_manager.go +29 -18
@@ -45,10 +45,7 @@ type WorkspaceManager struct {
4545
4646 // Specialized managers for different pane operations
4747 stackedPaneManager *StackedPaneManager
48- focusManager *FocusManager
49-
50- // Current focused pane (Single Source of Truth for focus)
51- currentlyFocused *paneNode
48+ focusStateMachine *FocusStateMachine
5249
5350 // Widget operation synchronization to prevent GTK race conditions
5451 widgetMutex sync.Mutex
@@ -71,7 +68,7 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
7168
7269 // Initialize specialized managers
7370 manager.stackedPaneManager = NewStackedPaneManager(manager)
74- manager.focusManager = NewFocusManager(manager)
71+ manager.focusStateMachine = NewFocusStateMachine(manager)
7572 manager.createWebViewFn = func() (*webkit.WebView, error) {
7673 if manager.app == nil {
7774 return nil, errors.New("workspace manager missing app reference")
@@ -91,7 +88,7 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
9188 }
9289 return manager.app.createPaneForView(view)
9390 }
94- manager.ensureStyles()
91+ manager.ensureStackedPaneStyles()
9592
9693 rootContainer := rootPane.webView.RootWidget()
9794 root := &paneNode{
@@ -111,11 +108,21 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
111108 }
112109
113110 app.workspace = manager
114- manager.currentlyFocused = root
115- manager.focusManager.SetActivePane(root)
116- // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
117- // Ensure initial CSS classes are applied
118- manager.ensurePaneBaseClasses()
111+
112+ // Configure debug settings from app config
113+ if app.config != nil {
114+ manager.focusStateMachine.ConfigureDebug(
115+ app.config.Debug.EnableFocusDebug,
116+ false, // CSS debug removed
117+ app.config.Debug.EnableFocusMetrics,
118+ )
119+ }
120+
121+ // Initialize focus state machine after all setup is complete
122+ if err := manager.focusStateMachine.Initialize(); err != nil {
123+ log.Printf("[workspace] Failed to initialize focus state machine: %v", err)
124+ }
125+
119126 return manager
120127 }
121128
@@ -151,8 +158,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
151158 wm.paneModeActive = true
152159 wm.paneModeSource = source
153160 wm.lastPaneModeEntry = time.Now()
154- wm.currentlyFocused = node
155- wm.focusManager.SetActivePane(node)
161+ wm.SetActivePane(node, SourceProgrammatic)
156162 case "pane-confirmed", "pane-cancelled", "pane-mode-exited":
157163 // Debounce pane-mode-exited events to prevent duplicate focus calls
158164 if event == "pane-mode-exited" {
@@ -185,8 +191,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
185191 // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus
186192 wm.closeCurrentPane()
187193 case "pane-split":
188- wm.currentlyFocused = node
189- wm.focusManager.SetActivePane(node)
194+ wm.SetActivePane(node, SourceProgrammatic)
190195 direction := strings.ToLower(msg.Direction)
191196 if direction == "" {
192197 direction = "right"
@@ -212,8 +217,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
212217 wm.clonePaneState(node, newNode)
213218 wm.splitting = false
214219 case "pane-stack":
215- wm.currentlyFocused = node
216- wm.focusManager.SetActivePane(node)
220+ wm.SetActivePane(node, SourceProgrammatic)
217221 if last, ok := wm.lastSplitMsg[source]; ok {
218222 if time.Since(last) < 200*time.Millisecond {
219223 log.Printf("[workspace] stack ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
@@ -331,7 +335,14 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
331335
332336 // GetActiveNode returns the currently active pane node
333337 func (wm *WorkspaceManager) GetActiveNode() *paneNode {
334- return wm.currentlyFocused
338+ return wm.focusStateMachine.GetActivePane()
339+}
340+
341+// SetActivePane requests focus change through the focus state machine
342+func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
343+ if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
344+ log.Printf("[workspace] Focus request failed: %v", err)
345+ }
335346 }
336347
337348 // GetNodeForWebView returns the pane node associated with a WebView
@@ -45,10 +45,7 @@ type WorkspaceManager struct {
45 45
46 // Specialized managers for different pane operations46 // Specialized managers for different pane operations
47 stackedPaneManager *StackedPaneManager47 stackedPaneManager *StackedPaneManager
48- focusManager *FocusManager48+ focusStateMachine *FocusStateMachine
49-
50- // Current focused pane (Single Source of Truth for focus)
51- currentlyFocused *paneNode
52 49
53 // Widget operation synchronization to prevent GTK race conditions50 // Widget operation synchronization to prevent GTK race conditions
54 widgetMutex sync.Mutex51 widgetMutex sync.Mutex
@@ -71,7 +68,7 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
71 68
72 // Initialize specialized managers69 // Initialize specialized managers
73 manager.stackedPaneManager = NewStackedPaneManager(manager)70 manager.stackedPaneManager = NewStackedPaneManager(manager)
74- manager.focusManager = NewFocusManager(manager)71+ manager.focusStateMachine = NewFocusStateMachine(manager)
75 manager.createWebViewFn = func() (*webkit.WebView, error) {72 manager.createWebViewFn = func() (*webkit.WebView, error) {
76 if manager.app == nil {73 if manager.app == nil {
77 return nil, errors.New("workspace manager missing app reference")74 return nil, errors.New("workspace manager missing app reference")
@@ -91,7 +88,7 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
91 }88 }
92 return manager.app.createPaneForView(view)89 return manager.app.createPaneForView(view)
93 }90 }
94- manager.ensureStyles()91+ manager.ensureStackedPaneStyles()
95 92
96 rootContainer := rootPane.webView.RootWidget()93 rootContainer := rootPane.webView.RootWidget()
97 root := &paneNode{94 root := &paneNode{
@@ -111,11 +108,21 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
111 }108 }
112 109
113 app.workspace = manager110 app.workspace = manager
114- manager.currentlyFocused = root111+
115- manager.focusManager.SetActivePane(root)112+ // Configure debug settings from app config
116- // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler113+ if app.config != nil {
117- // Ensure initial CSS classes are applied114+ manager.focusStateMachine.ConfigureDebug(
118- manager.ensurePaneBaseClasses()115+ app.config.Debug.EnableFocusDebug,
116+ false, // CSS debug removed
117+ app.config.Debug.EnableFocusMetrics,
118+ )
119+ }
120+
121+ // Initialize focus state machine after all setup is complete
122+ if err := manager.focusStateMachine.Initialize(); err != nil {
123+ log.Printf("[workspace] Failed to initialize focus state machine: %v", err)
124+ }
125+
119 return manager126 return manager
120 }127 }
121 128
@@ -151,8 +158,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
151 wm.paneModeActive = true158 wm.paneModeActive = true
152 wm.paneModeSource = source159 wm.paneModeSource = source
153 wm.lastPaneModeEntry = time.Now()160 wm.lastPaneModeEntry = time.Now()
154- wm.currentlyFocused = node161+ wm.SetActivePane(node, SourceProgrammatic)
155- wm.focusManager.SetActivePane(node)
156 case "pane-confirmed", "pane-cancelled", "pane-mode-exited":162 case "pane-confirmed", "pane-cancelled", "pane-mode-exited":
157 // Debounce pane-mode-exited events to prevent duplicate focus calls163 // Debounce pane-mode-exited events to prevent duplicate focus calls
158 if event == "pane-mode-exited" {164 if event == "pane-mode-exited" {
@@ -185,8 +191,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
185 // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus191 // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus
186 wm.closeCurrentPane()192 wm.closeCurrentPane()
187 case "pane-split":193 case "pane-split":
188- wm.currentlyFocused = node194+ wm.SetActivePane(node, SourceProgrammatic)
189- wm.focusManager.SetActivePane(node)
190 direction := strings.ToLower(msg.Direction)195 direction := strings.ToLower(msg.Direction)
191 if direction == "" {196 if direction == "" {
192 direction = "right"197 direction = "right"
@@ -212,8 +217,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
212 wm.clonePaneState(node, newNode)217 wm.clonePaneState(node, newNode)
213 wm.splitting = false218 wm.splitting = false
214 case "pane-stack":219 case "pane-stack":
215- wm.currentlyFocused = node220+ wm.SetActivePane(node, SourceProgrammatic)
216- wm.focusManager.SetActivePane(node)
217 if last, ok := wm.lastSplitMsg[source]; ok {221 if last, ok := wm.lastSplitMsg[source]; ok {
218 if time.Since(last) < 200*time.Millisecond {222 if time.Since(last) < 200*time.Millisecond {
219 log.Printf("[workspace] stack ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)223 log.Printf("[workspace] stack ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
@@ -331,7 +335,14 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
331 335
332 // GetActiveNode returns the currently active pane node336 // GetActiveNode returns the currently active pane node
333 func (wm *WorkspaceManager) GetActiveNode() *paneNode {337 func (wm *WorkspaceManager) GetActiveNode() *paneNode {
334- return wm.currentlyFocused338+ return wm.focusStateMachine.GetActivePane()
339+}
340+
341+// SetActivePane requests focus change through the focus state machine
342+func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
343+ if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
344+ log.Printf("[workspace] Focus request failed: %v", err)
345+ }
335 }346 }
336 347
337 // GetNodeForWebView returns the pane node associated with a WebView348 // GetNodeForWebView returns the pane node associated with a WebView
M internal/app/browser/workspace_navigation.go +16 -20
@@ -52,15 +52,13 @@ func (wm *WorkspaceManager) focusRespectingStack(node *paneNode, reason string)
5252 if reason != "" {
5353 log.Printf("[workspace] %s: preserving active stacked pane index=%d", reason, activeIndex)
5454 }
55- wm.currentlyFocused = activePane
56- wm.focusManager.SetActivePane(activePane)
55+ wm.SetActivePane(activePane, SourceKeyboard)
5756 return
5857 }
5958 }
6059 }
6160
62- wm.currentlyFocused = node
63- wm.focusManager.SetActivePane(node)
61+ wm.SetActivePane(node, SourceKeyboard)
6462 }
6563
6664 // focusByView changes focus to the pane containing the specified WebView
@@ -80,7 +78,7 @@ func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
8078 wm.focusThrottleMutex.Unlock()
8179
8280 if node, ok := wm.viewToNode[view]; ok {
83- if wm.currentlyFocused != node {
81+ if wm.GetActiveNode() != node {
8482 wm.focusRespectingStack(node, "focus-by-view")
8583 }
8684 }
@@ -101,8 +99,7 @@ func (wm *WorkspaceManager) ensureHover(node *paneNode) {
10199 if wm == nil {
102100 return
103101 }
104- wm.focusManager.SetActivePane(node)
105- wm.currentlyFocused = node
102+ wm.SetActivePane(node, SourceMouse)
106103 })
107104 return nil
108105 })
@@ -148,13 +145,14 @@ func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
148145
149146 // navigateStack handles navigation within a stacked pane container.
150147 func (wm *WorkspaceManager) navigateStack(direction string) bool {
151- if wm.currentlyFocused == nil {
148+ currentFocused := wm.GetActiveNode()
149+ if currentFocused == nil {
152150 return false
153151 }
154152
155153 // Find the stack container this pane belongs to
156154 var stackNode *paneNode
157- current := wm.currentlyFocused
155+ current := currentFocused
158156
159157 // Check if current pane is directly in a stack
160158 if current.parent != nil && current.parent.isStacked {
@@ -211,8 +209,7 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool {
211209
212210 // Focus the new active pane
213211 newActivePane := stackNode.stackedPanes[newIndex]
214- wm.focusManager.SetActivePane(newActivePane)
215- wm.currentlyFocused = newActivePane
212+ wm.SetActivePane(newActivePane, SourceStackNav)
216213
217214 log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
218215 direction, currentIndex, newIndex, len(stackNode.stackedPanes))
@@ -221,19 +218,19 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool {
221218
222219 // focusAdjacent uses geometry-based calculations to find and focus adjacent panes
223220 func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
224- if wm.currentlyFocused == nil || !wm.currentlyFocused.isLeaf || wm.currentlyFocused.container == nil {
221+ currentFocused := wm.GetActiveNode()
222+ if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == nil {
225223 return false
226224 }
227225
228- if neighbor := wm.structuralNeighbor(wm.currentlyFocused, direction); neighbor != nil {
229- wm.focusManager.SetActivePane(neighbor)
230- wm.currentlyFocused = neighbor
226+ if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {
227+ wm.SetActivePane(neighbor, SourceKeyboard)
231228 return true
232229 }
233230
234231 var currentBounds webkit.WidgetBounds
235232 var ok bool
236- wm.currentlyFocused.container.Execute(func(containerPtr uintptr) error {
233+ currentFocused.container.Execute(func(containerPtr uintptr) error {
237234 currentBounds, ok = webkit.WidgetGetBounds(containerPtr)
238235 return nil
239236 })
@@ -251,7 +248,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
251248 var debugCandidates []string
252249
253250 for _, candidate := range leaves {
254- if candidate == nil || candidate == wm.currentlyFocused || candidate.container == nil {
251+ if candidate == nil || candidate == currentFocused || candidate.container == nil {
255252 continue
256253 }
257254 var bounds webkit.WidgetBounds
@@ -305,13 +302,12 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
305302 }
306303
307304 if best != nil {
308- wm.focusManager.SetActivePane(best)
309- wm.currentlyFocused = best
305+ wm.SetActivePane(best, SourceKeyboard)
310306 return true
311307 }
312308
313309 if len(debugCandidates) > 0 {
314- log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, wm.currentlyFocused.container, strings.Join(debugCandidates, "; "))
310+ log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, currentFocused.container, strings.Join(debugCandidates, "; "))
315311 }
316312 return false
317313 }
@@ -52,15 +52,13 @@ func (wm *WorkspaceManager) focusRespectingStack(node *paneNode, reason string)
52 if reason != "" {52 if reason != "" {
53 log.Printf("[workspace] %s: preserving active stacked pane index=%d", reason, activeIndex)53 log.Printf("[workspace] %s: preserving active stacked pane index=%d", reason, activeIndex)
54 }54 }
55- wm.currentlyFocused = activePane55+ wm.SetActivePane(activePane, SourceKeyboard)
56- wm.focusManager.SetActivePane(activePane)
57 return56 return
58 }57 }
59 }58 }
60 }59 }
61 60
62- wm.currentlyFocused = node61+ wm.SetActivePane(node, SourceKeyboard)
63- wm.focusManager.SetActivePane(node)
64 }62 }
65 63
66 // focusByView changes focus to the pane containing the specified WebView64 // focusByView changes focus to the pane containing the specified WebView
@@ -80,7 +78,7 @@ func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
80 wm.focusThrottleMutex.Unlock()78 wm.focusThrottleMutex.Unlock()
81 79
82 if node, ok := wm.viewToNode[view]; ok {80 if node, ok := wm.viewToNode[view]; ok {
83- if wm.currentlyFocused != node {81+ if wm.GetActiveNode() != node {
84 wm.focusRespectingStack(node, "focus-by-view")82 wm.focusRespectingStack(node, "focus-by-view")
85 }83 }
86 }84 }
@@ -101,8 +99,7 @@ func (wm *WorkspaceManager) ensureHover(node *paneNode) {
101 if wm == nil {99 if wm == nil {
102 return100 return
103 }101 }
104- wm.focusManager.SetActivePane(node)102+ wm.SetActivePane(node, SourceMouse)
105- wm.currentlyFocused = node
106 })103 })
107 return nil104 return nil
108 })105 })
@@ -148,13 +145,14 @@ func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
148 145
149 // navigateStack handles navigation within a stacked pane container.146 // navigateStack handles navigation within a stacked pane container.
150 func (wm *WorkspaceManager) navigateStack(direction string) bool {147 func (wm *WorkspaceManager) navigateStack(direction string) bool {
151- if wm.currentlyFocused == nil {148+ currentFocused := wm.GetActiveNode()
149+ if currentFocused == nil {
152 return false150 return false
153 }151 }
154 152
155 // Find the stack container this pane belongs to153 // Find the stack container this pane belongs to
156 var stackNode *paneNode154 var stackNode *paneNode
157- current := wm.currentlyFocused155+ current := currentFocused
158 156
159 // Check if current pane is directly in a stack157 // Check if current pane is directly in a stack
160 if current.parent != nil && current.parent.isStacked {158 if current.parent != nil && current.parent.isStacked {
@@ -211,8 +209,7 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool {
211 209
212 // Focus the new active pane210 // Focus the new active pane
213 newActivePane := stackNode.stackedPanes[newIndex]211 newActivePane := stackNode.stackedPanes[newIndex]
214- wm.focusManager.SetActivePane(newActivePane)212+ wm.SetActivePane(newActivePane, SourceStackNav)
215- wm.currentlyFocused = newActivePane
216 213
217 log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",214 log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
218 direction, currentIndex, newIndex, len(stackNode.stackedPanes))215 direction, currentIndex, newIndex, len(stackNode.stackedPanes))
@@ -221,19 +218,19 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool {
221 218
222 // focusAdjacent uses geometry-based calculations to find and focus adjacent panes219 // focusAdjacent uses geometry-based calculations to find and focus adjacent panes
223 func (wm *WorkspaceManager) focusAdjacent(direction string) bool {220 func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
224- if wm.currentlyFocused == nil || !wm.currentlyFocused.isLeaf || wm.currentlyFocused.container == nil {221+ currentFocused := wm.GetActiveNode()
222+ if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == nil {
225 return false223 return false
226 }224 }
227 225
228- if neighbor := wm.structuralNeighbor(wm.currentlyFocused, direction); neighbor != nil {226+ if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {
229- wm.focusManager.SetActivePane(neighbor)227+ wm.SetActivePane(neighbor, SourceKeyboard)
230- wm.currentlyFocused = neighbor
231 return true228 return true
232 }229 }
233 230
234 var currentBounds webkit.WidgetBounds231 var currentBounds webkit.WidgetBounds
235 var ok bool232 var ok bool
236- wm.currentlyFocused.container.Execute(func(containerPtr uintptr) error {233+ currentFocused.container.Execute(func(containerPtr uintptr) error {
237 currentBounds, ok = webkit.WidgetGetBounds(containerPtr)234 currentBounds, ok = webkit.WidgetGetBounds(containerPtr)
238 return nil235 return nil
239 })236 })
@@ -251,7 +248,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
251 var debugCandidates []string248 var debugCandidates []string
252 249
253 for _, candidate := range leaves {250 for _, candidate := range leaves {
254- if candidate == nil || candidate == wm.currentlyFocused || candidate.container == nil {251+ if candidate == nil || candidate == currentFocused || candidate.container == nil {
255 continue252 continue
256 }253 }
257 var bounds webkit.WidgetBounds254 var bounds webkit.WidgetBounds
@@ -305,13 +302,12 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
305 }302 }
306 303
307 if best != nil {304 if best != nil {
308- wm.focusManager.SetActivePane(best)305+ wm.SetActivePane(best, SourceKeyboard)
309- wm.currentlyFocused = best
310 return true306 return true
311 }307 }
312 308
313 if len(debugCandidates) > 0 {309 if len(debugCandidates) > 0 {
314- log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, wm.currentlyFocused.container, strings.Join(debugCandidates, "; "))310+ log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, currentFocused.container, strings.Join(debugCandidates, "; "))
315 }311 }
316 return false312 return false
317 }313 }
M internal/app/browser/workspace_pane_ops.go +18 -21
@@ -103,10 +103,10 @@ func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
103103
104104 // closeCurrentPane closes the currently focused pane
105105 func (wm *WorkspaceManager) closeCurrentPane() {
106- if wm == nil || wm.currentlyFocused == nil {
106+ if wm == nil || wm.GetActiveNode() == nil {
107107 return
108108 }
109- if err := wm.closePane(wm.currentlyFocused); err != nil {
109+ if err := wm.closePane(wm.GetActiveNode()); err != nil {
110110 log.Printf("[workspace] close current pane failed: %v", err)
111111 }
112112 }
@@ -174,8 +174,8 @@ func (wm *WorkspaceManager) updateMainPane() {
174174 }
175175
176176 if wm.mainPane == nil || !wm.mainPane.isLeaf {
177- if wm.currentlyFocused != nil && wm.currentlyFocused.isLeaf {
178- wm.mainPane = wm.currentlyFocused
177+ if wm.GetActiveNode() != nil && wm.GetActiveNode().isLeaf {
178+ wm.mainPane = wm.GetActiveNode()
179179 }
180180 }
181181 }
@@ -365,14 +365,16 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
365365 }
366366
367367 // Update CSS classes for all panes now that we have multiple panes
368- wm.ensurePaneBaseClasses()
369368
370369 webkit.IdleAdd(func() bool {
371370 if newContainer != 0 {
372371 webkit.WidgetShow(newContainer)
373372 }
374- wm.currentlyFocused = newLeaf
375- wm.focusManager.SetActivePane(newLeaf)
373+ // Attach GTK focus controller to new pane
374+ if wm.focusStateMachine != nil {
375+ wm.focusStateMachine.attachGTKController(newLeaf)
376+ }
377+ wm.SetActivePane(newLeaf, SourceSplit)
376378 return false
377379 })
378380
@@ -596,14 +598,16 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
596598 }
597599
598600 // Update CSS classes for all panes now that we have multiple panes
599- wm.ensurePaneBaseClasses()
600601
601602 webkit.IdleAdd(func() bool {
602603 if newContainer != 0 {
603604 webkit.WidgetShow(newContainer)
604605 }
605- wm.currentlyFocused = newLeaf
606- wm.focusManager.SetActivePane(newLeaf)
606+ // Attach GTK focus controller to new pane
607+ if wm.focusStateMachine != nil {
608+ wm.focusStateMachine.attachGTKController(newLeaf)
609+ }
610+ wm.SetActivePane(newLeaf, SourceSplit)
607611 return false
608612 })
609613
@@ -718,7 +722,6 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
718722 }
719723
720724 wm.root = nil
721- wm.currentlyFocused = nil
722725 wm.mainPane = nil
723726
724727 webkit.QuitMainLoop()
@@ -769,8 +772,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
769772 }
770773
771774 // Clear current active if it's the node being closed
772- if wm.currentlyFocused == node {
773- wm.currentlyFocused = nil
775+ if wm.GetActiveNode() == node {
774776 }
775777
776778 // Set replacement as new root
@@ -790,8 +792,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
790792 // Focus a suitable pane
791793 focusTarget := wm.leftmostLeaf(replacement)
792794 if focusTarget != nil {
793- wm.currentlyFocused = focusTarget
794- wm.focusManager.SetActivePane(focusTarget)
795+ wm.SetActivePane(focusTarget, SourceClose)
795796 }
796797
797798 // Destroy the webview and detach hover AFTER rearranging hierarchy
@@ -816,7 +817,6 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
816817
817818 wm.updateMainPane()
818819 // Update CSS classes after pane count changes
819- wm.ensurePaneBaseClasses()
820820 log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
821821 return nil
822822 }
@@ -841,8 +841,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
841841 }
842842
843843 // Clear current active if it's the node being closed
844- if wm.currentlyFocused == node {
845- wm.currentlyFocused = nil
844+ if wm.GetActiveNode() == node {
846845 }
847846
848847 grand := parent.parent
@@ -897,8 +896,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
897896 // Find a suitable focus target
898897 focusTarget := wm.leftmostLeaf(sibling)
899898 if focusTarget != nil {
900- wm.currentlyFocused = focusTarget
901- wm.focusManager.SetActivePane(focusTarget)
899+ wm.SetActivePane(focusTarget, SourceClose)
902900 }
903901
904902 // Clean up the node being closed
@@ -923,7 +921,6 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
923921
924922 wm.updateMainPane()
925923 // Update CSS classes after pane count changes
926- wm.ensurePaneBaseClasses()
927924 log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))
928925 return nil
929926 }
@@ -103,10 +103,10 @@ func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
103 103
104 // closeCurrentPane closes the currently focused pane104 // closeCurrentPane closes the currently focused pane
105 func (wm *WorkspaceManager) closeCurrentPane() {105 func (wm *WorkspaceManager) closeCurrentPane() {
106- if wm == nil || wm.currentlyFocused == nil {106+ if wm == nil || wm.GetActiveNode() == nil {
107 return107 return
108 }108 }
109- if err := wm.closePane(wm.currentlyFocused); err != nil {109+ if err := wm.closePane(wm.GetActiveNode()); err != nil {
110 log.Printf("[workspace] close current pane failed: %v", err)110 log.Printf("[workspace] close current pane failed: %v", err)
111 }111 }
112 }112 }
@@ -174,8 +174,8 @@ func (wm *WorkspaceManager) updateMainPane() {
174 }174 }
175 175
176 if wm.mainPane == nil || !wm.mainPane.isLeaf {176 if wm.mainPane == nil || !wm.mainPane.isLeaf {
177- if wm.currentlyFocused != nil && wm.currentlyFocused.isLeaf {177+ if wm.GetActiveNode() != nil && wm.GetActiveNode().isLeaf {
178- wm.mainPane = wm.currentlyFocused178+ wm.mainPane = wm.GetActiveNode()
179 }179 }
180 }180 }
181 }181 }
@@ -365,14 +365,16 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
365 }365 }
366 366
367 // Update CSS classes for all panes now that we have multiple panes367 // Update CSS classes for all panes now that we have multiple panes
368- wm.ensurePaneBaseClasses()
369 368
370 webkit.IdleAdd(func() bool {369 webkit.IdleAdd(func() bool {
371 if newContainer != 0 {370 if newContainer != 0 {
372 webkit.WidgetShow(newContainer)371 webkit.WidgetShow(newContainer)
373 }372 }
374- wm.currentlyFocused = newLeaf373+ // Attach GTK focus controller to new pane
375- wm.focusManager.SetActivePane(newLeaf)374+ if wm.focusStateMachine != nil {
375+ wm.focusStateMachine.attachGTKController(newLeaf)
376+ }
377+ wm.SetActivePane(newLeaf, SourceSplit)
376 return false378 return false
377 })379 })
378 380
@@ -596,14 +598,16 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
596 }598 }
597 599
598 // Update CSS classes for all panes now that we have multiple panes600 // Update CSS classes for all panes now that we have multiple panes
599- wm.ensurePaneBaseClasses()
600 601
601 webkit.IdleAdd(func() bool {602 webkit.IdleAdd(func() bool {
602 if newContainer != 0 {603 if newContainer != 0 {
603 webkit.WidgetShow(newContainer)604 webkit.WidgetShow(newContainer)
604 }605 }
605- wm.currentlyFocused = newLeaf606+ // Attach GTK focus controller to new pane
606- wm.focusManager.SetActivePane(newLeaf)607+ if wm.focusStateMachine != nil {
608+ wm.focusStateMachine.attachGTKController(newLeaf)
609+ }
610+ wm.SetActivePane(newLeaf, SourceSplit)
607 return false611 return false
608 })612 })
609 613
@@ -718,7 +722,6 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
718 }722 }
719 723
720 wm.root = nil724 wm.root = nil
721- wm.currentlyFocused = nil
722 wm.mainPane = nil725 wm.mainPane = nil
723 726
724 webkit.QuitMainLoop()727 webkit.QuitMainLoop()
@@ -769,8 +772,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
769 }772 }
770 773
771 // Clear current active if it's the node being closed774 // Clear current active if it's the node being closed
772- if wm.currentlyFocused == node {775+ if wm.GetActiveNode() == node {
773- wm.currentlyFocused = nil
774 }776 }
775 777
776 // Set replacement as new root778 // Set replacement as new root
@@ -790,8 +792,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
790 // Focus a suitable pane792 // Focus a suitable pane
791 focusTarget := wm.leftmostLeaf(replacement)793 focusTarget := wm.leftmostLeaf(replacement)
792 if focusTarget != nil {794 if focusTarget != nil {
793- wm.currentlyFocused = focusTarget795+ wm.SetActivePane(focusTarget, SourceClose)
794- wm.focusManager.SetActivePane(focusTarget)
795 }796 }
796 797
797 // Destroy the webview and detach hover AFTER rearranging hierarchy798 // Destroy the webview and detach hover AFTER rearranging hierarchy
@@ -816,7 +817,6 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
816 817
817 wm.updateMainPane()818 wm.updateMainPane()
818 // Update CSS classes after pane count changes819 // Update CSS classes after pane count changes
819- wm.ensurePaneBaseClasses()
820 log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))820 log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
821 return nil821 return nil
822 }822 }
@@ -841,8 +841,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
841 }841 }
842 842
843 // Clear current active if it's the node being closed843 // Clear current active if it's the node being closed
844- if wm.currentlyFocused == node {844+ if wm.GetActiveNode() == node {
845- wm.currentlyFocused = nil
846 }845 }
847 846
848 grand := parent.parent847 grand := parent.parent
@@ -897,8 +896,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
897 // Find a suitable focus target896 // Find a suitable focus target
898 focusTarget := wm.leftmostLeaf(sibling)897 focusTarget := wm.leftmostLeaf(sibling)
899 if focusTarget != nil {898 if focusTarget != nil {
900- wm.currentlyFocused = focusTarget899+ wm.SetActivePane(focusTarget, SourceClose)
901- wm.focusManager.SetActivePane(focusTarget)
902 }900 }
903 901
904 // Clean up the node being closed902 // Clean up the node being closed
@@ -923,7 +921,6 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
923 921
924 wm.updateMainPane()922 wm.updateMainPane()
925 // Update CSS classes after pane count changes923 // Update CSS classes after pane count changes
926- wm.ensurePaneBaseClasses()
927 log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))924 log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))
928 return nil925 return nil
929 }926 }
M internal/app/browser/workspace_popup.go +2 -2
@@ -124,8 +124,8 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
124124
125125 // Determine target node for splitting
126126 target := node
127- if !popCfg.FollowPaneContext && wm.currentlyFocused != nil {
128- target = wm.currentlyFocused
127+ if !popCfg.FollowPaneContext && wm.GetActiveNode() != nil {
128+ target = wm.GetActiveNode()
129129 }
130130
131131 // Add the popup pane to the workspace using manual pane insertion
@@ -124,8 +124,8 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
124 124
125 // Determine target node for splitting125 // Determine target node for splitting
126 target := node126 target := node
127- if !popCfg.FollowPaneContext && wm.currentlyFocused != nil {127+ if !popCfg.FollowPaneContext && wm.GetActiveNode() != nil {
128- target = wm.currentlyFocused128+ target = wm.GetActiveNode()
129 }129 }
130 130
131 // Add the popup pane to the workspace using manual pane insertion131 // Add the popup pane to the workspace using manual pane insertion
M internal/config/config.go +22 -0
@@ -215,6 +215,15 @@ type DebugConfig struct {
215215
216216 // Enable workspace navigation and focus debug logs
217217 EnableWorkspaceDebug bool `mapstructure:"enable_workspace_debug" yaml:"enable_workspace_debug"`
218+
219+ // Enable focus state machine debug logs
220+ EnableFocusDebug bool `mapstructure:"enable_focus_debug" yaml:"enable_focus_debug"`
221+
222+ // Enable CSS reconciler debug logs
223+ EnableCSSDebug bool `mapstructure:"enable_css_debug" yaml:"enable_css_debug"`
224+
225+ // Enable focus metrics tracking
226+ EnableFocusMetrics bool `mapstructure:"enable_focus_metrics" yaml:"enable_focus_metrics"`
218227 }
219228
220229 // APISecurityConfig holds optional API key protection for dumb://api endpoints
@@ -634,6 +643,19 @@ func (m *Manager) setDefaults() {
634643 m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)
635644 m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)
636645
646+ // Debug defaults
647+ m.viper.SetDefault("debug.enable_webkit_debug", defaults.Debug.EnableWebKitDebug)
648+ m.viper.SetDefault("debug.webkit_debug_categories", defaults.Debug.WebKitDebugCategories)
649+ m.viper.SetDefault("debug.enable_filtering_debug", defaults.Debug.EnableFilteringDebug)
650+ m.viper.SetDefault("debug.enable_webview_debug", defaults.Debug.EnableWebViewDebug)
651+ m.viper.SetDefault("debug.log_webkit_crashes", defaults.Debug.LogWebKitCrashes)
652+ m.viper.SetDefault("debug.enable_script_debug", defaults.Debug.EnableScriptDebug)
653+ m.viper.SetDefault("debug.enable_general_debug", defaults.Debug.EnableGeneralDebug)
654+ m.viper.SetDefault("debug.enable_workspace_debug", defaults.Debug.EnableWorkspaceDebug)
655+ m.viper.SetDefault("debug.enable_focus_debug", defaults.Debug.EnableFocusDebug)
656+ m.viper.SetDefault("debug.enable_css_debug", defaults.Debug.EnableCSSDebug)
657+ m.viper.SetDefault("debug.enable_focus_metrics", defaults.Debug.EnableFocusMetrics)
658+
637659 // Appearance defaults
638660 m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
639661 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
@@ -215,6 +215,15 @@ type DebugConfig struct {
215 215
216 // Enable workspace navigation and focus debug logs216 // Enable workspace navigation and focus debug logs
217 EnableWorkspaceDebug bool `mapstructure:"enable_workspace_debug" yaml:"enable_workspace_debug"`217 EnableWorkspaceDebug bool `mapstructure:"enable_workspace_debug" yaml:"enable_workspace_debug"`
218+
219+ // Enable focus state machine debug logs
220+ EnableFocusDebug bool `mapstructure:"enable_focus_debug" yaml:"enable_focus_debug"`
221+
222+ // Enable CSS reconciler debug logs
223+ EnableCSSDebug bool `mapstructure:"enable_css_debug" yaml:"enable_css_debug"`
224+
225+ // Enable focus metrics tracking
226+ EnableFocusMetrics bool `mapstructure:"enable_focus_metrics" yaml:"enable_focus_metrics"`
218 }227 }
219 228
220 // APISecurityConfig holds optional API key protection for dumb://api endpoints229 // APISecurityConfig holds optional API key protection for dumb://api endpoints
@@ -634,6 +643,19 @@ func (m *Manager) setDefaults() {
634 m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)643 m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)
635 m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)644 m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)
636 645
646+ // Debug defaults
647+ m.viper.SetDefault("debug.enable_webkit_debug", defaults.Debug.EnableWebKitDebug)
648+ m.viper.SetDefault("debug.webkit_debug_categories", defaults.Debug.WebKitDebugCategories)
649+ m.viper.SetDefault("debug.enable_filtering_debug", defaults.Debug.EnableFilteringDebug)
650+ m.viper.SetDefault("debug.enable_webview_debug", defaults.Debug.EnableWebViewDebug)
651+ m.viper.SetDefault("debug.log_webkit_crashes", defaults.Debug.LogWebKitCrashes)
652+ m.viper.SetDefault("debug.enable_script_debug", defaults.Debug.EnableScriptDebug)
653+ m.viper.SetDefault("debug.enable_general_debug", defaults.Debug.EnableGeneralDebug)
654+ m.viper.SetDefault("debug.enable_workspace_debug", defaults.Debug.EnableWorkspaceDebug)
655+ m.viper.SetDefault("debug.enable_focus_debug", defaults.Debug.EnableFocusDebug)
656+ m.viper.SetDefault("debug.enable_css_debug", defaults.Debug.EnableCSSDebug)
657+ m.viper.SetDefault("debug.enable_focus_metrics", defaults.Debug.EnableFocusMetrics)
658+
637 // Appearance defaults659 // Appearance defaults
638 m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)660 m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
639 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)661 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
M internal/config/defaults.go +4 -0
@@ -151,6 +151,10 @@ func DefaultConfig() *Config {
151151 LogWebKitCrashes: true, // Always log crashes
152152 EnableScriptDebug: false,
153153 EnableGeneralDebug: false,
154+ EnableWorkspaceDebug: false,
155+ EnableFocusDebug: false,
156+ EnableCSSDebug: false,
157+ EnableFocusMetrics: false,
154158 },
155159 APISecurity: APISecurityConfig{
156160 Token: "",
@@ -151,6 +151,10 @@ func DefaultConfig() *Config {
151 LogWebKitCrashes: true, // Always log crashes151 LogWebKitCrashes: true, // Always log crashes
152 EnableScriptDebug: false,152 EnableScriptDebug: false,
153 EnableGeneralDebug: false,153 EnableGeneralDebug: false,
154+ EnableWorkspaceDebug: false,
155+ EnableFocusDebug: false,
156+ EnableCSSDebug: false,
157+ EnableFocusMetrics: false,
154 },158 },
155 APISecurity: APISecurityConfig{159 APISecurity: APISecurityConfig{
156 Token: "",160 Token: "",
M pkg/webkit/workspace_widgets_cgo.go +130 -0
@@ -19,6 +19,8 @@ package webkit
1919 // Callback function for idle handling following the existing pattern
2020 extern gboolean goIdleCallback(uintptr_t handle);
2121 extern void goHoverCallback(uintptr_t handle);
22+extern void goFocusEnterCallback(uintptr_t handle);
23+extern void goFocusLeaveCallback(uintptr_t handle);
2224
2325 static gboolean idle_callback_wrapper(gpointer data) {
2426 return goIdleCallback((uintptr_t)data);
@@ -222,6 +224,41 @@ static void label_set_max_width_chars(GtkWidget* label, int n_chars) {
222224 if (!label) return;
223225 gtk_label_set_max_width_chars(GTK_LABEL(label), n_chars);
224226 }
227+
228+// GTK4 Focus Controller helpers for focus state machine
229+static void focus_enter_cb(GtkEventControllerFocus* controller, gpointer user_data) {
230+ (void)controller;
231+ uintptr_t nodePtr = (uintptr_t)user_data;
232+ goFocusEnterCallback(nodePtr);
233+}
234+
235+static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_data) {
236+ (void)controller;
237+ uintptr_t nodePtr = (uintptr_t)user_data;
238+ goFocusLeaveCallback(nodePtr);
239+}
240+
241+static GtkEventController* widget_add_focus_controller(GtkWidget* widget, uintptr_t nodePtr) {
242+ if (!widget) {
243+ return NULL;
244+ }
245+ GtkEventController* focus = gtk_event_controller_focus_new();
246+ if (!focus) {
247+ return NULL;
248+ }
249+ gtk_event_controller_set_propagation_phase(focus, GTK_PHASE_CAPTURE);
250+ g_signal_connect(focus, "enter", G_CALLBACK(focus_enter_cb), (gpointer)nodePtr);
251+ g_signal_connect(focus, "leave", G_CALLBACK(focus_leave_cb), (gpointer)nodePtr);
252+ gtk_widget_add_controller(widget, focus);
253+ return focus;
254+}
255+
256+static void widget_remove_focus_controller(GtkWidget* widget, GtkEventController* controller) {
257+ if (!widget || !controller) {
258+ return;
259+ }
260+ gtk_widget_remove_controller(widget, controller);
261+}
225262 */
226263 import "C"
227264
@@ -239,12 +276,24 @@ type WidgetBounds struct {
239276 Height float64
240277 }
241278
279+// FocusCallbacks holds the enter and leave callbacks for focus events
280+type FocusCallbacks struct {
281+ OnEnter func()
282+ OnLeave func()
283+}
284+
242285 var (
243286 hoverMu sync.Mutex
244287 hoverCallbacks = make(map[uintptr]func())
245288 hoverControllers = make(map[uintptr]uintptr)
246289 nextHoverID uintptr = 1
247290 // gtkTestInitOnce removed - no longer needed
291+
292+ // Focus controller management
293+ focusMu sync.Mutex
294+ focusCallbacks = make(map[uintptr]FocusCallbacks)
295+ focusControllers = make(map[uintptr]uintptr)
296+ nextFocusID uintptr = 1
248297 )
249298
250299 func widgetIsValid(widget uintptr) bool {
@@ -779,3 +828,84 @@ func WidgetHasCSSClass(widget uintptr, class string) bool {
779828 result := C.gtk_widget_has_css_class((*C.GtkWidget)(unsafe.Pointer(widget)), cstr)
780829 return result != 0
781830 }
831+
832+// GTK4 Focus Controller functions for focus state machine
833+
834+// WidgetAddFocusController registers focus callbacks for the given widget and
835+// returns a token that can be used to detach it later.
836+func WidgetAddFocusController(widget uintptr, onEnter, onLeave func()) uintptr {
837+ if widget == 0 || !widgetIsValid(widget) {
838+ return 0
839+ }
840+
841+ focusMu.Lock()
842+ token := nextFocusID
843+ nextFocusID++
844+
845+ callbacks := FocusCallbacks{
846+ OnEnter: onEnter,
847+ OnLeave: onLeave,
848+ }
849+ focusCallbacks[token] = callbacks
850+ focusMu.Unlock()
851+
852+ controller := C.widget_add_focus_controller((*C.GtkWidget)(unsafe.Pointer(widget)), C.uintptr_t(token))
853+ if controller == nil {
854+ focusMu.Lock()
855+ delete(focusCallbacks, token)
856+ focusMu.Unlock()
857+ return 0
858+ }
859+
860+ focusMu.Lock()
861+ focusControllers[token] = uintptr(unsafe.Pointer(controller))
862+ focusMu.Unlock()
863+ return token
864+}
865+
866+// WidgetRemoveFocusController removes a previously registered focus controller.
867+func WidgetRemoveFocusController(widget uintptr, token uintptr) {
868+ if token == 0 {
869+ return
870+ }
871+
872+ focusMu.Lock()
873+ controller := focusControllers[token]
874+ delete(focusCallbacks, token)
875+ delete(focusControllers, token)
876+ focusMu.Unlock()
877+
878+ if controller == 0 {
879+ return
880+ }
881+
882+ if widget == 0 || !widgetIsValid(widget) {
883+ return
884+ }
885+
886+ C.widget_remove_focus_controller((*C.GtkWidget)(unsafe.Pointer(widget)), (*C.GtkEventController)(unsafe.Pointer(controller)))
887+}
888+
889+//export goFocusEnterCallback
890+func goFocusEnterCallback(handle C.uintptr_t) {
891+ token := uintptr(handle)
892+ focusMu.Lock()
893+ callbacks := focusCallbacks[token]
894+ focusMu.Unlock()
895+
896+ if callbacks.OnEnter != nil {
897+ callbacks.OnEnter()
898+ }
899+}
900+
901+//export goFocusLeaveCallback
902+func goFocusLeaveCallback(handle C.uintptr_t) {
903+ token := uintptr(handle)
904+ focusMu.Lock()
905+ callbacks := focusCallbacks[token]
906+ focusMu.Unlock()
907+
908+ if callbacks.OnLeave != nil {
909+ callbacks.OnLeave()
910+ }
911+}
@@ -19,6 +19,8 @@ package webkit
19 // Callback function for idle handling following the existing pattern19 // Callback function for idle handling following the existing pattern
20 extern gboolean goIdleCallback(uintptr_t handle);20 extern gboolean goIdleCallback(uintptr_t handle);
21 extern void goHoverCallback(uintptr_t handle);21 extern void goHoverCallback(uintptr_t handle);
22+extern void goFocusEnterCallback(uintptr_t handle);
23+extern void goFocusLeaveCallback(uintptr_t handle);
22 24
23 static gboolean idle_callback_wrapper(gpointer data) {25 static gboolean idle_callback_wrapper(gpointer data) {
24 return goIdleCallback((uintptr_t)data);26 return goIdleCallback((uintptr_t)data);
@@ -222,6 +224,41 @@ static void label_set_max_width_chars(GtkWidget* label, int n_chars) {
222 if (!label) return;224 if (!label) return;
223 gtk_label_set_max_width_chars(GTK_LABEL(label), n_chars);225 gtk_label_set_max_width_chars(GTK_LABEL(label), n_chars);
224 }226 }
227+
228+// GTK4 Focus Controller helpers for focus state machine
229+static void focus_enter_cb(GtkEventControllerFocus* controller, gpointer user_data) {
230+ (void)controller;
231+ uintptr_t nodePtr = (uintptr_t)user_data;
232+ goFocusEnterCallback(nodePtr);
233+}
234+
235+static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_data) {
236+ (void)controller;
237+ uintptr_t nodePtr = (uintptr_t)user_data;
238+ goFocusLeaveCallback(nodePtr);
239+}
240+
241+static GtkEventController* widget_add_focus_controller(GtkWidget* widget, uintptr_t nodePtr) {
242+ if (!widget) {
243+ return NULL;
244+ }
245+ GtkEventController* focus = gtk_event_controller_focus_new();
246+ if (!focus) {
247+ return NULL;
248+ }
249+ gtk_event_controller_set_propagation_phase(focus, GTK_PHASE_CAPTURE);
250+ g_signal_connect(focus, "enter", G_CALLBACK(focus_enter_cb), (gpointer)nodePtr);
251+ g_signal_connect(focus, "leave", G_CALLBACK(focus_leave_cb), (gpointer)nodePtr);
252+ gtk_widget_add_controller(widget, focus);
253+ return focus;
254+}
255+
256+static void widget_remove_focus_controller(GtkWidget* widget, GtkEventController* controller) {
257+ if (!widget || !controller) {
258+ return;
259+ }
260+ gtk_widget_remove_controller(widget, controller);
261+}
225 */262 */
226 import "C"263 import "C"
227 264
@@ -239,12 +276,24 @@ type WidgetBounds struct {
239 Height float64276 Height float64
240 }277 }
241 278
279+// FocusCallbacks holds the enter and leave callbacks for focus events
280+type FocusCallbacks struct {
281+ OnEnter func()
282+ OnLeave func()
283+}
284+
242 var (285 var (
243 hoverMu sync.Mutex286 hoverMu sync.Mutex
244 hoverCallbacks = make(map[uintptr]func())287 hoverCallbacks = make(map[uintptr]func())
245 hoverControllers = make(map[uintptr]uintptr)288 hoverControllers = make(map[uintptr]uintptr)
246 nextHoverID uintptr = 1289 nextHoverID uintptr = 1
247 // gtkTestInitOnce removed - no longer needed290 // gtkTestInitOnce removed - no longer needed
291+
292+ // Focus controller management
293+ focusMu sync.Mutex
294+ focusCallbacks = make(map[uintptr]FocusCallbacks)
295+ focusControllers = make(map[uintptr]uintptr)
296+ nextFocusID uintptr = 1
248 )297 )
249 298
250 func widgetIsValid(widget uintptr) bool {299 func widgetIsValid(widget uintptr) bool {
@@ -779,3 +828,84 @@ func WidgetHasCSSClass(widget uintptr, class string) bool {
779 result := C.gtk_widget_has_css_class((*C.GtkWidget)(unsafe.Pointer(widget)), cstr)828 result := C.gtk_widget_has_css_class((*C.GtkWidget)(unsafe.Pointer(widget)), cstr)
780 return result != 0829 return result != 0
781 }830 }
831+
832+// GTK4 Focus Controller functions for focus state machine
833+
834+// WidgetAddFocusController registers focus callbacks for the given widget and
835+// returns a token that can be used to detach it later.
836+func WidgetAddFocusController(widget uintptr, onEnter, onLeave func()) uintptr {
837+ if widget == 0 || !widgetIsValid(widget) {
838+ return 0
839+ }
840+
841+ focusMu.Lock()
842+ token := nextFocusID
843+ nextFocusID++
844+
845+ callbacks := FocusCallbacks{
846+ OnEnter: onEnter,
847+ OnLeave: onLeave,
848+ }
849+ focusCallbacks[token] = callbacks
850+ focusMu.Unlock()
851+
852+ controller := C.widget_add_focus_controller((*C.GtkWidget)(unsafe.Pointer(widget)), C.uintptr_t(token))
853+ if controller == nil {
854+ focusMu.Lock()
855+ delete(focusCallbacks, token)
856+ focusMu.Unlock()
857+ return 0
858+ }
859+
860+ focusMu.Lock()
861+ focusControllers[token] = uintptr(unsafe.Pointer(controller))
862+ focusMu.Unlock()
863+ return token
864+}
865+
866+// WidgetRemoveFocusController removes a previously registered focus controller.
867+func WidgetRemoveFocusController(widget uintptr, token uintptr) {
868+ if token == 0 {
869+ return
870+ }
871+
872+ focusMu.Lock()
873+ controller := focusControllers[token]
874+ delete(focusCallbacks, token)
875+ delete(focusControllers, token)
876+ focusMu.Unlock()
877+
878+ if controller == 0 {
879+ return
880+ }
881+
882+ if widget == 0 || !widgetIsValid(widget) {
883+ return
884+ }
885+
886+ C.widget_remove_focus_controller((*C.GtkWidget)(unsafe.Pointer(widget)), (*C.GtkEventController)(unsafe.Pointer(controller)))
887+}
888+
889+//export goFocusEnterCallback
890+func goFocusEnterCallback(handle C.uintptr_t) {
891+ token := uintptr(handle)
892+ focusMu.Lock()
893+ callbacks := focusCallbacks[token]
894+ focusMu.Unlock()
895+
896+ if callbacks.OnEnter != nil {
897+ callbacks.OnEnter()
898+ }
899+}
900+
901+//export goFocusLeaveCallback
902+func goFocusLeaveCallback(handle C.uintptr_t) {
903+ token := uintptr(handle)
904+ focusMu.Lock()
905+ callbacks := focusCallbacks[token]
906+ focusMu.Unlock()
907+
908+ if callbacks.OnLeave != nil {
909+ callbacks.OnLeave()
910+ }
911+}
M pkg/webkit/workspace_widgets_stub.go +10 -0
@@ -370,6 +370,16 @@ func WidgetQueueDraw(widget uintptr) {
370370 // Stub implementation - no-op
371371 }
372372
373+// Focus controller functions for GTK4 EventControllerFocus (stub implementations)
374+func WidgetAddFocusController(widget uintptr, onEnter, onLeave func()) uintptr {
375+ // Stub implementation - return a fake token
376+ return nextHoverToken
377+}
378+
379+func WidgetRemoveFocusController(widget uintptr, token uintptr) {
380+ // Stub implementation - no-op
381+}
382+
373383 // SimulateFocusForTesting gives focus to a widget for test scenarios
374384 func SimulateFocusForTesting(widget uintptr) {
375385 WidgetGrabFocus(widget)
@@ -370,6 +370,16 @@ func WidgetQueueDraw(widget uintptr) {
370 // Stub implementation - no-op370 // Stub implementation - no-op
371 }371 }
372 372
373+// Focus controller functions for GTK4 EventControllerFocus (stub implementations)
374+func WidgetAddFocusController(widget uintptr, onEnter, onLeave func()) uintptr {
375+ // Stub implementation - return a fake token
376+ return nextHoverToken
377+}
378+
379+func WidgetRemoveFocusController(widget uintptr, token uintptr) {
380+ // Stub implementation - no-op
381+}
382+
373 // SimulateFocusForTesting gives focus to a widget for test scenarios383 // SimulateFocusForTesting gives focus to a widget for test scenarios
374 func SimulateFocusForTesting(widget uintptr) {384 func SimulateFocusForTesting(widget uintptr) {
375 WidgetGrabFocus(widget)385 WidgetGrabFocus(widget)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close