1// Package component provides UI components for the browser.
2package component
3
4import (
5 "context"
6 "errors"
7 "sync"
8 "sync/atomic"
9 "time"
10
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/dumber/internal/ui/layout"
14 "github.com/bnema/puregotk/v4/glib"
15 "github.com/rs/zerolog"
16)
17
18// ErrNilWorkspace is returned when attempting to set a nil workspace.
19var ErrNilWorkspace = errors.New("workspace is nil")
20
21// ErrPaneNotFound is returned when a pane ID cannot be found.
22var ErrPaneNotFound = errors.New("pane not found")
23
24// WorkspaceView is the top-level container that renders a workspace's pane tree.
25// It manages the widget tree and handles active pane state.
26type WorkspaceView struct {
27 factory layout.WidgetFactory
28 treeRenderer *layout.TreeRenderer
29 container layout.BoxWidget
30 overlay layout.OverlayWidget // Wraps container for mode borders
31 rootWidget layout.Widget // Current root widget for removal on rebuild
32 logger zerolog.Logger
33
34 // Mode border overlay slot
35 modeBorderWidget layout.Widget
36
37 // Omnibox lifecycle
38 omnibox *Omnibox // Current omnibox (nil if not shown)
39 omniboxWidget layout.Widget // Wrapped omnibox widget for overlay
40 omniboxPaneID entity.PaneID // Which pane has the omnibox
41 omniboxCfg OmniboxConfig // Stored config for creating omniboxes
42
43 // Find bar lifecycle
44 findBar *FindBar // Current find bar (nil if not shown)
45 findBarWidget layout.Widget // Wrapped find bar widget for overlay
46 findBarPaneID entity.PaneID // Which pane has the find bar
47 findBarCfg FindBarConfig // Stored config for creating find bars
48
49 workspace *entity.Workspace
50 paneViews map[entity.PaneID]*PaneView
51
52 onPaneFocused func(paneID entity.PaneID)
53 onActivePaneChanged func(paneID entity.PaneID)
54 onWebViewAttached func(paneID entity.PaneID)
55 onSplitRatioDragged func(nodeID string, ratio float64)
56 onRebuilt func()
57
58 // Hover suppression for keyboard navigation (Issue #89)
59 // Prevents hover focus from overriding keyboard-initiated focus changes
60 hoverSuppressedUntil time.Time
61 hoverFocusLocked atomic.Bool
62
63 // newPaneFocusLock suppresses hover focus after a new pane is created.
64 // Rule: new pane creation always wins focus. Hover focus is blocked
65 // until the user intentionally moves the mouse (motion event), at which
66 // point normal hover-follows-mouse behavior resumes. This prevents
67 // GTK's synthetic enter events (fired on widget rearrangement) from
68 // stealing focus away from a freshly created pane.
69 newPaneFocusLock atomic.Bool
70
71 // Auto-open omnibox on new pane creation
72 autoOpenOnNewPane bool
73
74 mu sync.RWMutex
75}
76
77// SetPaneViewsForTest injects pane views into the workspace view for testing.
78// This is a narrow testability seam; production code populates pane views via
79// SetWorkspace and the tree renderer. The workspace field must be set
80// separately on the entity if the tests exercise code paths that read it.
81func (wv *WorkspaceView) SetPaneViewsForTest(paneViews map[entity.PaneID]*PaneView) {
82 wv.mu.Lock()
83 defer wv.mu.Unlock()
84 wv.paneViews = paneViews
85}
86
87// paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
88// so TreeRenderer can create PaneViews.
89type paneViewFactoryAdapter struct {
90 wv *WorkspaceView
91 ctx context.Context
92}
93
94func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Widget {
95 if node == nil || node.Pane == nil {
96 return nil
97 }
98
99 // Create PaneView without WebView widget for now
100 // WebView will be attached later by the application layer
101 pv := NewPaneView(a.ctx, a.wv.factory, node.Pane.ID, nil)
102
103 // Store in map for later lookup
104 // Note: Caller (SetWorkspace) already holds the lock, so we access directly
105 a.wv.paneViews[node.Pane.ID] = pv
106
107 // Set up focus callback
108 pv.SetOnFocusIn(func(paneID entity.PaneID) {
109 a.wv.mu.RLock()
110 callback := a.wv.onPaneFocused
111 a.wv.mu.RUnlock()
112
113 if callback != nil {
114 callback(paneID)
115 }
116 })
117
118 // Clear new-pane focus lock on intentional mouse motion.
119 // This allows hover-follows-mouse to resume after the user moves the
120 // mouse, while keeping focus on the new pane until that happens.
121 pv.SetOnMouseMotion(func() {
122 a.wv.newPaneFocusLock.Store(false)
123 })
124
125 // Set up hover callback for focus-follows-mouse
126 pv.SetOnHover(func(paneID entity.PaneID) {
127 // Skip if hover is suppressed (keyboard navigation just happened)
128 if a.wv.IsHoverSuppressed() {
129 return
130 }
131
132 if a.wv.IsHoverFocusLocked() {
133 return
134 }
135
136 // Skip if new pane was just created — focus stays on new pane
137 // until the user intentionally moves the mouse (see SetOnMouseMotion).
138 if a.wv.newPaneFocusLock.Load() {
139 return
140 }
141
142 // Skip if this pane is already active
143 if a.wv.GetActivePaneID() == paneID {
144 return
145 }
146
147 // Activate the hovered pane and grab focus
148 if err := a.wv.SetActivePaneID(paneID); err == nil {
149 a.wv.FocusPane(paneID)
150 }
151 })
152
153 // Attach hover handler with debouncing
154 pv.AttachHoverHandler(a.ctx)
155
156 return pv.Widget()
157}
158
159// NewWorkspaceView creates a new workspace view.
160func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *WorkspaceView {
161 log := logging.FromContext(ctx)
162
163 container := factory.NewBox(layout.OrientationVertical, 0)
164 container.SetHexpand(true)
165 container.SetVexpand(true)
166 container.SetVisible(true)
167
168 // Wrap container in overlay for mode borders
169 overlay := factory.NewOverlay()
170 overlay.SetHexpand(true)
171 overlay.SetVexpand(true)
172 overlay.SetChild(container)
173 overlay.SetVisible(true)
174
175 wv := &WorkspaceView{
176 factory: factory,
177 container: container,
178 overlay: overlay,
179 logger: log.With().Str("component", "workspace-view").Logger(),
180 paneViews: make(map[entity.PaneID]*PaneView),
181 }
182
183 // Create tree renderer with our adapter as the pane view factory
184 wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})
185 wv.treeRenderer.SetOnSplitRatioChanged(func(nodeID string, ratio float64) {
186 wv.mu.RLock()
187 cb := wv.onSplitRatioDragged
188 wv.mu.RUnlock()
189 if cb != nil {
190 cb(nodeID, ratio)
191 }
192 })
193
194 return wv
195}
196
197// SetWorkspace sets the workspace to render and builds the widget tree.
198func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) (buildErr error) {
199 if ws == nil {
200 return ErrNilWorkspace
201 }
202
203 wv.mu.Lock()
204 defer func() {
205 callback := wv.onRebuilt
206 wv.mu.Unlock()
207 if buildErr == nil && callback != nil {
208 callback()
209 }
210 }()
211
212 // Release previous views before dropping their ownership. This also detaches
213 // hover controllers and callback closures before their widgets are rebuilt.
214 for _, pv := range wv.paneViews {
215 if pv != nil {
216 pv.Cleanup()
217 }
218 }
219
220 // Clear previous state
221 wv.workspace = ws
222 wv.paneViews = make(map[entity.PaneID]*PaneView)
223
224 // Remove old root widget from container before building new tree
225 if wv.rootWidget != nil {
226 wv.container.Remove(wv.rootWidget)
227 wv.rootWidget = nil
228 }
229
230 // Build new tree
231 if ws.Root != nil {
232 widget, err := wv.treeRenderer.Build(ctx, ws.Root)
233 if err != nil {
234 return err
235 }
236
237 if widget != nil {
238 widget.SetVisible(true)
239 wv.container.Append(widget)
240 wv.rootWidget = widget
241 }
242 }
243
244 // Set initial active pane. A persisted workspace can contain a stale active
245 // pane ID after restore/rebuild; choose a valid fallback instead of leaving
246 // later active-pane logic pointing at a missing pane.
247 wv.restoreActivePaneInternal()
248
249 // Update single-pane mode based on pane count
250 wv.updateSinglePaneModeInternal()
251
252 return nil
253}
254
255// SetActivePaneID updates which pane is visually marked as active.
256func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error {
257 wv.mu.Lock()
258 currentActiveID := wv.getActivePaneIDInternal()
259 err := wv.setActivePaneIDInternal(paneID)
260 callback := wv.onActivePaneChanged
261 wv.mu.Unlock()
262 if err != nil {
263 return err
264 }
265 if currentActiveID != paneID && callback != nil {
266 callback(paneID)
267 }
268 return nil
269}
270
271// restoreActivePaneInternal restores the active pane after building paneViews.
272// Must be called with the lock held.
273func (wv *WorkspaceView) restoreActivePaneInternal() {
274 if wv.workspace == nil {
275 return
276 }
277
278 paneID := wv.workspace.ActivePaneID
279 if paneID != "" {
280 if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
281 _ = wv.setActivePaneIDInternal(paneID)
282 return
283 }
284 wv.logger.Debug().Str("pane_id", string(paneID)).Msg("stale active pane ignored")
285 wv.workspace.ActivePaneID = ""
286 }
287
288 if fallbackID, ok := wv.firstPaneIDInTreeInternal(); ok {
289 _ = wv.setActivePaneIDInternal(fallbackID)
290 }
291}
292
293func (wv *WorkspaceView) firstPaneIDInTreeInternal() (entity.PaneID, bool) {
294 if wv.workspace == nil {
295 return "", false
296 }
297 return wv.firstPaneIDInNodeInternal(wv.workspace.Root)
298}
299
300func (wv *WorkspaceView) firstPaneIDInNodeInternal(node *entity.PaneNode) (entity.PaneID, bool) {
301 if node == nil {
302 return "", false
303 }
304 if node.Pane != nil {
305 paneID := node.Pane.ID
306 pv, ok := wv.paneViews[paneID]
307 return paneID, ok && pv != nil
308 }
309 for _, child := range node.Children {
310 if paneID, ok := wv.firstPaneIDInNodeInternal(child); ok {
311 return paneID, true
312 }
313 }
314 return "", false
315}
316
317// setActivePaneIDInternal updates active pane without locking.
318// Updates the domain model as the single source of truth.
319func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
320 currentActiveID := wv.getActivePaneIDInternal()
321
322 wv.logger.Debug().
323 Str("from_pane", string(currentActiveID)).
324 Str("to_pane", string(paneID)).
325 Msg("active pane changing")
326
327 // Validate the target pane before mutating UI state. Invalid/stale IDs
328 // should leave the current active pane styling and overlays untouched.
329 newPV, ok := wv.paneViews[paneID]
330 if !ok || newPV == nil {
331 return ErrPaneNotFound
332 }
333
334 // Destroy find bar if active pane is changing
335 if currentActiveID != paneID && wv.findBar != nil {
336 wv.hideFindBarInternal()
337 }
338
339 // Hide omnibox if active pane is changing
340 if currentActiveID != paneID && wv.omnibox != nil {
341 wv.hideOmniboxInternal()
342 }
343
344 // Deactivate current active pane
345 if currentActiveID != "" {
346 if oldPV, ok := wv.paneViews[currentActiveID]; ok && oldPV != nil {
347 oldPV.SetActive(false)
348 }
349 }
350
351 newPV.SetActive(true)
352
353 // Update domain model - single source of truth
354 if wv.workspace != nil {
355 wv.workspace.ActivePaneID = paneID
356 }
357
358 return nil
359}
360
361// GetActivePaneID returns the ID of the currently active pane.
362// Reads from the domain model as the single source of truth.
363func (wv *WorkspaceView) GetActivePaneID() entity.PaneID {
364 wv.mu.RLock()
365 defer wv.mu.RUnlock()
366
367 return wv.getActivePaneIDInternal()
368}
369
370// getActivePaneIDInternal returns the active pane ID without locking.
371// Must be called with at least a read lock held.
372func (wv *WorkspaceView) getActivePaneIDInternal() entity.PaneID {
373 if wv.workspace == nil {
374 return ""
375 }
376 return wv.workspace.ActivePaneID
377}
378
379// GetPaneView returns the PaneView for a given pane ID.
380// Returns nil if not found.
381func (wv *WorkspaceView) GetPaneView(paneID entity.PaneID) *PaneView {
382 wv.mu.RLock()
383 defer wv.mu.RUnlock()
384
385 return wv.paneViews[paneID]
386}
387
388// GetPaneIDs returns all pane IDs in this workspace view.
389func (wv *WorkspaceView) GetPaneIDs() []entity.PaneID {
390 wv.mu.RLock()
391 defer wv.mu.RUnlock()
392
393 ids := make([]entity.PaneID, 0, len(wv.paneViews))
394 for id := range wv.paneViews {
395 ids = append(ids, id)
396 }
397 return ids
398}
399
400// PaneCount returns the number of panes in the workspace view.
401func (wv *WorkspaceView) PaneCount() int {
402 wv.mu.RLock()
403 defer wv.mu.RUnlock()
404
405 return len(wv.paneViews)
406}
407
408// singlePaneClass is used to hide the active pane border when only one visible area exists.
409const singlePaneClass = "single-pane"
410
411// updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.
412// Uses VisibleAreaCount from the domain model: stacked panes count as 1 visible area.
413// Must be called with the lock held.
414func (wv *WorkspaceView) updateSinglePaneModeInternal() {
415 if wv.container == nil {
416 return
417 }
418
419 visibleAreas := 0
420 if wv.workspace != nil {
421 visibleAreas = wv.workspace.VisibleAreaCount()
422 }
423
424 if visibleAreas <= 1 {
425 wv.container.AddCssClass(singlePaneClass)
426 } else {
427 wv.container.RemoveCssClass(singlePaneClass)
428 }
429}
430
431// SetOnPaneFocused sets the callback for when a pane receives focus.
432func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
433 wv.mu.Lock()
434 defer wv.mu.Unlock()
435
436 wv.onPaneFocused = fn
437}
438
439// SetOnActivePaneChanged sets the callback invoked after the active pane changes.
440func (wv *WorkspaceView) SetOnActivePaneChanged(fn func(paneID entity.PaneID)) {
441 wv.mu.Lock()
442 defer wv.mu.Unlock()
443
444 wv.onActivePaneChanged = fn
445}
446
447// SetOnWebViewAttached sets the callback invoked after a pane receives a WebView widget.
448func (wv *WorkspaceView) SetOnWebViewAttached(fn func(paneID entity.PaneID)) {
449 wv.mu.Lock()
450 defer wv.mu.Unlock()
451
452 wv.onWebViewAttached = fn
453}
454
455func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {
456 wv.mu.Lock()
457 defer wv.mu.Unlock()
458
459 wv.onSplitRatioDragged = fn
460}
461
462// SetOnRebuilt notifies visual overlays when their target widget was replaced.
463func (wv *WorkspaceView) SetOnRebuilt(fn func()) {
464 wv.mu.Lock()
465 defer wv.mu.Unlock()
466 wv.onRebuilt = fn
467}
468
469// Rebuild rebuilds the widget tree from the current workspace.
470// Use this after structural changes like splits or closes.
471func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
472 wv.mu.RLock()
473 ws := wv.workspace
474 wv.mu.RUnlock()
475
476 if ws == nil {
477 return ErrNilWorkspace
478 }
479
480 return wv.SetWorkspace(ctx, ws)
481}
482
483// FocusPane attempts to give focus to a specific pane.
484// Returns true if focus was successfully grabbed.
485func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool {
486 wv.mu.RLock()
487 pv, ok := wv.paneViews[paneID]
488 wv.mu.RUnlock()
489
490 if !ok || pv == nil {
491 return false
492 }
493
494 return pv.GrabFocus()
495}
496
497// DeactivatePane removes the active visual indicator from a pane.
498// This is used when keyboard navigation overrides mouse hover focus.
499func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) {
500 wv.mu.RLock()
501 pv, ok := wv.paneViews[paneID]
502 wv.mu.RUnlock()
503
504 if ok && pv != nil {
505 pv.SetActive(false)
506 }
507}
508
509// SetWebViewWidget attaches a not-yet-revealed WebView widget to a specific pane.
510// Call AttachWebViewWidget when the content coordinator has explicit reveal state.
511func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
512 return wv.AttachWebViewWidget(paneID, widget, false)
513}
514
515// AttachWebViewWidget attaches a WebView widget with the content coordinator's
516// explicit reveal state. A WorkspaceView rebuild can unparent a live widget, so
517// widget parentage cannot determine whether its loading skeleton is still needed.
518func (wv *WorkspaceView) AttachWebViewWidget(paneID entity.PaneID, widget layout.Widget, revealed bool) error {
519 wv.mu.RLock()
520 pv, ok := wv.paneViews[paneID]
521 callback := wv.onWebViewAttached
522 wv.mu.RUnlock()
523
524 if !ok || pv == nil {
525 return ErrPaneNotFound
526 }
527
528 pv.AttachWebViewWidget(widget, revealed)
529 if widget != nil && callback != nil {
530 callback(paneID)
531 }
532 return nil
533}
534
535// Widget returns the overlay widget for embedding in the UI.
536// The overlay wraps the pane container and allows mode borders to be displayed.
537func (wv *WorkspaceView) Widget() layout.Widget {
538 return wv.overlay
539}
540
541// Container returns the underlying BoxWidget for direct access.
542func (wv *WorkspaceView) Container() layout.BoxWidget {
543 return wv.container
544}
545
546// TreeRenderer returns the underlying tree renderer.
547func (wv *WorkspaceView) TreeRenderer() *layout.TreeRenderer {
548 return wv.treeRenderer
549}
550
551// Workspace returns the current workspace.
552func (wv *WorkspaceView) Workspace() *entity.Workspace {
553 wv.mu.RLock()
554 defer wv.mu.RUnlock()
555
556 return wv.workspace
557}
558
559// RegisterPaneView adds a PaneView to the tracking map without rebuilding.
560// Use this for incremental operations like stacked panes.
561func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) {
562 wv.mu.Lock()
563 defer wv.mu.Unlock()
564
565 wv.paneViews[paneID] = pv
566 wv.updateSinglePaneModeInternal()
567}
568
569// UnregisterPaneView removes a PaneView from the tracking map and cleans it up.
570// This must be called when closing a pane to properly release GTK resources
571// before destroying the WebView.
572func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) {
573 wv.mu.Lock()
574 defer wv.mu.Unlock()
575
576 // Call Cleanup on the PaneView to unparent widgets and clear callbacks
577 // This must happen before WebView.Destroy() is called
578 if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
579 pv.Cleanup()
580 }
581
582 delete(wv.paneViews, paneID)
583 wv.updateSinglePaneModeInternal()
584}
585
586// GetRootWidget returns the current root widget of the workspace.
587// This is useful for incremental operations that need to modify the tree.
588func (wv *WorkspaceView) GetRootWidget() layout.Widget {
589 wv.mu.RLock()
590 defer wv.mu.RUnlock()
591
592 return wv.rootWidget
593}
594
595// SetRootWidgetDirect replaces the root widget without rebuilding the entire tree.
596// Use this for incremental operations when converting to/from stacked panes.
597func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) {
598 wv.mu.Lock()
599 defer wv.mu.Unlock()
600
601 // Remove old root if present
602 if wv.rootWidget != nil {
603 wv.container.Remove(wv.rootWidget)
604 }
605
606 // Add new root
607 if widget != nil {
608 widget.SetVisible(true)
609 wv.container.Append(widget)
610 }
611
612 wv.rootWidget = widget
613}
614
615// ClearRootWidgetRef clears the stored root widget reference without removing it.
616// Use this before SetRootWidgetDirect when the old root has already been removed
617// through other means (e.g., GTK paned operations).
618func (wv *WorkspaceView) ClearRootWidgetRef() {
619 wv.mu.Lock()
620 defer wv.mu.Unlock()
621 wv.rootWidget = nil
622}
623
624// Factory returns the widget factory used by this workspace view.
625func (wv *WorkspaceView) Factory() layout.WidgetFactory {
626 return wv.factory
627}
628
629// SetModeBorderOverlay attaches a mode border overlay widget.
630// The widget will be displayed on top of the pane container when modes are active.
631func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) {
632 wv.mu.Lock()
633 defer wv.mu.Unlock()
634
635 // Remove old overlay if present
636 if wv.modeBorderWidget != nil && wv.overlay != nil {
637 wv.overlay.RemoveOverlay(wv.modeBorderWidget)
638 }
639
640 wv.modeBorderWidget = widget
641
642 // Add new overlay
643 if widget != nil && wv.overlay != nil {
644 wv.overlay.AddOverlay(widget)
645 // Don't clip or measure the overlay - it should fill the entire area
646 wv.overlay.SetClipOverlay(widget, false)
647 wv.overlay.SetMeasureOverlay(widget, false)
648 }
649}
650
651// WorkspaceOverlayWidget returns the workspace-level overlay.
652func (wv *WorkspaceView) WorkspaceOverlayWidget() layout.OverlayWidget {
653 wv.mu.RLock()
654 defer wv.mu.RUnlock()
655 return wv.overlay
656}
657
658// AddWorkspaceOverlayWidget adds a widget to the workspace-level overlay safely.
659func (wv *WorkspaceView) AddWorkspaceOverlayWidget(widget layout.Widget) {
660 if widget == nil {
661 return
662 }
663
664 wv.mu.Lock()
665 defer wv.mu.Unlock()
666 if wv.overlay == nil {
667 return
668 }
669
670 if parent := widget.GetParent(); parent != nil {
671 if parent == wv.overlay {
672 return
673 }
674
675 parentGTK := parent.GtkWidget()
676 overlayGTK := wv.overlay.GtkWidget()
677 if parentGTK != nil && overlayGTK != nil && parentGTK.GoPointer() == overlayGTK.GoPointer() {
678 return
679 }
680 widget.Unparent()
681 }
682
683 wv.overlay.AddOverlay(widget)
684 wv.overlay.SetClipOverlay(widget, false)
685 wv.overlay.SetMeasureOverlay(widget, false)
686}
687
688// RemoveWorkspaceOverlayWidget removes a widget from the workspace-level overlay safely.
689func (wv *WorkspaceView) RemoveWorkspaceOverlayWidget(widget layout.Widget) {
690 if widget == nil {
691 return
692 }
693
694 wv.mu.Lock()
695 defer wv.mu.Unlock()
696 if wv.overlay == nil {
697 return
698 }
699
700 if parent := widget.GetParent(); parent == wv.overlay {
701 wv.overlay.RemoveOverlay(widget)
702 } else if parent != nil {
703 widget.Unparent()
704 }
705}
706
707// GetPaneWidget returns the widget for a pane ID.
708// Implements focus.PaneGeometryProvider.
709func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {
710 wv.mu.RLock()
711 defer wv.mu.RUnlock()
712
713 pv, ok := wv.paneViews[paneID]
714 if !ok || pv == nil {
715 return nil
716 }
717 return pv.Widget()
718}
719
720// GetStackContainerWidget returns the stack container widget for a stacked pane.
721// Returns nil if the pane is not in a stack.
722// Implements focus.PaneGeometryProvider.
723func (wv *WorkspaceView) GetStackContainerWidget(paneID entity.PaneID) layout.Widget {
724 if wv.treeRenderer == nil {
725 return nil
726 }
727
728 stackedView := wv.treeRenderer.GetStackedViewForPane(string(paneID))
729 if stackedView == nil {
730 return nil
731 }
732
733 return stackedView.Widget()
734}
735
736// ContainerWidget returns the container widget for relative positioning.
737// Implements focus.PaneGeometryProvider.
738func (wv *WorkspaceView) ContainerWidget() layout.Widget {
739 return wv.container
740}
741
742// SetOmniboxConfig stores the omnibox configuration for later use.
743func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) {
744 wv.mu.Lock()
745 defer wv.mu.Unlock()
746 wv.omniboxCfg = cfg
747}
748
749// SetOmniboxUIScale updates current and future omniboxes on the GTK main thread.
750func (wv *WorkspaceView) SetOmniboxUIScale(scale float64) {
751 scale = normalizeUIScale(scale)
752 wv.mu.Lock()
753 wv.omniboxCfg.UIScale = scale
754 omnibox := wv.omnibox
755 wv.mu.Unlock()
756 if omnibox != nil {
757 omnibox.SetUIScale(scale)
758 }
759}
760
761// SetFindBarConfig stores the find bar configuration for later use.
762func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
763 wv.mu.Lock()
764 defer wv.mu.Unlock()
765 wv.findBarCfg = cfg
766}
767
768// SetAutoOpenOnNewPane configures whether to show omnibox when a new pane is created.
769func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) {
770 wv.mu.Lock()
771 defer wv.mu.Unlock()
772 wv.autoOpenOnNewPane = enabled
773}
774
775// NotifyNewPaneCreated handles new pane creation events.
776// Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox.
777func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) {
778 // Lock hover focus until the user intentionally moves the mouse.
779 // New pane creation always wins focus; hover-follows-mouse resumes
780 // only after real mouse motion (cleared by PaneView.SetOnMouseMotion).
781 wv.newPaneFocusLock.Store(true)
782 wv.SuppressHover(KeyboardFocusSuppressDuration)
783 wv.CancelAllPendingHovers()
784
785 wv.mu.RLock()
786 autoOpen := wv.autoOpenOnNewPane
787 wv.mu.RUnlock()
788
789 if !autoOpen {
790 return
791 }
792
793 // Defer omnibox show until after GTK layout allocation completes
794 cb := glib.SourceFunc(func(uintptr) bool {
795 wv.ShowOmnibox(ctx, "")
796 return false
797 })
798 glib.IdleAdd(&cb, 0)
799}
800
801// ShowOmnibox creates and shows the omnibox in the active pane.
802func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
803 wv.mu.Lock()
804 defer wv.mu.Unlock()
805
806 // Get the active pane view
807 activePaneID := wv.getActivePaneIDInternal()
808 pv := wv.paneViews[activePaneID]
809 if pv == nil {
810 wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show omnibox: active pane not found")
811 return
812 }
813
814 // If omnibox exists but on different pane, destroy and recreate on active pane
815 if wv.omnibox != nil {
816 if wv.omniboxPaneID != activePaneID {
817 wv.logger.Debug().
818 Str("old_pane", string(wv.omniboxPaneID)).
819 Str("new_pane", string(activePaneID)).
820 Msg("omnibox pane changed, recreating")
821 wv.hideOmniboxInternal()
822 } else {
823 wv.logger.Debug().Str("pane", string(activePaneID)).Msg("showing existing omnibox")
824 wv.omnibox.Show(ctx, query)
825 return
826 }
827 }
828
829 // Create omnibox with pane-specific toast callback
830 cfg := wv.omniboxCfg
831 cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) {
832 pv.ShowToast(toastCtx, message, level)
833 }
834 omnibox := NewOmnibox(ctx, cfg)
835 if omnibox == nil {
836 wv.logger.Error().Msg("failed to create omnibox")
837 return
838 }
839
840 // Set parent overlay for sizing
841 omnibox.SetParentOverlay(pv.Overlay())
842
843 // Wrap widget for layout system
844 omniboxWidget := omnibox.WidgetAsLayout(wv.factory)
845 if omniboxWidget == nil {
846 wv.logger.Error().Msg("failed to wrap omnibox widget")
847 return
848 }
849
850 // Add to pane overlay
851 pv.AddOverlayWidget(omniboxWidget)
852
853 // Store references
854 wv.omnibox = omnibox
855 wv.omniboxWidget = omniboxWidget
856 wv.omniboxPaneID = activePaneID
857
858 // Show the omnibox
859 omnibox.Show(ctx, query)
860
861 wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("omnibox shown")
862}
863
864// HideOmnibox hides and destroys the current omnibox.
865func (wv *WorkspaceView) HideOmnibox() {
866 wv.mu.Lock()
867 defer wv.mu.Unlock()
868
869 wv.hideOmniboxInternal()
870}
871
872// hideOmniboxInternal destroys the omnibox without locking.
873func (wv *WorkspaceView) hideOmniboxInternal() {
874 if wv.omnibox == nil {
875 return
876 }
877
878 // Hide the omnibox first (use Background context since this is internal cleanup)
879 wv.omnibox.Hide(context.Background())
880
881 // Remove from pane overlay
882 if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {
883 // The omnibox widget may have been reparented during a rebuild/move.
884 // Avoid GTK criticals by only removing from the overlay that owns it.
885 if parent := wv.omniboxWidget.GetParent(); parent == pv.Overlay() {
886 pv.RemoveOverlayWidget(wv.omniboxWidget)
887 } else if parent != nil {
888 wv.omniboxWidget.Unparent()
889 }
890 }
891
892 // Clear references
893 wv.omnibox = nil
894 wv.omniboxWidget = nil
895 wv.omniboxPaneID = ""
896
897 wv.logger.Debug().Msg("omnibox hidden and destroyed")
898}
899
900// ShowFindBar creates and shows the find bar in the active pane.
901func (wv *WorkspaceView) ShowFindBar(ctx context.Context) {
902 wv.mu.Lock()
903 defer wv.mu.Unlock()
904
905 // If find bar already exists, just show it
906 if wv.findBar != nil {
907 wv.findBar.Show()
908 return
909 }
910
911 activePaneID := wv.getActivePaneIDInternal()
912 pv := wv.paneViews[activePaneID]
913 if pv == nil {
914 wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show find bar: active pane not found")
915 return
916 }
917
918 cfg := wv.findBarCfg
919 cfg.OnClose = func() {
920 wv.HideFindBar()
921 }
922 findBar := NewFindBar(ctx, cfg)
923 if findBar == nil {
924 wv.logger.Error().Msg("failed to create find bar")
925 return
926 }
927
928 findBarWidget := findBar.WidgetAsLayout(wv.factory)
929 if findBarWidget == nil {
930 wv.logger.Error().Msg("failed to wrap find bar widget")
931 return
932 }
933
934 pv.AddOverlayWidget(findBarWidget)
935
936 if cfg.GetFindController != nil {
937 controller := cfg.GetFindController(activePaneID)
938 findBar.SetFindController(controller)
939 }
940
941 wv.findBar = findBar
942 wv.findBarWidget = findBarWidget
943 wv.findBarPaneID = activePaneID
944
945 findBar.Show()
946
947 wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("find bar shown")
948}
949
950// HideFindBar hides and destroys the current find bar.
951func (wv *WorkspaceView) HideFindBar() {
952 wv.mu.Lock()
953 defer wv.mu.Unlock()
954
955 wv.hideFindBarInternal()
956}
957
958// FindNext triggers the next match in the find bar if available.
959func (wv *WorkspaceView) FindNext() {
960 wv.mu.RLock()
961 findBar := wv.findBar
962 wv.mu.RUnlock()
963
964 if findBar != nil {
965 findBar.FindNext()
966 }
967}
968
969// FindPrevious triggers the previous match in the find bar if available.
970func (wv *WorkspaceView) FindPrevious() {
971 wv.mu.RLock()
972 findBar := wv.findBar
973 wv.mu.RUnlock()
974
975 if findBar != nil {
976 findBar.FindPrevious()
977 }
978}
979
980// IsFindBarVisible returns whether the find bar is currently visible.
981func (wv *WorkspaceView) IsFindBarVisible() bool {
982 wv.mu.RLock()
983 defer wv.mu.RUnlock()
984 return wv.findBar != nil && wv.findBar.IsVisible()
985}
986
987// hideFindBarInternal destroys the find bar without locking.
988func (wv *WorkspaceView) hideFindBarInternal() {
989 if wv.findBar == nil {
990 return
991 }
992
993 wv.findBar.Hide()
994
995 if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
996 if parent := wv.findBarWidget.GetParent(); parent == pv.Overlay() {
997 pv.RemoveOverlayWidget(wv.findBarWidget)
998 } else if parent != nil {
999 wv.findBarWidget.Unparent()
1000 }
1001 }
1002
1003 wv.findBar = nil
1004 wv.findBarWidget = nil
1005 wv.findBarPaneID = ""
1006
1007 wv.logger.Debug().Msg("find bar hidden and destroyed")
1008}
1009
1010// GetOmnibox returns the current omnibox if visible.
1011func (wv *WorkspaceView) GetOmnibox() *Omnibox {
1012 wv.mu.RLock()
1013 defer wv.mu.RUnlock()
1014 return wv.omnibox
1015}
1016
1017// IsOmniboxVisible returns whether the omnibox is currently visible.
1018func (wv *WorkspaceView) IsOmniboxVisible() bool {
1019 wv.mu.RLock()
1020 defer wv.mu.RUnlock()
1021 return wv.omnibox != nil && wv.omnibox.IsVisible()
1022}
1023
1024// GetActivePaneView returns the PaneView for the current active pane.
1025func (wv *WorkspaceView) GetActivePaneView() *PaneView {
1026 wv.mu.RLock()
1027 defer wv.mu.RUnlock()
1028 return wv.paneViews[wv.getActivePaneIDInternal()]
1029}
1030
1031// KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation.
1032const KeyboardFocusSuppressDuration = 300 * time.Millisecond
1033
1034// SuppressHover temporarily blocks hover focus changes after keyboard navigation.
1035// This prevents the race condition where a pending hover timer overrides
1036// an explicit keyboard focus change (Issue #89).
1037func (wv *WorkspaceView) SuppressHover(duration time.Duration) {
1038 wv.mu.Lock()
1039 wv.hoverSuppressedUntil = time.Now().Add(duration)
1040 wv.mu.Unlock()
1041}
1042
1043// IsHoverSuppressed returns true if hover focus is temporarily suppressed.
1044func (wv *WorkspaceView) IsHoverSuppressed() bool {
1045 wv.mu.RLock()
1046 defer wv.mu.RUnlock()
1047 return time.Now().Before(wv.hoverSuppressedUntil)
1048}
1049
1050// SetHoverFocusLocked enables or disables hover-to-focus behavior.
1051func (wv *WorkspaceView) SetHoverFocusLocked(locked bool) {
1052 wv.hoverFocusLocked.Store(locked)
1053}
1054
1055// IsHoverFocusLocked returns whether hover-to-focus is currently locked.
1056func (wv *WorkspaceView) IsHoverFocusLocked() bool {
1057 return wv.hoverFocusLocked.Load()
1058}
1059
1060// CancelAllPendingHovers cancels any pending hover timers on all pane views.
1061// Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
1062func (wv *WorkspaceView) CancelAllPendingHovers() {
1063 wv.mu.RLock()
1064 defer wv.mu.RUnlock()
1065
1066 for _, pv := range wv.paneViews {
1067 if pv != nil {
1068 pv.CancelPendingHover()
1069 }
1070 }
1071}