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 { | |||
| 44 | 44 | ||
| 45 | 45 | // For related popups, they're active if parent chain contains the active node | |
| 46 | 46 | if node.isRelated && node.parentPane != nil { | |
| 47 | - | activeNode := s.app.workspace.currentlyFocused | |
| 47 | + | activeNode := s.app.workspace.GetActiveNode() | |
| 48 | 48 | cur := node | |
| 49 | 49 | for cur != nil { | |
| 50 | 50 | if cur == activeNode { | |
| @@ -56,7 +56,7 @@ func (s *ShortcutHandler) isActivePane() bool { | |||
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | // Independent panes: equal to active node | |
| 59 | - | return node == s.app.workspace.currentlyFocused | |
| 59 | + | return node == s.app.workspace.GetActiveNode() | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | // 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 node | 45 | // 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.currentlyFocused | 47 | + | activeNode := s.app.workspace.GetActiveNode() |
| 48 | cur := node | 48 | 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 node | 58 | // Independent panes: equal to active node | ||
| 59 | - | return node == s.app.workspace.currentlyFocused | 59 | + | return node == s.app.workspace.GetActiveNode() |
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | // RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards | 62 | // 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 | |||
| 338 | 338 | newLeaf.pane.zoomController.ApplyInitialZoom() | |
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | - | // Update CSS classes | |
| 342 | - | spm.wm.ensurePaneBaseClasses() | |
| 343 | - | ||
| 344 | 341 | // Mark stack operation timestamp to prevent focus conflicts | |
| 345 | 342 | spm.wm.lastStackOperation = time.Now() | |
| 346 | 343 | ||
| @@ -362,8 +359,7 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod | |||
| 362 | 359 | spm.UpdateStackVisibility(stackNode) | |
| 363 | 360 | ||
| 364 | 361 | // Set focus on the new pane synchronously | |
| 365 | - | spm.wm.focusManager.SetActivePane(newLeaf) | |
| 366 | - | spm.wm.currentlyFocused = newLeaf | |
| 362 | + | spm.wm.SetActivePane(newLeaf, SourceSplit) | |
| 367 | 363 | ||
| 368 | 364 | log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d", | |
| 369 | 365 | stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex) | |
| @@ -404,8 +400,6 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) { | |||
| 404 | 400 | // Active pane: show container, NEVER show title bar | |
| 405 | 401 | if pane.container != nil { | |
| 406 | 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 | 404 | if pane.titleBar != nil { | |
| 411 | 405 | webkit.WidgetSetVisible(pane.titleBar.Ptr(), false) // ABSOLUTE RULE: never visible for active pane | |
| @@ -415,27 +409,23 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) { | |||
| 415 | 409 | // Inactive panes: hide container, show title bar | |
| 416 | 410 | if pane.container != nil { | |
| 417 | 411 | webkit.WidgetSetVisible(pane.container.Ptr(), false) | |
| 418 | - | webkit.WidgetAddCSSClass(pane.container.Ptr(), "stacked-pane-collapsed") | |
| 419 | 412 | } | |
| 420 | 413 | if pane.titleBar != nil { | |
| 421 | 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 | 420 | // NavigateStack handles navigation within a stacked pane container | |
| 431 | 421 | func (spm *StackedPaneManager) NavigateStack(direction string) bool { | |
| 432 | - | if spm.wm.currentlyFocused == nil { | |
| 422 | + | if spm.wm.GetActiveNode() == nil { | |
| 433 | 423 | return false | |
| 434 | 424 | } | |
| 435 | 425 | ||
| 436 | 426 | // Find the stack container this pane belongs to | |
| 437 | 427 | var stackNode *paneNode | |
| 438 | - | current := spm.wm.currentlyFocused | |
| 428 | + | current := spm.wm.GetActiveNode() | |
| 439 | 429 | ||
| 440 | 430 | // Check if current pane is directly in a stack | |
| 441 | 431 | if current.parent != nil && current.parent.isStacked { | |
| @@ -498,8 +488,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool { | |||
| 498 | 488 | ||
| 499 | 489 | // Focus the new active pane | |
| 500 | 490 | newActivePane := stackNode.stackedPanes[newIndex] | |
| 501 | - | spm.wm.focusManager.SetActivePane(newActivePane) | |
| 502 | - | spm.wm.currentlyFocused = newActivePane | |
| 491 | + | spm.wm.SetActivePane(newActivePane, SourceStackNav) | |
| 503 | 492 | ||
| 504 | 493 | log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d", | |
| 505 | 494 | direction, currentIndex, newIndex, len(stackNode.stackedPanes)) | |
| @@ -787,14 +776,15 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 787 | 776 | // FIXED: Focus the remaining pane if any pane from this stack was currently focused | |
| 788 | 777 | // Check if currentlyFocused is part of this stack (including the pane being closed) | |
| 789 | 778 | shouldFocus := false | |
| 790 | - | if spm.wm.currentlyFocused != nil { | |
| 779 | + | currentlyFocused := spm.wm.GetActiveNode() | |
| 780 | + | if currentlyFocused != nil { | |
| 791 | 781 | log.Printf("[workspace] DEBUG: currentlyFocused=%p, node being closed=%p, lastPane=%p", | |
| 792 | - | spm.wm.currentlyFocused, node, lastPane) | |
| 782 | + | currentlyFocused, node, lastPane) | |
| 793 | 783 | // Check if currentlyFocused is the pane being closed | |
| 794 | - | if spm.wm.currentlyFocused == node { | |
| 784 | + | if currentlyFocused == node { | |
| 795 | 785 | shouldFocus = true | |
| 796 | 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 | 788 | // Check if currentlyFocused is another pane in this stack | |
| 799 | 789 | shouldFocus = true | |
| 800 | 790 | log.Printf("[workspace] DEBUG: shouldFocus=true (focused pane in same stack)") | |
| @@ -811,14 +801,10 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 811 | 801 | lastPaneWidget, visible) | |
| 812 | 802 | } | |
| 813 | 803 | ||
| 814 | - | // Update CSS classes (mirror finalizeStackCreation) | |
| 815 | - | spm.wm.ensurePaneBaseClasses() | |
| 816 | - | ||
| 817 | 804 | // Mirror the exact logic from finalizeStackCreation that works perfectly: | |
| 818 | 805 | // 1. First call SetActivePane (this does all the focus work) | |
| 819 | 806 | // 2. Then set currentlyFocused | |
| 820 | - | spm.wm.focusManager.SetActivePane(lastPane) | |
| 821 | - | spm.wm.currentlyFocused = lastPane | |
| 807 | + | spm.wm.SetActivePane(lastPane, SourceClose) | |
| 822 | 808 | ||
| 823 | 809 | // CRITICAL: Ensure widget visibility and focus after reparenting | |
| 824 | 810 | // The widget may need explicit show/focus calls after being reparented | |
| @@ -852,18 +838,15 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 852 | 838 | spm.UpdateStackVisibility(stackNode) | |
| 853 | 839 | ||
| 854 | 840 | // 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 | 842 | newActivePaneInStack := stackNode.stackedPanes[stackNode.activeStackIndex] | |
| 857 | - | spm.wm.currentlyFocused = newActivePaneInStack | |
| 858 | - | spm.wm.focusManager.SetActivePane(newActivePaneInStack) | |
| 843 | + | spm.wm.SetActivePane(newActivePaneInStack, SourceClose) | |
| 859 | 844 | } | |
| 860 | 845 | ||
| 861 | 846 | log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d", | |
| 862 | 847 | len(stackNode.stackedPanes), stackNode.activeStackIndex) | |
| 863 | 848 | } | |
| 864 | 849 | ||
| 865 | - | // Update CSS classes after pane count changes | |
| 866 | - | spm.wm.ensurePaneBaseClasses() | |
| 867 | 850 | log.Printf("[workspace] stacked pane closed; panes remaining=%d", len(spm.wm.app.panes)) | |
| 868 | 851 | ||
| 869 | 852 | 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 conflicts | 341 | // 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 synchronously | 361 | // 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 bar | 400 | // 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 pane | 405 | 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 bar | 409 | // 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 container | 420 | // 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 false | 423 | return false | ||
| 434 | } | 424 | } | ||
| 435 | 425 | ||||
| 436 | // Find the stack container this pane belongs to | 426 | // Find the stack container this pane belongs to | ||
| 437 | var stackNode *paneNode | 427 | var stackNode *paneNode | ||
| 438 | - | current := spm.wm.currentlyFocused | 428 | + | current := spm.wm.GetActiveNode() |
| 439 | 429 | ||||
| 440 | // Check if current pane is directly in a stack | 430 | // 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 pane | 489 | // 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 focused | 776 | // 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 := false | 778 | 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 closed | 783 | // Check if currentlyFocused is the pane being closed | ||
| 794 | - | if spm.wm.currentlyFocused == node { | 784 | + | if currentlyFocused == node { |
| 795 | shouldFocus = true | 785 | 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 stack | 788 | // Check if currentlyFocused is another pane in this stack | ||
| 799 | shouldFocus = true | 789 | 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 currentlyFocused | 806 | // 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 reparenting | 809 | // CRITICAL: Ensure widget visibility and focus after reparenting | ||
| 824 | // The widget may need explicit show/focus calls after being reparented | 810 | // 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 one | 840 | // 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 = newActivePaneInStack | 843 | + | 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 nil | 852 | return nil | ||
M internal/app/browser/workspace_css.go +7 -67
| @@ -35,8 +35,8 @@ func getStackTitleTextColor(isDark bool) string { | |||
| 35 | 35 | return "#333333" | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 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 { | |
| 40 | 40 | cfg := config.Get() | |
| 41 | 41 | styling := cfg.Workspace.Styling | |
| 42 | 42 | ||
| @@ -51,34 +51,10 @@ func (wm *WorkspaceManager) generateActivePaneCSS() string { | |||
| 51 | 51 | 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 | 54 | css := fmt.Sprintf(`window { | |
| 58 | 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 | 58 | /* Stacked panes styling */ | |
| 83 | 59 | .stacked-pane-container { | |
| 84 | 60 | background-color: %s; | |
| @@ -110,18 +86,7 @@ func (wm *WorkspaceManager) generateActivePaneCSS() string { | |||
| 110 | 86 | .stacked-pane-collapsed { | |
| 111 | 87 | /* Collapsed panes are hidden - handled in code via widget visibility */ | |
| 112 | 88 | }`, | |
| 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 | |
| 125 | 90 | windowBackgroundColor, // stacked-pane-container background | |
| 126 | 91 | styling.BorderRadius, // stacked-pane-container border-radius | |
| 127 | 92 | getStackTitleBg(isDark), // stacked-pane-title background | |
| @@ -131,40 +96,15 @@ func (wm *WorkspaceManager) generateActivePaneCSS() string { | |||
| 131 | 96 | 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 | 99 | return css | |
| 138 | 100 | } | |
| 139 | 101 | ||
| 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() { | |
| 142 | 104 | if wm == nil || wm.cssInitialized { | |
| 143 | 105 | return | |
| 144 | 106 | } | |
| 145 | - | activePaneCSS := wm.generateActivePaneCSS() | |
| 146 | - | webkit.AddCSSProvider(activePaneCSS) | |
| 107 | + | stackedPaneCSS := wm.generateStackedPaneCSS() | |
| 108 | + | webkit.AddCSSProvider(stackedPaneCSS) | |
| 147 | 109 | 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 | - | } | |
| @@ -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 config | 38 | + | // 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.Styling | 41 | styling := cfg.Workspace.Styling | ||
| 42 | 42 | ||||
| @@ -51,34 +51,10 @@ func (wm *WorkspaceManager) generateActivePaneCSS() string { | |||||
| 51 | windowBackgroundColor = "#ffffff" // Light window background | 51 | 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 background | 90 | windowBackgroundColor, // stacked-pane-container background | ||
| 126 | styling.BorderRadius, // stacked-pane-container border-radius | 91 | styling.BorderRadius, // stacked-pane-container border-radius | ||
| 127 | getStackTitleBg(isDark), // stacked-pane-title background | 92 | getStackTitleBg(isDark), // stacked-pane-title background | ||
| @@ -131,40 +96,15 @@ func (wm *WorkspaceManager) generateActivePaneCSS() string { | |||||
| 131 | getStackTitleTextColor(isDark), // stacked-pane-title-text color | 96 | 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 css | 99 | return css | ||
| 138 | } | 100 | } | ||
| 139 | 101 | ||||
| 140 | - | // ensureStyles ensures that CSS styles are applied to the workspace | 102 | + | // 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 | return | 105 | return | ||
| 144 | } | 106 | } | ||
| 145 | - | activePaneCSS := wm.generateActivePaneCSS() | 107 | + | stackedPaneCSS := wm.generateStackedPaneCSS() |
| 146 | - | webkit.AddCSSProvider(activePaneCSS) | 108 | + | webkit.AddCSSProvider(stackedPaneCSS) |
| 147 | wm.cssInitialized = true | 109 | 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 { | |||
| 45 | 45 | ||
| 46 | 46 | // Specialized managers for different pane operations | |
| 47 | 47 | stackedPaneManager *StackedPaneManager | |
| 48 | - | focusManager *FocusManager | |
| 49 | - | ||
| 50 | - | // Current focused pane (Single Source of Truth for focus) | |
| 51 | - | currentlyFocused *paneNode | |
| 48 | + | focusStateMachine *FocusStateMachine | |
| 52 | 49 | ||
| 53 | 50 | // Widget operation synchronization to prevent GTK race conditions | |
| 54 | 51 | widgetMutex sync.Mutex | |
| @@ -71,7 +68,7 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 71 | 68 | ||
| 72 | 69 | // Initialize specialized managers | |
| 73 | 70 | manager.stackedPaneManager = NewStackedPaneManager(manager) | |
| 74 | - | manager.focusManager = NewFocusManager(manager) | |
| 71 | + | manager.focusStateMachine = NewFocusStateMachine(manager) | |
| 75 | 72 | manager.createWebViewFn = func() (*webkit.WebView, error) { | |
| 76 | 73 | if manager.app == nil { | |
| 77 | 74 | return nil, errors.New("workspace manager missing app reference") | |
| @@ -91,7 +88,7 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 91 | 88 | } | |
| 92 | 89 | return manager.app.createPaneForView(view) | |
| 93 | 90 | } | |
| 94 | - | manager.ensureStyles() | |
| 91 | + | manager.ensureStackedPaneStyles() | |
| 95 | 92 | ||
| 96 | 93 | rootContainer := rootPane.webView.RootWidget() | |
| 97 | 94 | root := &paneNode{ | |
| @@ -111,11 +108,21 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 111 | 108 | } | |
| 112 | 109 | ||
| 113 | 110 | 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 | + | ||
| 119 | 126 | return manager | |
| 120 | 127 | } | |
| 121 | 128 | ||
| @@ -151,8 +158,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||
| 151 | 158 | wm.paneModeActive = true | |
| 152 | 159 | wm.paneModeSource = source | |
| 153 | 160 | wm.lastPaneModeEntry = time.Now() | |
| 154 | - | wm.currentlyFocused = node | |
| 155 | - | wm.focusManager.SetActivePane(node) | |
| 161 | + | wm.SetActivePane(node, SourceProgrammatic) | |
| 156 | 162 | case "pane-confirmed", "pane-cancelled", "pane-mode-exited": | |
| 157 | 163 | // Debounce pane-mode-exited events to prevent duplicate focus calls | |
| 158 | 164 | if event == "pane-mode-exited" { | |
| @@ -185,8 +191,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||
| 185 | 191 | // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus | |
| 186 | 192 | wm.closeCurrentPane() | |
| 187 | 193 | case "pane-split": | |
| 188 | - | wm.currentlyFocused = node | |
| 189 | - | wm.focusManager.SetActivePane(node) | |
| 194 | + | wm.SetActivePane(node, SourceProgrammatic) | |
| 190 | 195 | direction := strings.ToLower(msg.Direction) | |
| 191 | 196 | if direction == "" { | |
| 192 | 197 | direction = "right" | |
| @@ -212,8 +217,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||
| 212 | 217 | wm.clonePaneState(node, newNode) | |
| 213 | 218 | wm.splitting = false | |
| 214 | 219 | case "pane-stack": | |
| 215 | - | wm.currentlyFocused = node | |
| 216 | - | wm.focusManager.SetActivePane(node) | |
| 220 | + | wm.SetActivePane(node, SourceProgrammatic) | |
| 217 | 221 | if last, ok := wm.lastSplitMsg[source]; ok { | |
| 218 | 222 | if time.Since(last) < 200*time.Millisecond { | |
| 219 | 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 | 336 | // GetActiveNode returns the currently active pane node | |
| 333 | 337 | 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 | + | } | |
| 335 | 346 | } | |
| 336 | 347 | ||
| 337 | 348 | // GetNodeForWebView returns the pane node associated with a WebView | |
| @@ -45,10 +45,7 @@ type WorkspaceManager struct { | |||||
| 45 | 45 | ||||
| 46 | // Specialized managers for different pane operations | 46 | // Specialized managers for different pane operations | ||
| 47 | stackedPaneManager *StackedPaneManager | 47 | stackedPaneManager *StackedPaneManager | ||
| 48 | - | focusManager *FocusManager | 48 | + | 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 conditions | 50 | // Widget operation synchronization to prevent GTK race conditions | ||
| 54 | widgetMutex sync.Mutex | 51 | widgetMutex sync.Mutex | ||
| @@ -71,7 +68,7 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||||
| 71 | 68 | ||||
| 72 | // Initialize specialized managers | 69 | // 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 = manager | 110 | app.workspace = manager | ||
| 114 | - | manager.currentlyFocused = root | 111 | + | |
| 115 | - | manager.focusManager.SetActivePane(root) | 112 | + | // Configure debug settings from app config |
| 116 | - | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | 113 | + | if app.config != nil { |
| 117 | - | // Ensure initial CSS classes are applied | 114 | + | 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 manager | 126 | return manager | ||
| 120 | } | 127 | } | ||
| 121 | 128 | ||||
| @@ -151,8 +158,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||||
| 151 | wm.paneModeActive = true | 158 | wm.paneModeActive = true | ||
| 152 | wm.paneModeSource = source | 159 | wm.paneModeSource = source | ||
| 153 | wm.lastPaneModeEntry = time.Now() | 160 | wm.lastPaneModeEntry = time.Now() | ||
| 154 | - | wm.currentlyFocused = node | 161 | + | 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 calls | 163 | // 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 focus | 191 | // 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 = node | 194 | + | 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 = false | 218 | wm.splitting = false | ||
| 214 | case "pane-stack": | 219 | case "pane-stack": | ||
| 215 | - | wm.currentlyFocused = node | 220 | + | 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 node | 336 | // GetActiveNode returns the currently active pane node | ||
| 333 | func (wm *WorkspaceManager) GetActiveNode() *paneNode { | 337 | 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 | + | } | |||
| 335 | } | 346 | } | ||
| 336 | 347 | ||||
| 337 | // GetNodeForWebView returns the pane node associated with a WebView | 348 | // 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) | |||
| 52 | 52 | if reason != "" { | |
| 53 | 53 | log.Printf("[workspace] %s: preserving active stacked pane index=%d", reason, activeIndex) | |
| 54 | 54 | } | |
| 55 | - | wm.currentlyFocused = activePane | |
| 56 | - | wm.focusManager.SetActivePane(activePane) | |
| 55 | + | wm.SetActivePane(activePane, SourceKeyboard) | |
| 57 | 56 | return | |
| 58 | 57 | } | |
| 59 | 58 | } | |
| 60 | 59 | } | |
| 61 | 60 | ||
| 62 | - | wm.currentlyFocused = node | |
| 63 | - | wm.focusManager.SetActivePane(node) | |
| 61 | + | wm.SetActivePane(node, SourceKeyboard) | |
| 64 | 62 | } | |
| 65 | 63 | ||
| 66 | 64 | // focusByView changes focus to the pane containing the specified WebView | |
| @@ -80,7 +78,7 @@ func (wm *WorkspaceManager) focusByView(view *webkit.WebView) { | |||
| 80 | 78 | wm.focusThrottleMutex.Unlock() | |
| 81 | 79 | ||
| 82 | 80 | if node, ok := wm.viewToNode[view]; ok { | |
| 83 | - | if wm.currentlyFocused != node { | |
| 81 | + | if wm.GetActiveNode() != node { | |
| 84 | 82 | wm.focusRespectingStack(node, "focus-by-view") | |
| 85 | 83 | } | |
| 86 | 84 | } | |
| @@ -101,8 +99,7 @@ func (wm *WorkspaceManager) ensureHover(node *paneNode) { | |||
| 101 | 99 | if wm == nil { | |
| 102 | 100 | return | |
| 103 | 101 | } | |
| 104 | - | wm.focusManager.SetActivePane(node) | |
| 105 | - | wm.currentlyFocused = node | |
| 102 | + | wm.SetActivePane(node, SourceMouse) | |
| 106 | 103 | }) | |
| 107 | 104 | return nil | |
| 108 | 105 | }) | |
| @@ -148,13 +145,14 @@ func (wm *WorkspaceManager) FocusNeighbor(direction string) bool { | |||
| 148 | 145 | ||
| 149 | 146 | // navigateStack handles navigation within a stacked pane container. | |
| 150 | 147 | func (wm *WorkspaceManager) navigateStack(direction string) bool { | |
| 151 | - | if wm.currentlyFocused == nil { | |
| 148 | + | currentFocused := wm.GetActiveNode() | |
| 149 | + | if currentFocused == nil { | |
| 152 | 150 | return false | |
| 153 | 151 | } | |
| 154 | 152 | ||
| 155 | 153 | // Find the stack container this pane belongs to | |
| 156 | 154 | var stackNode *paneNode | |
| 157 | - | current := wm.currentlyFocused | |
| 155 | + | current := currentFocused | |
| 158 | 156 | ||
| 159 | 157 | // Check if current pane is directly in a stack | |
| 160 | 158 | if current.parent != nil && current.parent.isStacked { | |
| @@ -211,8 +209,7 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool { | |||
| 211 | 209 | ||
| 212 | 210 | // Focus the new active pane | |
| 213 | 211 | newActivePane := stackNode.stackedPanes[newIndex] | |
| 214 | - | wm.focusManager.SetActivePane(newActivePane) | |
| 215 | - | wm.currentlyFocused = newActivePane | |
| 212 | + | wm.SetActivePane(newActivePane, SourceStackNav) | |
| 216 | 213 | ||
| 217 | 214 | log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d", | |
| 218 | 215 | direction, currentIndex, newIndex, len(stackNode.stackedPanes)) | |
| @@ -221,19 +218,19 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool { | |||
| 221 | 218 | ||
| 222 | 219 | // focusAdjacent uses geometry-based calculations to find and focus adjacent panes | |
| 223 | 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 | 223 | return false | |
| 226 | 224 | } | |
| 227 | 225 | ||
| 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) | |
| 231 | 228 | return true | |
| 232 | 229 | } | |
| 233 | 230 | ||
| 234 | 231 | var currentBounds webkit.WidgetBounds | |
| 235 | 232 | var ok bool | |
| 236 | - | wm.currentlyFocused.container.Execute(func(containerPtr uintptr) error { | |
| 233 | + | currentFocused.container.Execute(func(containerPtr uintptr) error { | |
| 237 | 234 | currentBounds, ok = webkit.WidgetGetBounds(containerPtr) | |
| 238 | 235 | return nil | |
| 239 | 236 | }) | |
| @@ -251,7 +248,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 251 | 248 | var debugCandidates []string | |
| 252 | 249 | ||
| 253 | 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 | 252 | continue | |
| 256 | 253 | } | |
| 257 | 254 | var bounds webkit.WidgetBounds | |
| @@ -305,13 +302,12 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 305 | 302 | } | |
| 306 | 303 | ||
| 307 | 304 | if best != nil { | |
| 308 | - | wm.focusManager.SetActivePane(best) | |
| 309 | - | wm.currentlyFocused = best | |
| 305 | + | wm.SetActivePane(best, SourceKeyboard) | |
| 310 | 306 | return true | |
| 311 | 307 | } | |
| 312 | 308 | ||
| 313 | 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 | 312 | return false | |
| 317 | 313 | } | |
| @@ -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 = activePane | 55 | + | wm.SetActivePane(activePane, SourceKeyboard) |
| 56 | - | wm.focusManager.SetActivePane(activePane) | |||
| 57 | return | 56 | return | ||
| 58 | } | 57 | } | ||
| 59 | } | 58 | } | ||
| 60 | } | 59 | } | ||
| 61 | 60 | ||||
| 62 | - | wm.currentlyFocused = node | 61 | + | wm.SetActivePane(node, SourceKeyboard) |
| 63 | - | wm.focusManager.SetActivePane(node) | |||
| 64 | } | 62 | } | ||
| 65 | 63 | ||||
| 66 | // focusByView changes focus to the pane containing the specified WebView | 64 | // 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 | return | 100 | return | ||
| 103 | } | 101 | } | ||
| 104 | - | wm.focusManager.SetActivePane(node) | 102 | + | wm.SetActivePane(node, SourceMouse) |
| 105 | - | wm.currentlyFocused = node | |||
| 106 | }) | 103 | }) | ||
| 107 | return nil | 104 | 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 false | 150 | return false | ||
| 153 | } | 151 | } | ||
| 154 | 152 | ||||
| 155 | // Find the stack container this pane belongs to | 153 | // Find the stack container this pane belongs to | ||
| 156 | var stackNode *paneNode | 154 | var stackNode *paneNode | ||
| 157 | - | current := wm.currentlyFocused | 155 | + | current := currentFocused |
| 158 | 156 | ||||
| 159 | // Check if current pane is directly in a stack | 157 | // 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 pane | 210 | // 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 panes | 219 | // 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 false | 223 | 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 true | 228 | return true | ||
| 232 | } | 229 | } | ||
| 233 | 230 | ||||
| 234 | var currentBounds webkit.WidgetBounds | 231 | var currentBounds webkit.WidgetBounds | ||
| 235 | var ok bool | 232 | 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 nil | 235 | return nil | ||
| 239 | }) | 236 | }) | ||
| @@ -251,7 +248,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||||
| 251 | var debugCandidates []string | 248 | 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 | continue | 252 | continue | ||
| 256 | } | 253 | } | ||
| 257 | var bounds webkit.WidgetBounds | 254 | 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 true | 306 | 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 false | 312 | 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) { | |||
| 103 | 103 | ||
| 104 | 104 | // closeCurrentPane closes the currently focused pane | |
| 105 | 105 | func (wm *WorkspaceManager) closeCurrentPane() { | |
| 106 | - | if wm == nil || wm.currentlyFocused == nil { | |
| 106 | + | if wm == nil || wm.GetActiveNode() == nil { | |
| 107 | 107 | return | |
| 108 | 108 | } | |
| 109 | - | if err := wm.closePane(wm.currentlyFocused); err != nil { | |
| 109 | + | if err := wm.closePane(wm.GetActiveNode()); err != nil { | |
| 110 | 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 | 176 | 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() | |
| 179 | 179 | } | |
| 180 | 180 | } | |
| 181 | 181 | } | |
| @@ -365,14 +365,16 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||
| 365 | 365 | } | |
| 366 | 366 | ||
| 367 | 367 | // Update CSS classes for all panes now that we have multiple panes | |
| 368 | - | wm.ensurePaneBaseClasses() | |
| 369 | 368 | ||
| 370 | 369 | webkit.IdleAdd(func() bool { | |
| 371 | 370 | if newContainer != 0 { | |
| 372 | 371 | webkit.WidgetShow(newContainer) | |
| 373 | 372 | } | |
| 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) | |
| 376 | 378 | return false | |
| 377 | 379 | }) | |
| 378 | 380 | ||
| @@ -596,14 +598,16 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 596 | 598 | } | |
| 597 | 599 | ||
| 598 | 600 | // Update CSS classes for all panes now that we have multiple panes | |
| 599 | - | wm.ensurePaneBaseClasses() | |
| 600 | 601 | ||
| 601 | 602 | webkit.IdleAdd(func() bool { | |
| 602 | 603 | if newContainer != 0 { | |
| 603 | 604 | webkit.WidgetShow(newContainer) | |
| 604 | 605 | } | |
| 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) | |
| 607 | 611 | return false | |
| 608 | 612 | }) | |
| 609 | 613 | ||
| @@ -718,7 +722,6 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 718 | 722 | } | |
| 719 | 723 | ||
| 720 | 724 | wm.root = nil | |
| 721 | - | wm.currentlyFocused = nil | |
| 722 | 725 | wm.mainPane = nil | |
| 723 | 726 | ||
| 724 | 727 | webkit.QuitMainLoop() | |
| @@ -769,8 +772,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 769 | 772 | } | |
| 770 | 773 | ||
| 771 | 774 | // Clear current active if it's the node being closed | |
| 772 | - | if wm.currentlyFocused == node { | |
| 773 | - | wm.currentlyFocused = nil | |
| 775 | + | if wm.GetActiveNode() == node { | |
| 774 | 776 | } | |
| 775 | 777 | ||
| 776 | 778 | // Set replacement as new root | |
| @@ -790,8 +792,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 790 | 792 | // Focus a suitable pane | |
| 791 | 793 | focusTarget := wm.leftmostLeaf(replacement) | |
| 792 | 794 | if focusTarget != nil { | |
| 793 | - | wm.currentlyFocused = focusTarget | |
| 794 | - | wm.focusManager.SetActivePane(focusTarget) | |
| 795 | + | wm.SetActivePane(focusTarget, SourceClose) | |
| 795 | 796 | } | |
| 796 | 797 | ||
| 797 | 798 | // Destroy the webview and detach hover AFTER rearranging hierarchy | |
| @@ -816,7 +817,6 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 816 | 817 | ||
| 817 | 818 | wm.updateMainPane() | |
| 818 | 819 | // Update CSS classes after pane count changes | |
| 819 | - | wm.ensurePaneBaseClasses() | |
| 820 | 820 | log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes)) | |
| 821 | 821 | return nil | |
| 822 | 822 | } | |
| @@ -841,8 +841,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 841 | 841 | } | |
| 842 | 842 | ||
| 843 | 843 | // Clear current active if it's the node being closed | |
| 844 | - | if wm.currentlyFocused == node { | |
| 845 | - | wm.currentlyFocused = nil | |
| 844 | + | if wm.GetActiveNode() == node { | |
| 846 | 845 | } | |
| 847 | 846 | ||
| 848 | 847 | grand := parent.parent | |
| @@ -897,8 +896,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 897 | 896 | // Find a suitable focus target | |
| 898 | 897 | focusTarget := wm.leftmostLeaf(sibling) | |
| 899 | 898 | if focusTarget != nil { | |
| 900 | - | wm.currentlyFocused = focusTarget | |
| 901 | - | wm.focusManager.SetActivePane(focusTarget) | |
| 899 | + | wm.SetActivePane(focusTarget, SourceClose) | |
| 902 | 900 | } | |
| 903 | 901 | ||
| 904 | 902 | // Clean up the node being closed | |
| @@ -923,7 +921,6 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 923 | 921 | ||
| 924 | 922 | wm.updateMainPane() | |
| 925 | 923 | // Update CSS classes after pane count changes | |
| 926 | - | wm.ensurePaneBaseClasses() | |
| 927 | 924 | log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes)) | |
| 928 | 925 | return nil | |
| 929 | 926 | } | |
| @@ -103,10 +103,10 @@ func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) { | |||||
| 103 | 103 | ||||
| 104 | // closeCurrentPane closes the currently focused pane | 104 | // 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 | return | 107 | 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.currentlyFocused | 178 | + | 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 panes | 367 | // 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 = newLeaf | 373 | + | // 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 false | 378 | 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 panes | 600 | // 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 = newLeaf | 606 | + | // 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 false | 611 | return false | ||
| 608 | }) | 612 | }) | ||
| 609 | 613 | ||||
| @@ -718,7 +722,6 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 718 | } | 722 | } | ||
| 719 | 723 | ||||
| 720 | wm.root = nil | 724 | wm.root = nil | ||
| 721 | - | wm.currentlyFocused = nil | |||
| 722 | wm.mainPane = nil | 725 | 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 closed | 774 | // 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 root | 778 | // Set replacement as new root | ||
| @@ -790,8 +792,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 790 | // Focus a suitable pane | 792 | // 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 = focusTarget | 795 | + | wm.SetActivePane(focusTarget, SourceClose) |
| 794 | - | wm.focusManager.SetActivePane(focusTarget) | |||
| 795 | } | 796 | } | ||
| 796 | 797 | ||||
| 797 | // Destroy the webview and detach hover AFTER rearranging hierarchy | 798 | // 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 changes | 819 | // 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 nil | 821 | 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 closed | 843 | // 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.parent | 847 | grand := parent.parent | ||
| @@ -897,8 +896,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 897 | // Find a suitable focus target | 896 | // 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 = focusTarget | 899 | + | wm.SetActivePane(focusTarget, SourceClose) |
| 901 | - | wm.focusManager.SetActivePane(focusTarget) | |||
| 902 | } | 900 | } | ||
| 903 | 901 | ||||
| 904 | // Clean up the node being closed | 902 | // 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 changes | 923 | // 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 nil | 925 | 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 | |||
| 124 | 124 | ||
| 125 | 125 | // Determine target node for splitting | |
| 126 | 126 | target := node | |
| 127 | - | if !popCfg.FollowPaneContext && wm.currentlyFocused != nil { | |
| 128 | - | target = wm.currentlyFocused | |
| 127 | + | if !popCfg.FollowPaneContext && wm.GetActiveNode() != nil { | |
| 128 | + | target = wm.GetActiveNode() | |
| 129 | 129 | } | |
| 130 | 130 | ||
| 131 | 131 | // 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 splitting | 125 | // Determine target node for splitting | ||
| 126 | target := node | 126 | target := node | ||
| 127 | - | if !popCfg.FollowPaneContext && wm.currentlyFocused != nil { | 127 | + | if !popCfg.FollowPaneContext && wm.GetActiveNode() != nil { |
| 128 | - | target = wm.currentlyFocused | 128 | + | target = wm.GetActiveNode() |
| 129 | } | 129 | } | ||
| 130 | 130 | ||||
| 131 | // Add the popup pane to the workspace using manual pane insertion | 131 | // 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 { | |||
| 215 | 215 | ||
| 216 | 216 | // Enable workspace navigation and focus debug logs | |
| 217 | 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 | 229 | // APISecurityConfig holds optional API key protection for dumb://api endpoints | |
| @@ -634,6 +643,19 @@ func (m *Manager) setDefaults() { | |||
| 634 | 643 | m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile) | |
| 635 | 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 | 659 | // Appearance defaults | |
| 638 | 660 | m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont) | |
| 639 | 661 | 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 logs | 216 | // 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 endpoints | 229 | // 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 defaults | 659 | // 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 { | |||
| 151 | 151 | LogWebKitCrashes: true, // Always log crashes | |
| 152 | 152 | EnableScriptDebug: false, | |
| 153 | 153 | EnableGeneralDebug: false, | |
| 154 | + | EnableWorkspaceDebug: false, | |
| 155 | + | EnableFocusDebug: false, | |
| 156 | + | EnableCSSDebug: false, | |
| 157 | + | EnableFocusMetrics: false, | |
| 154 | 158 | }, | |
| 155 | 159 | APISecurity: APISecurityConfig{ | |
| 156 | 160 | Token: "", | |
| @@ -151,6 +151,10 @@ func DefaultConfig() *Config { | |||||
| 151 | LogWebKitCrashes: true, // Always log crashes | 151 | 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 | |||
| 19 | 19 | // Callback function for idle handling following the existing pattern | |
| 20 | 20 | extern gboolean goIdleCallback(uintptr_t handle); | |
| 21 | 21 | extern void goHoverCallback(uintptr_t handle); | |
| 22 | + | extern void goFocusEnterCallback(uintptr_t handle); | |
| 23 | + | extern void goFocusLeaveCallback(uintptr_t handle); | |
| 22 | 24 | ||
| 23 | 25 | static gboolean idle_callback_wrapper(gpointer data) { | |
| 24 | 26 | return goIdleCallback((uintptr_t)data); | |
| @@ -222,6 +224,41 @@ static void label_set_max_width_chars(GtkWidget* label, int n_chars) { | |||
| 222 | 224 | if (!label) return; | |
| 223 | 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 | 263 | import "C" | |
| 227 | 264 | ||
| @@ -239,12 +276,24 @@ type WidgetBounds struct { | |||
| 239 | 276 | 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 | 285 | var ( | |
| 243 | 286 | hoverMu sync.Mutex | |
| 244 | 287 | hoverCallbacks = make(map[uintptr]func()) | |
| 245 | 288 | hoverControllers = make(map[uintptr]uintptr) | |
| 246 | 289 | nextHoverID uintptr = 1 | |
| 247 | 290 | // 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 | 299 | func widgetIsValid(widget uintptr) bool { | |
| @@ -779,3 +828,84 @@ func WidgetHasCSSClass(widget uintptr, class string) bool { | |||
| 779 | 828 | result := C.gtk_widget_has_css_class((*C.GtkWidget)(unsafe.Pointer(widget)), cstr) | |
| 780 | 829 | 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 | + | } | |
| @@ -19,6 +19,8 @@ package webkit | |||||
| 19 | // Callback function for idle handling following the existing pattern | 19 | // 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 float64 | 276 | 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.Mutex | 286 | 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 = 1 | 289 | nextHoverID uintptr = 1 | ||
| 247 | // gtkTestInitOnce removed - no longer needed | 290 | // 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 != 0 | 829 | 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) { | |||
| 370 | 370 | // 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 | 383 | // SimulateFocusForTesting gives focus to a widget for test scenarios | |
| 374 | 384 | func SimulateFocusForTesting(widget uintptr) { | |
| 375 | 385 | WidgetGrabFocus(widget) | |
| @@ -370,6 +370,16 @@ func WidgetQueueDraw(widget uintptr) { | |||||
| 370 | // Stub implementation - no-op | 370 | // 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 scenarios | 383 | // 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) | ||