1// Package component provides UI components for the browser.
2package component
3
4import (
5 "context"
6 "errors"
7 "fmt"
8 "sync"
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/rs/zerolog"
15)
16
17// ErrNilWorkspace is returned when attempting to set a nil workspace.
18var ErrNilWorkspace = errors.New("workspace is nil")
19
20// ErrPaneNotFound is returned when a pane ID cannot be found.
21var ErrPaneNotFound = errors.New("pane not found")
22
23// WorkspaceView is the top-level container that renders a workspace's pane tree.
24// It manages the widget tree and handles active pane state.
25type WorkspaceView struct {
26 factory layout.WidgetFactory
27 treeRenderer *layout.TreeRenderer
28 container layout.BoxWidget
29 overlay layout.OverlayWidget // Wraps container for mode borders
30 rootWidget layout.Widget // Current root widget for removal on rebuild
31 logger zerolog.Logger
32
33 // Mode border overlay slot
34 modeBorderWidget layout.Widget
35
36 // Omnibox lifecycle
37 omnibox *Omnibox // Current omnibox (nil if not shown)
38 omniboxWidget layout.Widget // Wrapped omnibox widget for overlay
39 omniboxPaneID entity.PaneID // Which pane has the omnibox
40 omniboxCfg OmniboxConfig // Stored config for creating omniboxes
41
42 // Find bar lifecycle
43 findBar *FindBar // Current find bar (nil if not shown)
44 findBarWidget layout.Widget // Wrapped find bar widget for overlay
45 findBarPaneID entity.PaneID // Which pane has the find bar
46 findBarCfg FindBarConfig // Stored config for creating find bars
47
48 workspace *entity.Workspace
49 paneViews map[entity.PaneID]*PaneView
50
51 onPaneFocused func(paneID entity.PaneID)
52 onSplitRatioDragged func(nodeID string, ratio float64)
53
54 // Hover suppression for keyboard navigation (Issue #89)
55 // Prevents hover focus from overriding keyboard-initiated focus changes
56 hoverSuppressedUntil time.Time
57
58 mu sync.RWMutex
59}
60
61// paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
62// so TreeRenderer can create PaneViews.
63type paneViewFactoryAdapter struct {
64 wv *WorkspaceView
65 ctx context.Context
66}
67
68func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Widget {
69 if node == nil || node.Pane == nil {
70 return nil
71 }
72
73 // Create PaneView without WebView widget for now
74 // WebView will be attached later by the application layer
75 pv := NewPaneView(a.wv.factory, node.Pane.ID, nil)
76
77 // Store in map for later lookup
78 // Note: Caller (SetWorkspace) already holds the lock, so we access directly
79 a.wv.paneViews[node.Pane.ID] = pv
80
81 // Set up focus callback
82 pv.SetOnFocusIn(func(paneID entity.PaneID) {
83 a.wv.mu.RLock()
84 callback := a.wv.onPaneFocused
85 a.wv.mu.RUnlock()
86
87 if callback != nil {
88 callback(paneID)
89 }
90 })
91
92 // Set up hover callback for focus-follows-mouse
93 pv.SetOnHover(func(paneID entity.PaneID) {
94 // Skip if hover is suppressed (keyboard navigation just happened)
95 if a.wv.IsHoverSuppressed() {
96 return
97 }
98
99 // Skip if this pane is already active
100 if a.wv.GetActivePaneID() == paneID {
101 return
102 }
103
104 // Activate the hovered pane and grab focus
105 if err := a.wv.SetActivePaneID(paneID); err == nil {
106 a.wv.FocusPane(paneID)
107 }
108 })
109
110 // Attach hover handler with debouncing
111 pv.AttachHoverHandler(a.ctx)
112
113 return pv.Widget()
114}
115
116// NewWorkspaceView creates a new workspace view.
117func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *WorkspaceView {
118 log := logging.FromContext(ctx)
119
120 container := factory.NewBox(layout.OrientationVertical, 0)
121 container.SetHexpand(true)
122 container.SetVexpand(true)
123 container.SetVisible(true)
124
125 // Wrap container in overlay for mode borders
126 overlay := factory.NewOverlay()
127 overlay.SetHexpand(true)
128 overlay.SetVexpand(true)
129 overlay.SetChild(container)
130 overlay.SetVisible(true)
131
132 wv := &WorkspaceView{
133 factory: factory,
134 container: container,
135 overlay: overlay,
136 logger: log.With().Str("component", "workspace-view").Logger(),
137 paneViews: make(map[entity.PaneID]*PaneView),
138 }
139
140 // Create tree renderer with our adapter as the pane view factory
141 wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})
142 wv.treeRenderer.SetOnSplitRatioChanged(func(nodeID string, ratio float64) {
143 wv.mu.RLock()
144 cb := wv.onSplitRatioDragged
145 wv.mu.RUnlock()
146 if cb != nil {
147 cb(nodeID, ratio)
148 }
149 })
150
151 return wv
152}
153
154// SetWorkspace sets the workspace to render and builds the widget tree.
155func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) error {
156 if ws == nil {
157 return ErrNilWorkspace
158 }
159
160 wv.mu.Lock()
161 defer wv.mu.Unlock()
162
163 // Clear previous state
164 wv.workspace = ws
165 wv.paneViews = make(map[entity.PaneID]*PaneView)
166
167 // Remove old root widget from container before building new tree
168 if wv.rootWidget != nil {
169 wv.container.Remove(wv.rootWidget)
170 wv.rootWidget = nil
171 }
172
173 // Build new tree
174 if ws.Root != nil {
175 widget, err := wv.treeRenderer.Build(ctx, ws.Root)
176 if err != nil {
177 return err
178 }
179
180 if widget != nil {
181 widget.SetVisible(true)
182 wv.container.Append(widget)
183 wv.rootWidget = widget
184 }
185 }
186
187 // Set initial active pane
188 if ws.ActivePaneID != "" {
189 if err := wv.setActivePaneIDInternal(ws.ActivePaneID); err != nil {
190 return fmt.Errorf("set initial active pane: %w", err)
191 }
192 }
193
194 // Update single-pane mode based on pane count
195 wv.updateSinglePaneModeInternal()
196
197 return nil
198}
199
200// SetActivePaneID updates which pane is visually marked as active.
201func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error {
202 wv.mu.Lock()
203 defer wv.mu.Unlock()
204
205 return wv.setActivePaneIDInternal(paneID)
206}
207
208// setActivePaneIDInternal updates active pane without locking.
209// Updates the domain model as the single source of truth.
210func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
211 currentActiveID := wv.getActivePaneIDInternal()
212
213 wv.logger.Debug().
214 Str("from_pane", string(currentActiveID)).
215 Str("to_pane", string(paneID)).
216 Bool("omnibox_visible", wv.omnibox != nil).
217 Str("omnibox_pane", string(wv.omniboxPaneID)).
218 Msg("active pane changing")
219
220 // Destroy omnibox if active pane is changing
221 if currentActiveID != paneID && wv.omnibox != nil {
222 wv.logger.Debug().Msg("destroying omnibox due to pane change")
223 wv.hideOmniboxInternal()
224 }
225 // Destroy find bar if active pane is changing
226 if currentActiveID != paneID && wv.findBar != nil {
227 wv.hideFindBarInternal()
228 }
229
230 // Deactivate current active pane
231 if currentActiveID != "" {
232 if oldPV, ok := wv.paneViews[currentActiveID]; ok {
233 oldPV.SetActive(false)
234 }
235 }
236
237 // Activate new pane
238 newPV, ok := wv.paneViews[paneID]
239 if !ok {
240 return ErrPaneNotFound
241 }
242
243 newPV.SetActive(true)
244
245 // Update domain model - single source of truth
246 if wv.workspace != nil {
247 wv.workspace.ActivePaneID = paneID
248 }
249
250 return nil
251}
252
253// GetActivePaneID returns the ID of the currently active pane.
254// Reads from the domain model as the single source of truth.
255func (wv *WorkspaceView) GetActivePaneID() entity.PaneID {
256 wv.mu.RLock()
257 defer wv.mu.RUnlock()
258
259 return wv.getActivePaneIDInternal()
260}
261
262// getActivePaneIDInternal returns the active pane ID without locking.
263// Must be called with at least a read lock held.
264func (wv *WorkspaceView) getActivePaneIDInternal() entity.PaneID {
265 if wv.workspace == nil {
266 return ""
267 }
268 return wv.workspace.ActivePaneID
269}
270
271// GetPaneView returns the PaneView for a given pane ID.
272// Returns nil if not found.
273func (wv *WorkspaceView) GetPaneView(paneID entity.PaneID) *PaneView {
274 wv.mu.RLock()
275 defer wv.mu.RUnlock()
276
277 return wv.paneViews[paneID]
278}
279
280// GetPaneIDs returns all pane IDs in this workspace view.
281func (wv *WorkspaceView) GetPaneIDs() []entity.PaneID {
282 wv.mu.RLock()
283 defer wv.mu.RUnlock()
284
285 ids := make([]entity.PaneID, 0, len(wv.paneViews))
286 for id := range wv.paneViews {
287 ids = append(ids, id)
288 }
289 return ids
290}
291
292// PaneCount returns the number of panes in the workspace view.
293func (wv *WorkspaceView) PaneCount() int {
294 wv.mu.RLock()
295 defer wv.mu.RUnlock()
296
297 return len(wv.paneViews)
298}
299
300// singlePaneClass is used to hide the active pane border when only one pane exists.
301const singlePaneClass = "single-pane"
302
303// updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.
304// Must be called with the lock held.
305func (wv *WorkspaceView) updateSinglePaneModeInternal() {
306 if wv.container == nil {
307 return
308 }
309
310 if len(wv.paneViews) <= 1 {
311 wv.container.AddCssClass(singlePaneClass)
312 } else {
313 wv.container.RemoveCssClass(singlePaneClass)
314 }
315}
316
317// SetOnPaneFocused sets the callback for when a pane receives focus.
318func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
319 wv.mu.Lock()
320 defer wv.mu.Unlock()
321
322 wv.onPaneFocused = fn
323}
324
325func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {
326 wv.mu.Lock()
327 defer wv.mu.Unlock()
328
329 wv.onSplitRatioDragged = fn
330}
331
332// Rebuild rebuilds the widget tree from the current workspace.
333// Use this after structural changes like splits or closes.
334func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
335 wv.mu.RLock()
336 ws := wv.workspace
337 wv.mu.RUnlock()
338
339 if ws == nil {
340 return ErrNilWorkspace
341 }
342
343 return wv.SetWorkspace(ctx, ws)
344}
345
346// FocusPane attempts to give focus to a specific pane.
347// Returns true if focus was successfully grabbed.
348func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool {
349 wv.mu.RLock()
350 pv, ok := wv.paneViews[paneID]
351 wv.mu.RUnlock()
352
353 if !ok || pv == nil {
354 return false
355 }
356
357 return pv.GrabFocus()
358}
359
360// DeactivatePane removes the active visual indicator from a pane.
361// This is used when keyboard navigation overrides mouse hover focus.
362func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) {
363 wv.mu.RLock()
364 pv, ok := wv.paneViews[paneID]
365 wv.mu.RUnlock()
366
367 if ok && pv != nil {
368 pv.SetActive(false)
369 }
370}
371
372// SetWebViewWidget attaches a WebView widget to a specific pane.
373func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
374 wv.mu.RLock()
375 pv, ok := wv.paneViews[paneID]
376 wv.mu.RUnlock()
377
378 if !ok {
379 return ErrPaneNotFound
380 }
381
382 pv.SetWebViewWidget(widget)
383 return nil
384}
385
386// Widget returns the overlay widget for embedding in the UI.
387// The overlay wraps the pane container and allows mode borders to be displayed.
388func (wv *WorkspaceView) Widget() layout.Widget {
389 return wv.overlay
390}
391
392// Container returns the underlying BoxWidget for direct access.
393func (wv *WorkspaceView) Container() layout.BoxWidget {
394 return wv.container
395}
396
397// TreeRenderer returns the underlying tree renderer.
398func (wv *WorkspaceView) TreeRenderer() *layout.TreeRenderer {
399 return wv.treeRenderer
400}
401
402// Workspace returns the current workspace.
403func (wv *WorkspaceView) Workspace() *entity.Workspace {
404 wv.mu.RLock()
405 defer wv.mu.RUnlock()
406
407 return wv.workspace
408}
409
410// RegisterPaneView adds a PaneView to the tracking map without rebuilding.
411// Use this for incremental operations like stacked panes.
412func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) {
413 wv.mu.Lock()
414 defer wv.mu.Unlock()
415
416 wv.paneViews[paneID] = pv
417 wv.updateSinglePaneModeInternal()
418}
419
420// UnregisterPaneView removes a PaneView from the tracking map and cleans it up.
421// This must be called when closing a pane to properly release GTK resources
422// before destroying the WebView.
423func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) {
424 wv.mu.Lock()
425 defer wv.mu.Unlock()
426
427 // Call Cleanup on the PaneView to unparent widgets and clear callbacks
428 // This must happen before WebView.Destroy() is called
429 if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
430 pv.Cleanup()
431 }
432
433 delete(wv.paneViews, paneID)
434 wv.updateSinglePaneModeInternal()
435}
436
437// GetRootWidget returns the current root widget of the workspace.
438// This is useful for incremental operations that need to modify the tree.
439func (wv *WorkspaceView) GetRootWidget() layout.Widget {
440 wv.mu.RLock()
441 defer wv.mu.RUnlock()
442
443 return wv.rootWidget
444}
445
446// SetRootWidgetDirect replaces the root widget without rebuilding the entire tree.
447// Use this for incremental operations when converting to/from stacked panes.
448func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) {
449 wv.mu.Lock()
450 defer wv.mu.Unlock()
451
452 // Remove old root if present
453 if wv.rootWidget != nil {
454 wv.container.Remove(wv.rootWidget)
455 }
456
457 // Add new root
458 if widget != nil {
459 widget.SetVisible(true)
460 wv.container.Append(widget)
461 }
462
463 wv.rootWidget = widget
464}
465
466// ClearRootWidgetRef clears the stored root widget reference without removing it.
467// Use this before SetRootWidgetDirect when the old root has already been removed
468// through other means (e.g., GTK paned operations).
469func (wv *WorkspaceView) ClearRootWidgetRef() {
470 wv.mu.Lock()
471 defer wv.mu.Unlock()
472 wv.rootWidget = nil
473}
474
475// Factory returns the widget factory used by this workspace view.
476func (wv *WorkspaceView) Factory() layout.WidgetFactory {
477 return wv.factory
478}
479
480// SetModeBorderOverlay attaches a mode border overlay widget.
481// The widget will be displayed on top of the pane container when modes are active.
482func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) {
483 wv.mu.Lock()
484 defer wv.mu.Unlock()
485
486 // Remove old overlay if present
487 if wv.modeBorderWidget != nil && wv.overlay != nil {
488 wv.overlay.RemoveOverlay(wv.modeBorderWidget)
489 }
490
491 wv.modeBorderWidget = widget
492
493 // Add new overlay
494 if widget != nil && wv.overlay != nil {
495 wv.overlay.AddOverlay(widget)
496 // Don't clip or measure the overlay - it should fill the entire area
497 wv.overlay.SetClipOverlay(widget, false)
498 wv.overlay.SetMeasureOverlay(widget, false)
499 }
500}
501
502// GetPaneWidget returns the widget for a pane ID.
503// Implements focus.PaneGeometryProvider.
504func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {
505 wv.mu.RLock()
506 defer wv.mu.RUnlock()
507
508 pv, ok := wv.paneViews[paneID]
509 if !ok || pv == nil {
510 return nil
511 }
512 return pv.Widget()
513}
514
515// GetStackContainerWidget returns the stack container widget for a stacked pane.
516// Returns nil if the pane is not in a stack.
517// Implements focus.PaneGeometryProvider.
518func (wv *WorkspaceView) GetStackContainerWidget(paneID entity.PaneID) layout.Widget {
519 if wv.treeRenderer == nil {
520 return nil
521 }
522
523 stackedView := wv.treeRenderer.GetStackedViewForPane(string(paneID))
524 if stackedView == nil {
525 return nil
526 }
527
528 return stackedView.Widget()
529}
530
531// ContainerWidget returns the container widget for relative positioning.
532// Implements focus.PaneGeometryProvider.
533func (wv *WorkspaceView) ContainerWidget() layout.Widget {
534 return wv.container
535}
536
537// SetOmniboxConfig stores the omnibox configuration for later use.
538func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) {
539 wv.mu.Lock()
540 defer wv.mu.Unlock()
541 wv.omniboxCfg = cfg
542}
543
544// SetFindBarConfig stores the find bar configuration for later use.
545func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
546 wv.mu.Lock()
547 defer wv.mu.Unlock()
548 wv.findBarCfg = cfg
549}
550
551// ShowOmnibox creates and shows the omnibox in the active pane.
552func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
553 wv.mu.Lock()
554 defer wv.mu.Unlock()
555
556 // Get the active pane view
557 activePaneID := wv.getActivePaneIDInternal()
558 pv := wv.paneViews[activePaneID]
559 if pv == nil {
560 wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show omnibox: active pane not found")
561 return
562 }
563
564 // If omnibox exists but on different pane, destroy and recreate on active pane
565 if wv.omnibox != nil {
566 if wv.omniboxPaneID != activePaneID {
567 wv.logger.Debug().
568 Str("old_pane", string(wv.omniboxPaneID)).
569 Str("new_pane", string(activePaneID)).
570 Msg("omnibox pane changed, recreating")
571 wv.hideOmniboxInternal()
572 } else {
573 wv.logger.Debug().Str("pane", string(activePaneID)).Msg("showing existing omnibox")
574 wv.omnibox.Show(ctx, query)
575 return
576 }
577 }
578
579 // Create omnibox with pane-specific toast callback
580 cfg := wv.omniboxCfg
581 cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) {
582 pv.ShowToast(toastCtx, message, level)
583 }
584 omnibox := NewOmnibox(ctx, cfg)
585 if omnibox == nil {
586 wv.logger.Error().Msg("failed to create omnibox")
587 return
588 }
589
590 // Set parent overlay for sizing
591 omnibox.SetParentOverlay(pv.Overlay())
592
593 // Wrap widget for layout system
594 omniboxWidget := omnibox.WidgetAsLayout(wv.factory)
595 if omniboxWidget == nil {
596 wv.logger.Error().Msg("failed to wrap omnibox widget")
597 return
598 }
599
600 // Add to pane overlay
601 pv.AddOverlayWidget(omniboxWidget)
602
603 // Store references
604 wv.omnibox = omnibox
605 wv.omniboxWidget = omniboxWidget
606 wv.omniboxPaneID = activePaneID
607
608 // Show the omnibox
609 omnibox.Show(ctx, query)
610
611 wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("omnibox shown")
612}
613
614// HideOmnibox hides and destroys the current omnibox.
615func (wv *WorkspaceView) HideOmnibox() {
616 wv.mu.Lock()
617 defer wv.mu.Unlock()
618
619 wv.hideOmniboxInternal()
620}
621
622// hideOmniboxInternal destroys the omnibox without locking.
623func (wv *WorkspaceView) hideOmniboxInternal() {
624 if wv.omnibox == nil {
625 return
626 }
627
628 // Hide the omnibox first (use Background context since this is internal cleanup)
629 wv.omnibox.Hide(context.Background())
630
631 // Remove from pane overlay
632 if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {
633 // The omnibox widget may have been reparented during a rebuild/move.
634 // Avoid GTK criticals by only removing from the overlay that owns it.
635 if parent := wv.omniboxWidget.GetParent(); parent == pv.Overlay() {
636 pv.RemoveOverlayWidget(wv.omniboxWidget)
637 } else if parent != nil {
638 wv.omniboxWidget.Unparent()
639 }
640 }
641
642 // Clear references
643 wv.omnibox = nil
644 wv.omniboxWidget = nil
645 wv.omniboxPaneID = ""
646
647 wv.logger.Debug().Msg("omnibox hidden and destroyed")
648}
649
650// ShowFindBar creates and shows the find bar in the active pane.
651func (wv *WorkspaceView) ShowFindBar(ctx context.Context) {
652 wv.mu.Lock()
653 defer wv.mu.Unlock()
654
655 // If find bar already exists, just show it
656 if wv.findBar != nil {
657 wv.findBar.Show()
658 return
659 }
660
661 activePaneID := wv.getActivePaneIDInternal()
662 pv := wv.paneViews[activePaneID]
663 if pv == nil {
664 wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show find bar: active pane not found")
665 return
666 }
667
668 cfg := wv.findBarCfg
669 cfg.OnClose = func() {
670 wv.HideFindBar()
671 }
672 findBar := NewFindBar(ctx, cfg)
673 if findBar == nil {
674 wv.logger.Error().Msg("failed to create find bar")
675 return
676 }
677
678 findBarWidget := findBar.WidgetAsLayout(wv.factory)
679 if findBarWidget == nil {
680 wv.logger.Error().Msg("failed to wrap find bar widget")
681 return
682 }
683
684 pv.AddOverlayWidget(findBarWidget)
685
686 if cfg.GetFindController != nil {
687 controller := cfg.GetFindController(activePaneID)
688 findBar.SetFindController(controller)
689 }
690
691 wv.findBar = findBar
692 wv.findBarWidget = findBarWidget
693 wv.findBarPaneID = activePaneID
694
695 findBar.Show()
696
697 wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("find bar shown")
698}
699
700// HideFindBar hides and destroys the current find bar.
701func (wv *WorkspaceView) HideFindBar() {
702 wv.mu.Lock()
703 defer wv.mu.Unlock()
704
705 wv.hideFindBarInternal()
706}
707
708// FindNext triggers the next match in the find bar if available.
709func (wv *WorkspaceView) FindNext() {
710 wv.mu.RLock()
711 findBar := wv.findBar
712 wv.mu.RUnlock()
713
714 if findBar != nil {
715 findBar.FindNext()
716 }
717}
718
719// FindPrevious triggers the previous match in the find bar if available.
720func (wv *WorkspaceView) FindPrevious() {
721 wv.mu.RLock()
722 findBar := wv.findBar
723 wv.mu.RUnlock()
724
725 if findBar != nil {
726 findBar.FindPrevious()
727 }
728}
729
730// IsFindBarVisible returns whether the find bar is currently visible.
731func (wv *WorkspaceView) IsFindBarVisible() bool {
732 wv.mu.RLock()
733 defer wv.mu.RUnlock()
734 return wv.findBar != nil && wv.findBar.IsVisible()
735}
736
737// hideFindBarInternal destroys the find bar without locking.
738func (wv *WorkspaceView) hideFindBarInternal() {
739 if wv.findBar == nil {
740 return
741 }
742
743 wv.findBar.Hide()
744
745 if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
746 if parent := wv.findBarWidget.GetParent(); parent == pv.Overlay() {
747 pv.RemoveOverlayWidget(wv.findBarWidget)
748 } else if parent != nil {
749 wv.findBarWidget.Unparent()
750 }
751 }
752
753 wv.findBar = nil
754 wv.findBarWidget = nil
755 wv.findBarPaneID = ""
756
757 wv.logger.Debug().Msg("find bar hidden and destroyed")
758}
759
760// GetOmnibox returns the current omnibox if visible.
761func (wv *WorkspaceView) GetOmnibox() *Omnibox {
762 wv.mu.RLock()
763 defer wv.mu.RUnlock()
764 return wv.omnibox
765}
766
767// IsOmniboxVisible returns whether the omnibox is currently visible.
768func (wv *WorkspaceView) IsOmniboxVisible() bool {
769 wv.mu.RLock()
770 defer wv.mu.RUnlock()
771 return wv.omnibox != nil && wv.omnibox.IsVisible()
772}
773
774// GetActivePaneView returns the PaneView for the current active pane.
775func (wv *WorkspaceView) GetActivePaneView() *PaneView {
776 wv.mu.RLock()
777 defer wv.mu.RUnlock()
778 return wv.paneViews[wv.getActivePaneIDInternal()]
779}
780
781// KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation.
782const KeyboardFocusSuppressDuration = 300 * time.Millisecond
783
784// SuppressHover temporarily blocks hover focus changes after keyboard navigation.
785// This prevents the race condition where a pending hover timer overrides
786// an explicit keyboard focus change (Issue #89).
787func (wv *WorkspaceView) SuppressHover(duration time.Duration) {
788 wv.mu.Lock()
789 wv.hoverSuppressedUntil = time.Now().Add(duration)
790 wv.mu.Unlock()
791}
792
793// IsHoverSuppressed returns true if hover focus is temporarily suppressed.
794func (wv *WorkspaceView) IsHoverSuppressed() bool {
795 wv.mu.RLock()
796 defer wv.mu.RUnlock()
797 return time.Now().Before(wv.hoverSuppressedUntil)
798}
799
800// CancelAllPendingHovers cancels any pending hover timers on all pane views.
801// Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
802func (wv *WorkspaceView) CancelAllPendingHovers() {
803 wv.mu.RLock()
804 defer wv.mu.RUnlock()
805
806 for _, pv := range wv.paneViews {
807 if pv != nil {
808 pv.CancelPendingHover()
809 }
810 }
811}