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