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 visible area exists.
301const singlePaneClass = "single-pane"
302
303// updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.
304// Uses VisibleAreaCount from the domain model: stacked panes count as 1 visible area.
305// Must be called with the lock held.
306func (wv *WorkspaceView) updateSinglePaneModeInternal() {
307 if wv.container == nil {
308 return
309 }
310
311 visibleAreas := 0
312 if wv.workspace != nil {
313 visibleAreas = wv.workspace.VisibleAreaCount()
314 }
315
316 if visibleAreas <= 1 {
317 wv.container.AddCssClass(singlePaneClass)
318 } else {
319 wv.container.RemoveCssClass(singlePaneClass)
320 }
321}
322
323// SetOnPaneFocused sets the callback for when a pane receives focus.
324func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
325 wv.mu.Lock()
326 defer wv.mu.Unlock()
327
328 wv.onPaneFocused = fn
329}
330
331func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {
332 wv.mu.Lock()
333 defer wv.mu.Unlock()
334
335 wv.onSplitRatioDragged = fn
336}
337
338// Rebuild rebuilds the widget tree from the current workspace.
339// Use this after structural changes like splits or closes.
340func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
341 wv.mu.RLock()
342 ws := wv.workspace
343 wv.mu.RUnlock()
344
345 if ws == nil {
346 return ErrNilWorkspace
347 }
348
349 return wv.SetWorkspace(ctx, ws)
350}
351
352// FocusPane attempts to give focus to a specific pane.
353// Returns true if focus was successfully grabbed.
354func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool {
355 wv.mu.RLock()
356 pv, ok := wv.paneViews[paneID]
357 wv.mu.RUnlock()
358
359 if !ok || pv == nil {
360 return false
361 }
362
363 return pv.GrabFocus()
364}
365
366// DeactivatePane removes the active visual indicator from a pane.
367// This is used when keyboard navigation overrides mouse hover focus.
368func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) {
369 wv.mu.RLock()
370 pv, ok := wv.paneViews[paneID]
371 wv.mu.RUnlock()
372
373 if ok && pv != nil {
374 pv.SetActive(false)
375 }
376}
377
378// SetWebViewWidget attaches a WebView widget to a specific pane.
379func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
380 wv.mu.RLock()
381 pv, ok := wv.paneViews[paneID]
382 wv.mu.RUnlock()
383
384 if !ok {
385 return ErrPaneNotFound
386 }
387
388 pv.SetWebViewWidget(widget)
389 return nil
390}
391
392// Widget returns the overlay widget for embedding in the UI.
393// The overlay wraps the pane container and allows mode borders to be displayed.
394func (wv *WorkspaceView) Widget() layout.Widget {
395 return wv.overlay
396}
397
398// Container returns the underlying BoxWidget for direct access.
399func (wv *WorkspaceView) Container() layout.BoxWidget {
400 return wv.container
401}
402
403// TreeRenderer returns the underlying tree renderer.
404func (wv *WorkspaceView) TreeRenderer() *layout.TreeRenderer {
405 return wv.treeRenderer
406}
407
408// Workspace returns the current workspace.
409func (wv *WorkspaceView) Workspace() *entity.Workspace {
410 wv.mu.RLock()
411 defer wv.mu.RUnlock()
412
413 return wv.workspace
414}
415
416// RegisterPaneView adds a PaneView to the tracking map without rebuilding.
417// Use this for incremental operations like stacked panes.
418func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) {
419 wv.mu.Lock()
420 defer wv.mu.Unlock()
421
422 wv.paneViews[paneID] = pv
423 wv.updateSinglePaneModeInternal()
424}
425
426// UnregisterPaneView removes a PaneView from the tracking map and cleans it up.
427// This must be called when closing a pane to properly release GTK resources
428// before destroying the WebView.
429func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) {
430 wv.mu.Lock()
431 defer wv.mu.Unlock()
432
433 // Call Cleanup on the PaneView to unparent widgets and clear callbacks
434 // This must happen before WebView.Destroy() is called
435 if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
436 pv.Cleanup()
437 }
438
439 delete(wv.paneViews, paneID)
440 wv.updateSinglePaneModeInternal()
441}
442
443// GetRootWidget returns the current root widget of the workspace.
444// This is useful for incremental operations that need to modify the tree.
445func (wv *WorkspaceView) GetRootWidget() layout.Widget {
446 wv.mu.RLock()
447 defer wv.mu.RUnlock()
448
449 return wv.rootWidget
450}
451
452// SetRootWidgetDirect replaces the root widget without rebuilding the entire tree.
453// Use this for incremental operations when converting to/from stacked panes.
454func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) {
455 wv.mu.Lock()
456 defer wv.mu.Unlock()
457
458 // Remove old root if present
459 if wv.rootWidget != nil {
460 wv.container.Remove(wv.rootWidget)
461 }
462
463 // Add new root
464 if widget != nil {
465 widget.SetVisible(true)
466 wv.container.Append(widget)
467 }
468
469 wv.rootWidget = widget
470}
471
472// ClearRootWidgetRef clears the stored root widget reference without removing it.
473// Use this before SetRootWidgetDirect when the old root has already been removed
474// through other means (e.g., GTK paned operations).
475func (wv *WorkspaceView) ClearRootWidgetRef() {
476 wv.mu.Lock()
477 defer wv.mu.Unlock()
478 wv.rootWidget = nil
479}
480
481// Factory returns the widget factory used by this workspace view.
482func (wv *WorkspaceView) Factory() layout.WidgetFactory {
483 return wv.factory
484}
485
486// SetModeBorderOverlay attaches a mode border overlay widget.
487// The widget will be displayed on top of the pane container when modes are active.
488func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) {
489 wv.mu.Lock()
490 defer wv.mu.Unlock()
491
492 // Remove old overlay if present
493 if wv.modeBorderWidget != nil && wv.overlay != nil {
494 wv.overlay.RemoveOverlay(wv.modeBorderWidget)
495 }
496
497 wv.modeBorderWidget = widget
498
499 // Add new overlay
500 if widget != nil && wv.overlay != nil {
501 wv.overlay.AddOverlay(widget)
502 // Don't clip or measure the overlay - it should fill the entire area
503 wv.overlay.SetClipOverlay(widget, false)
504 wv.overlay.SetMeasureOverlay(widget, false)
505 }
506}
507
508// GetPaneWidget returns the widget for a pane ID.
509// Implements focus.PaneGeometryProvider.
510func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {
511 wv.mu.RLock()
512 defer wv.mu.RUnlock()
513
514 pv, ok := wv.paneViews[paneID]
515 if !ok || pv == nil {
516 return nil
517 }
518 return pv.Widget()
519}
520
521// GetStackContainerWidget returns the stack container widget for a stacked pane.
522// Returns nil if the pane is not in a stack.
523// Implements focus.PaneGeometryProvider.
524func (wv *WorkspaceView) GetStackContainerWidget(paneID entity.PaneID) layout.Widget {
525 if wv.treeRenderer == nil {
526 return nil
527 }
528
529 stackedView := wv.treeRenderer.GetStackedViewForPane(string(paneID))
530 if stackedView == nil {
531 return nil
532 }
533
534 return stackedView.Widget()
535}
536
537// ContainerWidget returns the container widget for relative positioning.
538// Implements focus.PaneGeometryProvider.
539func (wv *WorkspaceView) ContainerWidget() layout.Widget {
540 return wv.container
541}
542
543// SetOmniboxConfig stores the omnibox configuration for later use.
544func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) {
545 wv.mu.Lock()
546 defer wv.mu.Unlock()
547 wv.omniboxCfg = cfg
548}
549
550// SetFindBarConfig stores the find bar configuration for later use.
551func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
552 wv.mu.Lock()
553 defer wv.mu.Unlock()
554 wv.findBarCfg = cfg
555}
556
557// ShowOmnibox creates and shows the omnibox in the active pane.
558func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
559 wv.mu.Lock()
560 defer wv.mu.Unlock()
561
562 // Get the active pane view
563 activePaneID := wv.getActivePaneIDInternal()
564 pv := wv.paneViews[activePaneID]
565 if pv == nil {
566 wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show omnibox: active pane not found")
567 return
568 }
569
570 // If omnibox exists but on different pane, destroy and recreate on active pane
571 if wv.omnibox != nil {
572 if wv.omniboxPaneID != activePaneID {
573 wv.logger.Debug().
574 Str("old_pane", string(wv.omniboxPaneID)).
575 Str("new_pane", string(activePaneID)).
576 Msg("omnibox pane changed, recreating")
577 wv.hideOmniboxInternal()
578 } else {
579 wv.logger.Debug().Str("pane", string(activePaneID)).Msg("showing existing omnibox")
580 wv.omnibox.Show(ctx, query)
581 return
582 }
583 }
584
585 // Create omnibox with pane-specific toast callback
586 cfg := wv.omniboxCfg
587 cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) {
588 pv.ShowToast(toastCtx, message, level)
589 }
590 omnibox := NewOmnibox(ctx, cfg)
591 if omnibox == nil {
592 wv.logger.Error().Msg("failed to create omnibox")
593 return
594 }
595
596 // Set parent overlay for sizing
597 omnibox.SetParentOverlay(pv.Overlay())
598
599 // Wrap widget for layout system
600 omniboxWidget := omnibox.WidgetAsLayout(wv.factory)
601 if omniboxWidget == nil {
602 wv.logger.Error().Msg("failed to wrap omnibox widget")
603 return
604 }
605
606 // Add to pane overlay
607 pv.AddOverlayWidget(omniboxWidget)
608
609 // Store references
610 wv.omnibox = omnibox
611 wv.omniboxWidget = omniboxWidget
612 wv.omniboxPaneID = activePaneID
613
614 // Show the omnibox
615 omnibox.Show(ctx, query)
616
617 wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("omnibox shown")
618}
619
620// HideOmnibox hides and destroys the current omnibox.
621func (wv *WorkspaceView) HideOmnibox() {
622 wv.mu.Lock()
623 defer wv.mu.Unlock()
624
625 wv.hideOmniboxInternal()
626}
627
628// hideOmniboxInternal destroys the omnibox without locking.
629func (wv *WorkspaceView) hideOmniboxInternal() {
630 if wv.omnibox == nil {
631 return
632 }
633
634 // Hide the omnibox first (use Background context since this is internal cleanup)
635 wv.omnibox.Hide(context.Background())
636
637 // Remove from pane overlay
638 if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {
639 // The omnibox widget may have been reparented during a rebuild/move.
640 // Avoid GTK criticals by only removing from the overlay that owns it.
641 if parent := wv.omniboxWidget.GetParent(); parent == pv.Overlay() {
642 pv.RemoveOverlayWidget(wv.omniboxWidget)
643 } else if parent != nil {
644 wv.omniboxWidget.Unparent()
645 }
646 }
647
648 // Clear references
649 wv.omnibox = nil
650 wv.omniboxWidget = nil
651 wv.omniboxPaneID = ""
652
653 wv.logger.Debug().Msg("omnibox hidden and destroyed")
654}
655
656// ShowFindBar creates and shows the find bar in the active pane.
657func (wv *WorkspaceView) ShowFindBar(ctx context.Context) {
658 wv.mu.Lock()
659 defer wv.mu.Unlock()
660
661 // If find bar already exists, just show it
662 if wv.findBar != nil {
663 wv.findBar.Show()
664 return
665 }
666
667 activePaneID := wv.getActivePaneIDInternal()
668 pv := wv.paneViews[activePaneID]
669 if pv == nil {
670 wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show find bar: active pane not found")
671 return
672 }
673
674 cfg := wv.findBarCfg
675 cfg.OnClose = func() {
676 wv.HideFindBar()
677 }
678 findBar := NewFindBar(ctx, cfg)
679 if findBar == nil {
680 wv.logger.Error().Msg("failed to create find bar")
681 return
682 }
683
684 findBarWidget := findBar.WidgetAsLayout(wv.factory)
685 if findBarWidget == nil {
686 wv.logger.Error().Msg("failed to wrap find bar widget")
687 return
688 }
689
690 pv.AddOverlayWidget(findBarWidget)
691
692 if cfg.GetFindController != nil {
693 controller := cfg.GetFindController(activePaneID)
694 findBar.SetFindController(controller)
695 }
696
697 wv.findBar = findBar
698 wv.findBarWidget = findBarWidget
699 wv.findBarPaneID = activePaneID
700
701 findBar.Show()
702
703 wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("find bar shown")
704}
705
706// HideFindBar hides and destroys the current find bar.
707func (wv *WorkspaceView) HideFindBar() {
708 wv.mu.Lock()
709 defer wv.mu.Unlock()
710
711 wv.hideFindBarInternal()
712}
713
714// FindNext triggers the next match in the find bar if available.
715func (wv *WorkspaceView) FindNext() {
716 wv.mu.RLock()
717 findBar := wv.findBar
718 wv.mu.RUnlock()
719
720 if findBar != nil {
721 findBar.FindNext()
722 }
723}
724
725// FindPrevious triggers the previous match in the find bar if available.
726func (wv *WorkspaceView) FindPrevious() {
727 wv.mu.RLock()
728 findBar := wv.findBar
729 wv.mu.RUnlock()
730
731 if findBar != nil {
732 findBar.FindPrevious()
733 }
734}
735
736// IsFindBarVisible returns whether the find bar is currently visible.
737func (wv *WorkspaceView) IsFindBarVisible() bool {
738 wv.mu.RLock()
739 defer wv.mu.RUnlock()
740 return wv.findBar != nil && wv.findBar.IsVisible()
741}
742
743// hideFindBarInternal destroys the find bar without locking.
744func (wv *WorkspaceView) hideFindBarInternal() {
745 if wv.findBar == nil {
746 return
747 }
748
749 wv.findBar.Hide()
750
751 if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
752 if parent := wv.findBarWidget.GetParent(); parent == pv.Overlay() {
753 pv.RemoveOverlayWidget(wv.findBarWidget)
754 } else if parent != nil {
755 wv.findBarWidget.Unparent()
756 }
757 }
758
759 wv.findBar = nil
760 wv.findBarWidget = nil
761 wv.findBarPaneID = ""
762
763 wv.logger.Debug().Msg("find bar hidden and destroyed")
764}
765
766// GetOmnibox returns the current omnibox if visible.
767func (wv *WorkspaceView) GetOmnibox() *Omnibox {
768 wv.mu.RLock()
769 defer wv.mu.RUnlock()
770 return wv.omnibox
771}
772
773// IsOmniboxVisible returns whether the omnibox is currently visible.
774func (wv *WorkspaceView) IsOmniboxVisible() bool {
775 wv.mu.RLock()
776 defer wv.mu.RUnlock()
777 return wv.omnibox != nil && wv.omnibox.IsVisible()
778}
779
780// GetActivePaneView returns the PaneView for the current active pane.
781func (wv *WorkspaceView) GetActivePaneView() *PaneView {
782 wv.mu.RLock()
783 defer wv.mu.RUnlock()
784 return wv.paneViews[wv.getActivePaneIDInternal()]
785}
786
787// KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation.
788const KeyboardFocusSuppressDuration = 300 * time.Millisecond
789
790// SuppressHover temporarily blocks hover focus changes after keyboard navigation.
791// This prevents the race condition where a pending hover timer overrides
792// an explicit keyboard focus change (Issue #89).
793func (wv *WorkspaceView) SuppressHover(duration time.Duration) {
794 wv.mu.Lock()
795 wv.hoverSuppressedUntil = time.Now().Add(duration)
796 wv.mu.Unlock()
797}
798
799// IsHoverSuppressed returns true if hover focus is temporarily suppressed.
800func (wv *WorkspaceView) IsHoverSuppressed() bool {
801 wv.mu.RLock()
802 defer wv.mu.RUnlock()
803 return time.Now().Before(wv.hoverSuppressedUntil)
804}
805
806// CancelAllPendingHovers cancels any pending hover timers on all pane views.
807// Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
808func (wv *WorkspaceView) CancelAllPendingHovers() {
809 wv.mu.RLock()
810 defer wv.mu.RUnlock()
811
812 for _, pv := range wv.paneViews {
813 if pv != nil {
814 pv.CancelPendingHover()
815 }
816 }
817}