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