detached 8cb6471

feat(focus): add geometric navigation and mode border overlays

- Add focus manager with geometric pane navigation (Alt+HJKL/arrows)
- Add border overlay system for tab/pane mode indicators
- Implement stack-aware navigation (within stacks, then escape)
- Add widget geometry methods (GetAllocatedWidth/Height, ComputePoint)
- Fix stacked pane navigation using stack container geometry
commit
8cb64710bd44a4814e35c4e0d7de524a25f4214e
author
brice · · 2025-12-16 06:52 UTC
parent
739acba
signature
unsigned
11 files · +700 -25
M internal/application/usecase/manage_panes.go +118 -0
@@ -3,6 +3,7 @@ package usecase
33 import (
44 "context"
55 "fmt"
6+ "sort"
67
78 "github.com/bnema/dumber/internal/domain/entity"
89 "github.com/bnema/dumber/internal/logging"
@@ -303,6 +304,123 @@ func (uc *ManagePanesUseCase) NavigateFocus(ctx context.Context, ws *entity.Work
303304 return targetNode, nil
304305 }
305306
307+// GeometricNavigationInput contains data for geometric focus navigation.
308+type GeometricNavigationInput struct {
309+ ActivePaneID entity.PaneID
310+ PaneRects []entity.PaneRect // All visible panes with their positions
311+ Direction NavigateDirection
312+}
313+
314+// GeometricNavigationOutput contains the result.
315+type GeometricNavigationOutput struct {
316+ TargetPaneID entity.PaneID
317+ Found bool
318+}
319+
320+// NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
321+// Algorithm:
322+// 1. Get center of active pane
323+// 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
324+// 3. Score by: primary_distance * 1000 + perpendicular_distance
325+// 4. Return lowest scoring candidate
326+func (uc *ManagePanesUseCase) NavigateFocusGeometric(
327+ ctx context.Context,
328+ input GeometricNavigationInput,
329+) (*GeometricNavigationOutput, error) {
330+ log := logging.FromContext(ctx)
331+ log.Debug().
332+ Str("direction", string(input.Direction)).
333+ Str("active", string(input.ActivePaneID)).
334+ Int("candidates", len(input.PaneRects)).
335+ Msg("geometric navigation")
336+
337+ // Find active pane rect
338+ var activeRect *entity.PaneRect
339+ for i := range input.PaneRects {
340+ if input.PaneRects[i].PaneID == input.ActivePaneID {
341+ activeRect = &input.PaneRects[i]
342+ break
343+ }
344+ }
345+ if activeRect == nil {
346+ log.Debug().Msg("active pane rect not found")
347+ return &GeometricNavigationOutput{Found: false}, nil
348+ }
349+
350+ acx, acy := activeRect.Center()
351+
352+ type candidate struct {
353+ paneID entity.PaneID
354+ score int
355+ }
356+ var candidates []candidate
357+
358+ for _, rect := range input.PaneRects {
359+ if rect.PaneID == input.ActivePaneID {
360+ continue
361+ }
362+
363+ cx, cy := rect.Center()
364+ dx := cx - acx
365+ dy := cy - acy
366+
367+ var inDirection bool
368+ var primaryDist, perpDist int
369+
370+ switch input.Direction {
371+ case NavLeft:
372+ inDirection = dx < 0
373+ primaryDist = abs(dx)
374+ perpDist = abs(dy)
375+ case NavRight:
376+ inDirection = dx > 0
377+ primaryDist = abs(dx)
378+ perpDist = abs(dy)
379+ case NavUp:
380+ inDirection = dy < 0
381+ primaryDist = abs(dy)
382+ perpDist = abs(dx)
383+ case NavDown:
384+ inDirection = dy > 0
385+ primaryDist = abs(dy)
386+ perpDist = abs(dx)
387+ }
388+
389+ if inDirection {
390+ score := primaryDist*1000 + perpDist
391+ candidates = append(candidates, candidate{rect.PaneID, score})
392+ }
393+ }
394+
395+ if len(candidates) == 0 {
396+ log.Debug().Msg("no candidates in direction")
397+ return &GeometricNavigationOutput{Found: false}, nil
398+ }
399+
400+ // Sort by score (lowest first)
401+ sort.Slice(candidates, func(i, j int) bool {
402+ return candidates[i].score < candidates[j].score
403+ })
404+
405+ log.Debug().
406+ Str("target", string(candidates[0].paneID)).
407+ Int("score", candidates[0].score).
408+ Msg("geometric navigation found target")
409+
410+ return &GeometricNavigationOutput{
411+ TargetPaneID: candidates[0].paneID,
412+ Found: true,
413+ }, nil
414+}
415+
416+// abs returns the absolute value of an integer.
417+func abs(x int) int {
418+ if x < 0 {
419+ return -x
420+ }
421+ return x
422+}
423+
306424 // findAdjacentPane finds the adjacent pane in the given direction using tree structure.
307425 func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode {
308426 if node == nil {
@@ -3,6 +3,7 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "sort"
6 7
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
@@ -303,6 +304,123 @@ func (uc *ManagePanesUseCase) NavigateFocus(ctx context.Context, ws *entity.Work
303 return targetNode, nil304 return targetNode, nil
304 }305 }
305 306
307+// GeometricNavigationInput contains data for geometric focus navigation.
308+type GeometricNavigationInput struct {
309+ ActivePaneID entity.PaneID
310+ PaneRects []entity.PaneRect // All visible panes with their positions
311+ Direction NavigateDirection
312+}
313+
314+// GeometricNavigationOutput contains the result.
315+type GeometricNavigationOutput struct {
316+ TargetPaneID entity.PaneID
317+ Found bool
318+}
319+
320+// NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
321+// Algorithm:
322+// 1. Get center of active pane
323+// 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
324+// 3. Score by: primary_distance * 1000 + perpendicular_distance
325+// 4. Return lowest scoring candidate
326+func (uc *ManagePanesUseCase) NavigateFocusGeometric(
327+ ctx context.Context,
328+ input GeometricNavigationInput,
329+) (*GeometricNavigationOutput, error) {
330+ log := logging.FromContext(ctx)
331+ log.Debug().
332+ Str("direction", string(input.Direction)).
333+ Str("active", string(input.ActivePaneID)).
334+ Int("candidates", len(input.PaneRects)).
335+ Msg("geometric navigation")
336+
337+ // Find active pane rect
338+ var activeRect *entity.PaneRect
339+ for i := range input.PaneRects {
340+ if input.PaneRects[i].PaneID == input.ActivePaneID {
341+ activeRect = &input.PaneRects[i]
342+ break
343+ }
344+ }
345+ if activeRect == nil {
346+ log.Debug().Msg("active pane rect not found")
347+ return &GeometricNavigationOutput{Found: false}, nil
348+ }
349+
350+ acx, acy := activeRect.Center()
351+
352+ type candidate struct {
353+ paneID entity.PaneID
354+ score int
355+ }
356+ var candidates []candidate
357+
358+ for _, rect := range input.PaneRects {
359+ if rect.PaneID == input.ActivePaneID {
360+ continue
361+ }
362+
363+ cx, cy := rect.Center()
364+ dx := cx - acx
365+ dy := cy - acy
366+
367+ var inDirection bool
368+ var primaryDist, perpDist int
369+
370+ switch input.Direction {
371+ case NavLeft:
372+ inDirection = dx < 0
373+ primaryDist = abs(dx)
374+ perpDist = abs(dy)
375+ case NavRight:
376+ inDirection = dx > 0
377+ primaryDist = abs(dx)
378+ perpDist = abs(dy)
379+ case NavUp:
380+ inDirection = dy < 0
381+ primaryDist = abs(dy)
382+ perpDist = abs(dx)
383+ case NavDown:
384+ inDirection = dy > 0
385+ primaryDist = abs(dy)
386+ perpDist = abs(dx)
387+ }
388+
389+ if inDirection {
390+ score := primaryDist*1000 + perpDist
391+ candidates = append(candidates, candidate{rect.PaneID, score})
392+ }
393+ }
394+
395+ if len(candidates) == 0 {
396+ log.Debug().Msg("no candidates in direction")
397+ return &GeometricNavigationOutput{Found: false}, nil
398+ }
399+
400+ // Sort by score (lowest first)
401+ sort.Slice(candidates, func(i, j int) bool {
402+ return candidates[i].score < candidates[j].score
403+ })
404+
405+ log.Debug().
406+ Str("target", string(candidates[0].paneID)).
407+ Int("score", candidates[0].score).
408+ Msg("geometric navigation found target")
409+
410+ return &GeometricNavigationOutput{
411+ TargetPaneID: candidates[0].paneID,
412+ Found: true,
413+ }, nil
414+}
415+
416+// abs returns the absolute value of an integer.
417+func abs(x int) int {
418+ if x < 0 {
419+ return -x
420+ }
421+ return x
422+}
423+
306 // findAdjacentPane finds the adjacent pane in the given direction using tree structure.424 // findAdjacentPane finds the adjacent pane in the given direction using tree structure.
307 func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode {425 func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode {
308 if node == nil {426 if node == nil {
A internal/domain/entity/geometry.go +15 -0
@@ -0,0 +1,15 @@
1+// Package entity defines domain entities for the browser.
2+package entity
3+
4+// PaneRect represents a pane's screen position and size.
5+// Used for geometric navigation to find adjacent panes by position.
6+type PaneRect struct {
7+ PaneID PaneID
8+ X, Y int // Top-left position relative to workspace container
9+ W, H int // Width and height
10+}
11+
12+// Center returns the center point of the rectangle.
13+func (r PaneRect) Center() (cx, cy int) {
14+ return r.X + r.W/2, r.Y + r.H/2
15+}
@@ -0,0 +1,15 @@
1+// Package entity defines domain entities for the browser.
2+package entity
3+
4+// PaneRect represents a pane's screen position and size.
5+// Used for geometric navigation to find adjacent panes by position.
6+type PaneRect struct {
7+ PaneID PaneID
8+ X, Y int // Top-left position relative to workspace container
9+ W, H int // Width and height
10+}
11+
12+// Center returns the center point of the rectangle.
13+func (r PaneRect) Center() (cx, cy int) {
14+ return r.X + r.W/2, r.Y + r.H/2
15+}
M internal/ui/app.go +45 -13
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/dumber/internal/infrastructure/webkit"
1616 "github.com/bnema/dumber/internal/logging"
1717 "github.com/bnema/dumber/internal/ui/component"
18+ "github.com/bnema/dumber/internal/ui/focus"
1819 "github.com/bnema/dumber/internal/ui/input"
1920 "github.com/bnema/dumber/internal/ui/layout"
2021 "github.com/bnema/dumber/internal/ui/window"
@@ -44,6 +45,10 @@ type App struct {
4445 // Input handling
4546 keyboardHandler *input.KeyboardHandler
4647
48+ // Focus and border management
49+ focusMgr *focus.Manager
50+ borderMgr *focus.BorderManager
51+
4752 // Native omnibox
4853 omnibox *component.Omnibox
4954
@@ -156,6 +161,12 @@ func (a *App) onActivate(ctx context.Context) {
156161 // Initialize stacked pane manager for incremental widget operations
157162 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
158163
164+ // Initialize focus manager for geometric navigation
165+ a.focusMgr = focus.NewManager(a.panesUC)
166+
167+ // Initialize border manager for mode indicators
168+ a.borderMgr = focus.NewBorderManager(a.widgetFactory)
169+
159170 // Create keyboard handler
160171 a.keyboardHandler = input.NewKeyboardHandler(
161172 ctx,
@@ -365,6 +376,11 @@ func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) err
365376 func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
366377 log := logging.FromContext(ctx)
367378 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
379+
380+ // Update border overlay visibility based on mode
381+ if a.borderMgr != nil {
382+ a.borderMgr.OnModeChange(ctx, from, to)
383+ }
368384 }
369385
370386 // handleNewTab creates a new tab.
@@ -510,6 +526,11 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
510526 // Ensure WebViews are attached to panes
511527 a.attachWorkspaceWebViews(ctx, tab.Workspace, wsView)
512528
529+ // Attach border manager overlay for mode indicators
530+ if a.borderMgr != nil {
531+ wsView.SetModeBorderOverlay(a.borderMgr.Widget())
532+ }
533+
513534 // Store in map
514535 a.workspaceViews[tab.ID] = wsView
515536
@@ -646,18 +667,32 @@ func (a *App) handleClosePane(ctx context.Context) error {
646667 func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDirection) error {
647668 log := logging.FromContext(ctx)
648669
649- if a.panesUC == nil {
650- log.Warn().Msg("panes use case not available")
651- return nil
652- }
653-
654670 ws := a.activeWorkspace()
655671 if ws == nil {
656672 log.Warn().Msg("no active workspace")
657673 return nil
658674 }
659675
660- newPane, err := a.panesUC.NavigateFocus(ctx, ws, direction)
676+ wsView := a.activeWorkspaceView()
677+ if wsView == nil {
678+ log.Warn().Msg("no active workspace view")
679+ return nil
680+ }
681+
682+ // Use geometric navigation if focus manager is available
683+ var newPane *entity.PaneNode
684+ var err error
685+
686+ if a.focusMgr != nil {
687+ newPane, err = a.focusMgr.NavigateGeometric(ctx, ws, wsView, direction)
688+ } else if a.panesUC != nil {
689+ // Fallback to structural navigation
690+ newPane, err = a.panesUC.NavigateFocus(ctx, ws, direction)
691+ } else {
692+ log.Warn().Msg("no navigation manager available")
693+ return nil
694+ }
695+
661696 if err != nil {
662697 log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
663698 return err
@@ -669,13 +704,10 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir
669704 }
670705
671706 // Update the workspace view's active pane
672- wsView := a.activeWorkspaceView()
673- if wsView != nil {
674- if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
675- log.Warn().Err(err).Msg("failed to update active pane in view")
676- } else {
677- wsView.FocusPane(newPane.Pane.ID)
678- }
707+ if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
708+ log.Warn().Err(err).Msg("failed to update active pane in view")
709+ } else {
710+ wsView.FocusPane(newPane.Pane.ID)
679711 }
680712
681713 log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated")
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/dumber/internal/infrastructure/webkit"15 "github.com/bnema/dumber/internal/infrastructure/webkit"
16 "github.com/bnema/dumber/internal/logging"16 "github.com/bnema/dumber/internal/logging"
17 "github.com/bnema/dumber/internal/ui/component"17 "github.com/bnema/dumber/internal/ui/component"
18+ "github.com/bnema/dumber/internal/ui/focus"
18 "github.com/bnema/dumber/internal/ui/input"19 "github.com/bnema/dumber/internal/ui/input"
19 "github.com/bnema/dumber/internal/ui/layout"20 "github.com/bnema/dumber/internal/ui/layout"
20 "github.com/bnema/dumber/internal/ui/window"21 "github.com/bnema/dumber/internal/ui/window"
@@ -44,6 +45,10 @@ type App struct {
44 // Input handling45 // Input handling
45 keyboardHandler *input.KeyboardHandler46 keyboardHandler *input.KeyboardHandler
46 47
48+ // Focus and border management
49+ focusMgr *focus.Manager
50+ borderMgr *focus.BorderManager
51+
47 // Native omnibox52 // Native omnibox
48 omnibox *component.Omnibox53 omnibox *component.Omnibox
49 54
@@ -156,6 +161,12 @@ func (a *App) onActivate(ctx context.Context) {
156 // Initialize stacked pane manager for incremental widget operations161 // Initialize stacked pane manager for incremental widget operations
157 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)162 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
158 163
164+ // Initialize focus manager for geometric navigation
165+ a.focusMgr = focus.NewManager(a.panesUC)
166+
167+ // Initialize border manager for mode indicators
168+ a.borderMgr = focus.NewBorderManager(a.widgetFactory)
169+
159 // Create keyboard handler170 // Create keyboard handler
160 a.keyboardHandler = input.NewKeyboardHandler(171 a.keyboardHandler = input.NewKeyboardHandler(
161 ctx,172 ctx,
@@ -365,6 +376,11 @@ func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) err
365 func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {376 func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
366 log := logging.FromContext(ctx)377 log := logging.FromContext(ctx)
367 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")378 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
379+
380+ // Update border overlay visibility based on mode
381+ if a.borderMgr != nil {
382+ a.borderMgr.OnModeChange(ctx, from, to)
383+ }
368 }384 }
369 385
370 // handleNewTab creates a new tab.386 // handleNewTab creates a new tab.
@@ -510,6 +526,11 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
510 // Ensure WebViews are attached to panes526 // Ensure WebViews are attached to panes
511 a.attachWorkspaceWebViews(ctx, tab.Workspace, wsView)527 a.attachWorkspaceWebViews(ctx, tab.Workspace, wsView)
512 528
529+ // Attach border manager overlay for mode indicators
530+ if a.borderMgr != nil {
531+ wsView.SetModeBorderOverlay(a.borderMgr.Widget())
532+ }
533+
513 // Store in map534 // Store in map
514 a.workspaceViews[tab.ID] = wsView535 a.workspaceViews[tab.ID] = wsView
515 536
@@ -646,18 +667,32 @@ func (a *App) handleClosePane(ctx context.Context) error {
646 func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDirection) error {667 func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDirection) error {
647 log := logging.FromContext(ctx)668 log := logging.FromContext(ctx)
648 669
649- if a.panesUC == nil {
650- log.Warn().Msg("panes use case not available")
651- return nil
652- }
653-
654 ws := a.activeWorkspace()670 ws := a.activeWorkspace()
655 if ws == nil {671 if ws == nil {
656 log.Warn().Msg("no active workspace")672 log.Warn().Msg("no active workspace")
657 return nil673 return nil
658 }674 }
659 675
660- newPane, err := a.panesUC.NavigateFocus(ctx, ws, direction)676+ wsView := a.activeWorkspaceView()
677+ if wsView == nil {
678+ log.Warn().Msg("no active workspace view")
679+ return nil
680+ }
681+
682+ // Use geometric navigation if focus manager is available
683+ var newPane *entity.PaneNode
684+ var err error
685+
686+ if a.focusMgr != nil {
687+ newPane, err = a.focusMgr.NavigateGeometric(ctx, ws, wsView, direction)
688+ } else if a.panesUC != nil {
689+ // Fallback to structural navigation
690+ newPane, err = a.panesUC.NavigateFocus(ctx, ws, direction)
691+ } else {
692+ log.Warn().Msg("no navigation manager available")
693+ return nil
694+ }
695+
661 if err != nil {696 if err != nil {
662 log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")697 log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
663 return err698 return err
@@ -669,13 +704,10 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir
669 }704 }
670 705
671 // Update the workspace view's active pane706 // Update the workspace view's active pane
672- wsView := a.activeWorkspaceView()707+ if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
673- if wsView != nil {708+ log.Warn().Err(err).Msg("failed to update active pane in view")
674- if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {709+ } else {
675- log.Warn().Err(err).Msg("failed to update active pane in view")710+ wsView.FocusPane(newPane.Pane.ID)
676- } else {
677- wsView.FocusPane(newPane.Pane.ID)
678- }
679 }711 }
680 712
681 log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated")713 log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated")
M internal/ui/component/pane_view.go +1 -1
@@ -10,7 +10,7 @@ import (
1010
1111 const (
1212 // CSS class applied to active pane's border overlay
13- activePaneClass = "active-pane"
13+ activePaneClass = "pane-active"
1414 )
1515
1616 // PaneView is a container for a single WebView with active state indication.
@@ -10,7 +10,7 @@ import (
10 10
11 const (11 const (
12 // CSS class applied to active pane's border overlay12 // CSS class applied to active pane's border overlay
13- activePaneClass = "active-pane"13+ activePaneClass = "pane-active"
14 )14 )
15 15
16 // PaneView is a container for a single WebView with active state indication.16 // PaneView is a container for a single WebView with active state indication.
M internal/ui/component/workspace_view.go +73 -3
@@ -24,9 +24,13 @@ type WorkspaceView struct {
2424 factory layout.WidgetFactory
2525 treeRenderer *layout.TreeRenderer
2626 container layout.BoxWidget
27- rootWidget layout.Widget // Current root widget for removal on rebuild
27+ overlay layout.OverlayWidget // Wraps container for mode borders
28+ rootWidget layout.Widget // Current root widget for removal on rebuild
2829 logger zerolog.Logger
2930
31+ // Mode border overlay slot
32+ modeBorderWidget layout.Widget
33+
3034 workspace *entity.Workspace
3135 paneViews map[entity.PaneID]*PaneView
3236 activePaneID entity.PaneID
@@ -78,9 +82,17 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp
7882 container.SetVexpand(true)
7983 container.SetVisible(true)
8084
85+ // Wrap container in overlay for mode borders
86+ overlay := factory.NewOverlay()
87+ overlay.SetHexpand(true)
88+ overlay.SetVexpand(true)
89+ overlay.SetChild(container)
90+ overlay.SetVisible(true)
91+
8192 wv := &WorkspaceView{
8293 factory: factory,
8394 container: container,
95+ overlay: overlay,
8496 logger: log.With().Str("component", "workspace-view").Logger(),
8597 paneViews: make(map[entity.PaneID]*PaneView),
8698 }
@@ -248,9 +260,10 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi
248260 return nil
249261 }
250262
251-// Widget returns the underlying container widget for embedding in the UI.
263+// Widget returns the overlay widget for embedding in the UI.
264+// The overlay wraps the pane container and allows mode borders to be displayed.
252265 func (wv *WorkspaceView) Widget() layout.Widget {
253- return wv.container
266+ return wv.overlay
254267 }
255268
256269 // Container returns the underlying BoxWidget for direct access.
@@ -322,3 +335,60 @@ func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) {
322335 func (wv *WorkspaceView) Factory() layout.WidgetFactory {
323336 return wv.factory
324337 }
338+
339+// SetModeBorderOverlay attaches a mode border overlay widget.
340+// The widget will be displayed on top of the pane container when modes are active.
341+func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) {
342+ wv.mu.Lock()
343+ defer wv.mu.Unlock()
344+
345+ // Remove old overlay if present
346+ if wv.modeBorderWidget != nil && wv.overlay != nil {
347+ wv.overlay.RemoveOverlay(wv.modeBorderWidget)
348+ }
349+
350+ wv.modeBorderWidget = widget
351+
352+ // Add new overlay
353+ if widget != nil && wv.overlay != nil {
354+ wv.overlay.AddOverlay(widget)
355+ // Don't clip or measure the overlay - it should fill the entire area
356+ wv.overlay.SetClipOverlay(widget, false)
357+ wv.overlay.SetMeasureOverlay(widget, false)
358+ }
359+}
360+
361+// GetPaneWidget returns the widget for a pane ID.
362+// Implements focus.PaneGeometryProvider.
363+func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {
364+ wv.mu.RLock()
365+ defer wv.mu.RUnlock()
366+
367+ pv, ok := wv.paneViews[paneID]
368+ if !ok || pv == nil {
369+ return nil
370+ }
371+ return pv.Widget()
372+}
373+
374+// GetStackContainerWidget returns the stack container widget for a stacked pane.
375+// Returns nil if the pane is not in a stack.
376+// Implements focus.PaneGeometryProvider.
377+func (wv *WorkspaceView) GetStackContainerWidget(paneID entity.PaneID) layout.Widget {
378+ if wv.treeRenderer == nil {
379+ return nil
380+ }
381+
382+ stackedView := wv.treeRenderer.GetStackedViewForPane(string(paneID))
383+ if stackedView == nil {
384+ return nil
385+ }
386+
387+ return stackedView.Widget()
388+}
389+
390+// ContainerWidget returns the container widget for relative positioning.
391+// Implements focus.PaneGeometryProvider.
392+func (wv *WorkspaceView) ContainerWidget() layout.Widget {
393+ return wv.container
394+}
@@ -24,9 +24,13 @@ type WorkspaceView struct {
24 factory layout.WidgetFactory24 factory layout.WidgetFactory
25 treeRenderer *layout.TreeRenderer25 treeRenderer *layout.TreeRenderer
26 container layout.BoxWidget26 container layout.BoxWidget
27- rootWidget layout.Widget // Current root widget for removal on rebuild27+ overlay layout.OverlayWidget // Wraps container for mode borders
28+ rootWidget layout.Widget // Current root widget for removal on rebuild
28 logger zerolog.Logger29 logger zerolog.Logger
29 30
31+ // Mode border overlay slot
32+ modeBorderWidget layout.Widget
33+
30 workspace *entity.Workspace34 workspace *entity.Workspace
31 paneViews map[entity.PaneID]*PaneView35 paneViews map[entity.PaneID]*PaneView
32 activePaneID entity.PaneID36 activePaneID entity.PaneID
@@ -78,9 +82,17 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp
78 container.SetVexpand(true)82 container.SetVexpand(true)
79 container.SetVisible(true)83 container.SetVisible(true)
80 84
85+ // Wrap container in overlay for mode borders
86+ overlay := factory.NewOverlay()
87+ overlay.SetHexpand(true)
88+ overlay.SetVexpand(true)
89+ overlay.SetChild(container)
90+ overlay.SetVisible(true)
91+
81 wv := &WorkspaceView{92 wv := &WorkspaceView{
82 factory: factory,93 factory: factory,
83 container: container,94 container: container,
95+ overlay: overlay,
84 logger: log.With().Str("component", "workspace-view").Logger(),96 logger: log.With().Str("component", "workspace-view").Logger(),
85 paneViews: make(map[entity.PaneID]*PaneView),97 paneViews: make(map[entity.PaneID]*PaneView),
86 }98 }
@@ -248,9 +260,10 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi
248 return nil260 return nil
249 }261 }
250 262
251-// Widget returns the underlying container widget for embedding in the UI.263+// Widget returns the overlay widget for embedding in the UI.
264+// The overlay wraps the pane container and allows mode borders to be displayed.
252 func (wv *WorkspaceView) Widget() layout.Widget {265 func (wv *WorkspaceView) Widget() layout.Widget {
253- return wv.container266+ return wv.overlay
254 }267 }
255 268
256 // Container returns the underlying BoxWidget for direct access.269 // Container returns the underlying BoxWidget for direct access.
@@ -322,3 +335,60 @@ func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) {
322 func (wv *WorkspaceView) Factory() layout.WidgetFactory {335 func (wv *WorkspaceView) Factory() layout.WidgetFactory {
323 return wv.factory336 return wv.factory
324 }337 }
338+
339+// SetModeBorderOverlay attaches a mode border overlay widget.
340+// The widget will be displayed on top of the pane container when modes are active.
341+func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) {
342+ wv.mu.Lock()
343+ defer wv.mu.Unlock()
344+
345+ // Remove old overlay if present
346+ if wv.modeBorderWidget != nil && wv.overlay != nil {
347+ wv.overlay.RemoveOverlay(wv.modeBorderWidget)
348+ }
349+
350+ wv.modeBorderWidget = widget
351+
352+ // Add new overlay
353+ if widget != nil && wv.overlay != nil {
354+ wv.overlay.AddOverlay(widget)
355+ // Don't clip or measure the overlay - it should fill the entire area
356+ wv.overlay.SetClipOverlay(widget, false)
357+ wv.overlay.SetMeasureOverlay(widget, false)
358+ }
359+}
360+
361+// GetPaneWidget returns the widget for a pane ID.
362+// Implements focus.PaneGeometryProvider.
363+func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {
364+ wv.mu.RLock()
365+ defer wv.mu.RUnlock()
366+
367+ pv, ok := wv.paneViews[paneID]
368+ if !ok || pv == nil {
369+ return nil
370+ }
371+ return pv.Widget()
372+}
373+
374+// GetStackContainerWidget returns the stack container widget for a stacked pane.
375+// Returns nil if the pane is not in a stack.
376+// Implements focus.PaneGeometryProvider.
377+func (wv *WorkspaceView) GetStackContainerWidget(paneID entity.PaneID) layout.Widget {
378+ if wv.treeRenderer == nil {
379+ return nil
380+ }
381+
382+ stackedView := wv.treeRenderer.GetStackedViewForPane(string(paneID))
383+ if stackedView == nil {
384+ return nil
385+ }
386+
387+ return stackedView.Widget()
388+}
389+
390+// ContainerWidget returns the container widget for relative positioning.
391+// Implements focus.PaneGeometryProvider.
392+func (wv *WorkspaceView) ContainerWidget() layout.Widget {
393+ return wv.container
394+}
A internal/ui/focus/borders.go +81 -0
@@ -0,0 +1,81 @@
1+package focus
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/logging"
7+ "github.com/bnema/dumber/internal/ui/input"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+)
10+
11+// CSS classes for mode borders.
12+const (
13+ paneModeClass = "pane-mode-active"
14+ tabModeClass = "tab-mode-active"
15+)
16+
17+// BorderManager manages mode indicator borders on the workspace container.
18+// It creates a transparent overlay box that displays colored borders when
19+// the user enters tab mode or pane mode.
20+type BorderManager struct {
21+ borderOverlay layout.BoxWidget
22+ currentMode input.Mode
23+}
24+
25+// NewBorderManager creates a border manager.
26+// It creates a border overlay widget that should be added to the workspace container.
27+func NewBorderManager(factory layout.WidgetFactory) *BorderManager {
28+ // Create overlay box for mode borders
29+ borderBox := factory.NewBox(layout.OrientationVertical, 0)
30+ borderBox.SetCanFocus(false)
31+ borderBox.SetCanTarget(false) // Don't intercept pointer events
32+ borderBox.SetHexpand(true)
33+ borderBox.SetVexpand(true)
34+ borderBox.SetVisible(false) // Hidden in normal mode
35+
36+ return &BorderManager{
37+ borderOverlay: borderBox,
38+ currentMode: input.ModeNormal,
39+ }
40+}
41+
42+// Widget returns the border overlay widget for adding to workspace overlay.
43+func (bm *BorderManager) Widget() layout.Widget {
44+ return bm.borderOverlay
45+}
46+
47+// OnModeChange updates border visibility based on mode.
48+func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) {
49+ log := logging.FromContext(ctx)
50+ log.Debug().
51+ Str("from", from.String()).
52+ Str("to", to.String()).
53+ Msg("border manager: mode changed")
54+
55+ // Remove old mode class
56+ switch from {
57+ case input.ModePane:
58+ bm.borderOverlay.RemoveCssClass(paneModeClass)
59+ case input.ModeTab:
60+ bm.borderOverlay.RemoveCssClass(tabModeClass)
61+ }
62+
63+ // Add new mode class and set visibility
64+ switch to {
65+ case input.ModePane:
66+ bm.borderOverlay.AddCssClass(paneModeClass)
67+ bm.borderOverlay.SetVisible(true)
68+ case input.ModeTab:
69+ bm.borderOverlay.AddCssClass(tabModeClass)
70+ bm.borderOverlay.SetVisible(true)
71+ case input.ModeNormal:
72+ bm.borderOverlay.SetVisible(false)
73+ }
74+
75+ bm.currentMode = to
76+}
77+
78+// CurrentMode returns the current input mode.
79+func (bm *BorderManager) CurrentMode() input.Mode {
80+ return bm.currentMode
81+}
@@ -0,0 +1,81 @@
1+package focus
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/logging"
7+ "github.com/bnema/dumber/internal/ui/input"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+)
10+
11+// CSS classes for mode borders.
12+const (
13+ paneModeClass = "pane-mode-active"
14+ tabModeClass = "tab-mode-active"
15+)
16+
17+// BorderManager manages mode indicator borders on the workspace container.
18+// It creates a transparent overlay box that displays colored borders when
19+// the user enters tab mode or pane mode.
20+type BorderManager struct {
21+ borderOverlay layout.BoxWidget
22+ currentMode input.Mode
23+}
24+
25+// NewBorderManager creates a border manager.
26+// It creates a border overlay widget that should be added to the workspace container.
27+func NewBorderManager(factory layout.WidgetFactory) *BorderManager {
28+ // Create overlay box for mode borders
29+ borderBox := factory.NewBox(layout.OrientationVertical, 0)
30+ borderBox.SetCanFocus(false)
31+ borderBox.SetCanTarget(false) // Don't intercept pointer events
32+ borderBox.SetHexpand(true)
33+ borderBox.SetVexpand(true)
34+ borderBox.SetVisible(false) // Hidden in normal mode
35+
36+ return &BorderManager{
37+ borderOverlay: borderBox,
38+ currentMode: input.ModeNormal,
39+ }
40+}
41+
42+// Widget returns the border overlay widget for adding to workspace overlay.
43+func (bm *BorderManager) Widget() layout.Widget {
44+ return bm.borderOverlay
45+}
46+
47+// OnModeChange updates border visibility based on mode.
48+func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) {
49+ log := logging.FromContext(ctx)
50+ log.Debug().
51+ Str("from", from.String()).
52+ Str("to", to.String()).
53+ Msg("border manager: mode changed")
54+
55+ // Remove old mode class
56+ switch from {
57+ case input.ModePane:
58+ bm.borderOverlay.RemoveCssClass(paneModeClass)
59+ case input.ModeTab:
60+ bm.borderOverlay.RemoveCssClass(tabModeClass)
61+ }
62+
63+ // Add new mode class and set visibility
64+ switch to {
65+ case input.ModePane:
66+ bm.borderOverlay.AddCssClass(paneModeClass)
67+ bm.borderOverlay.SetVisible(true)
68+ case input.ModeTab:
69+ bm.borderOverlay.AddCssClass(tabModeClass)
70+ bm.borderOverlay.SetVisible(true)
71+ case input.ModeNormal:
72+ bm.borderOverlay.SetVisible(false)
73+ }
74+
75+ bm.currentMode = to
76+}
77+
78+// CurrentMode returns the current input mode.
79+func (bm *BorderManager) CurrentMode() input.Mode {
80+ return bm.currentMode
81+}
A internal/ui/focus/manager.go +210 -0
@@ -0,0 +1,210 @@
1+// Package focus provides focus state management and geometric navigation for panes.
2+package focus
3+
4+import (
5+ "context"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/layout"
11+)
12+
13+// PaneGeometryProvider provides geometry information for panes.
14+// This interface allows the focus manager to work without direct component dependencies.
15+type PaneGeometryProvider interface {
16+ // GetPaneIDs returns all pane IDs in the provider.
17+ GetPaneIDs() []entity.PaneID
18+ // GetPaneWidget returns the widget for a pane, or nil if not found.
19+ GetPaneWidget(paneID entity.PaneID) layout.Widget
20+ // GetStackContainerWidget returns the stack container widget for a stacked pane.
21+ // Returns nil if the pane is not in a stack.
22+ GetStackContainerWidget(paneID entity.PaneID) layout.Widget
23+ // ContainerWidget returns the container widget for relative positioning.
24+ ContainerWidget() layout.Widget
25+}
26+
27+// Manager handles focus state and geometric navigation.
28+type Manager struct {
29+ panesUC *usecase.ManagePanesUseCase
30+}
31+
32+// NewManager creates a focus manager.
33+func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager {
34+ return &Manager{panesUC: panesUC}
35+}
36+
37+// CollectPaneRects gathers geometry from all visible panes.
38+// For panes in stacks, uses the stack container's geometry since individual
39+// collapsed panes have no allocated size.
40+func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect {
41+ var rects []entity.PaneRect
42+
43+ container := provider.ContainerWidget()
44+
45+ for _, paneID := range provider.GetPaneIDs() {
46+ // First check if pane is in a stack - use stack container geometry
47+ var widget layout.Widget
48+ if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil {
49+ widget = stackWidget
50+ } else {
51+ widget = provider.GetPaneWidget(paneID)
52+ }
53+
54+ if widget == nil || !widget.IsVisible() {
55+ continue
56+ }
57+
58+ // Get position relative to container
59+ x, y, ok := widget.ComputePoint(container)
60+ if !ok {
61+ continue
62+ }
63+
64+ w := widget.GetAllocatedWidth()
65+ h := widget.GetAllocatedHeight()
66+
67+ // Skip widgets with no size (collapsed/hidden)
68+ if w <= 0 || h <= 0 {
69+ continue
70+ }
71+
72+ rects = append(rects, entity.PaneRect{
73+ PaneID: paneID,
74+ X: int(x),
75+ Y: int(y),
76+ W: w,
77+ H: h,
78+ })
79+ }
80+
81+ return rects
82+}
83+
84+// NavigateGeometric performs geometric navigation and returns the target pane.
85+// It handles stack navigation internally: when navigating up/down and the current
86+// pane is in a stack, it will try to navigate within the stack first before
87+// escaping to external panes.
88+func (m *Manager) NavigateGeometric(
89+ ctx context.Context,
90+ ws *entity.Workspace,
91+ provider PaneGeometryProvider,
92+ direction usecase.NavigateDirection,
93+) (*entity.PaneNode, error) {
94+ log := logging.FromContext(ctx)
95+
96+ if ws == nil {
97+ return nil, nil
98+ }
99+
100+ // Get active pane node
101+ activeNode := ws.ActivePane()
102+ if activeNode == nil {
103+ log.Debug().Msg("no active pane for navigation")
104+ return nil, nil
105+ }
106+
107+ // Check if we should navigate within a stack first
108+ if direction == usecase.NavUp || direction == usecase.NavDown {
109+ if activeNode.Parent != nil && activeNode.Parent.IsStacked {
110+ // Try to navigate within the stack
111+ stackNode := activeNode.Parent
112+ canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction)
113+ if canNavigate {
114+ ws.ActivePaneID = newPaneID
115+ return ws.FindPane(newPaneID), nil
116+ }
117+ // At stack boundary - fall through to geometric navigation
118+ log.Debug().Msg("at stack boundary, using geometric navigation")
119+ }
120+ }
121+
122+ // Collect geometry from visible panes
123+ rects := m.CollectPaneRects(provider)
124+ if len(rects) == 0 {
125+ return nil, nil
126+ }
127+
128+ // Run geometric algorithm
129+ output, err := m.panesUC.NavigateFocusGeometric(ctx, usecase.GeometricNavigationInput{
130+ ActivePaneID: ws.ActivePaneID,
131+ PaneRects: rects,
132+ Direction: direction,
133+ })
134+ if err != nil {
135+ return nil, err
136+ }
137+ if !output.Found {
138+ return nil, nil
139+ }
140+
141+ // Update workspace
142+ ws.ActivePaneID = output.TargetPaneID
143+
144+ // Return target node
145+ return ws.FindPane(output.TargetPaneID), nil
146+}
147+
148+// navigateWithinStack tries to navigate within a stack.
149+// Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a
150+// boundary and navigation should escape the stack.
151+func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection) (bool, entity.PaneID) {
152+ if !stackNode.IsStacked || len(stackNode.Children) == 0 {
153+ return false, ""
154+ }
155+
156+ // Find current index in stack
157+ currentIdx := -1
158+ for i, child := range stackNode.Children {
159+ if child == currentNode {
160+ currentIdx = i
161+ break
162+ }
163+ }
164+ if currentIdx < 0 {
165+ return false, ""
166+ }
167+
168+ var newIdx int
169+ switch direction {
170+ case usecase.NavUp:
171+ newIdx = currentIdx - 1
172+ if newIdx < 0 {
173+ // At top boundary
174+ return false, ""
175+ }
176+ case usecase.NavDown:
177+ newIdx = currentIdx + 1
178+ if newIdx >= len(stackNode.Children) {
179+ // At bottom boundary
180+ return false, ""
181+ }
182+ default:
183+ return false, ""
184+ }
185+
186+ // Update stack active index
187+ stackNode.ActiveStackIndex = newIdx
188+
189+ // Get the new pane
190+ targetNode := stackNode.Children[newIdx]
191+ if targetNode.Pane != nil {
192+ return true, targetNode.Pane.ID
193+ }
194+
195+ // If the child is not a leaf, find the first leaf
196+ var leafPaneID entity.PaneID
197+ targetNode.Walk(func(n *entity.PaneNode) bool {
198+ if n.IsLeaf() && n.Pane != nil {
199+ leafPaneID = n.Pane.ID
200+ return false
201+ }
202+ return true
203+ })
204+
205+ if leafPaneID != "" {
206+ return true, leafPaneID
207+ }
208+
209+ return false, ""
210+}
@@ -0,0 +1,210 @@
1+// Package focus provides focus state management and geometric navigation for panes.
2+package focus
3+
4+import (
5+ "context"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/layout"
11+)
12+
13+// PaneGeometryProvider provides geometry information for panes.
14+// This interface allows the focus manager to work without direct component dependencies.
15+type PaneGeometryProvider interface {
16+ // GetPaneIDs returns all pane IDs in the provider.
17+ GetPaneIDs() []entity.PaneID
18+ // GetPaneWidget returns the widget for a pane, or nil if not found.
19+ GetPaneWidget(paneID entity.PaneID) layout.Widget
20+ // GetStackContainerWidget returns the stack container widget for a stacked pane.
21+ // Returns nil if the pane is not in a stack.
22+ GetStackContainerWidget(paneID entity.PaneID) layout.Widget
23+ // ContainerWidget returns the container widget for relative positioning.
24+ ContainerWidget() layout.Widget
25+}
26+
27+// Manager handles focus state and geometric navigation.
28+type Manager struct {
29+ panesUC *usecase.ManagePanesUseCase
30+}
31+
32+// NewManager creates a focus manager.
33+func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager {
34+ return &Manager{panesUC: panesUC}
35+}
36+
37+// CollectPaneRects gathers geometry from all visible panes.
38+// For panes in stacks, uses the stack container's geometry since individual
39+// collapsed panes have no allocated size.
40+func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect {
41+ var rects []entity.PaneRect
42+
43+ container := provider.ContainerWidget()
44+
45+ for _, paneID := range provider.GetPaneIDs() {
46+ // First check if pane is in a stack - use stack container geometry
47+ var widget layout.Widget
48+ if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil {
49+ widget = stackWidget
50+ } else {
51+ widget = provider.GetPaneWidget(paneID)
52+ }
53+
54+ if widget == nil || !widget.IsVisible() {
55+ continue
56+ }
57+
58+ // Get position relative to container
59+ x, y, ok := widget.ComputePoint(container)
60+ if !ok {
61+ continue
62+ }
63+
64+ w := widget.GetAllocatedWidth()
65+ h := widget.GetAllocatedHeight()
66+
67+ // Skip widgets with no size (collapsed/hidden)
68+ if w <= 0 || h <= 0 {
69+ continue
70+ }
71+
72+ rects = append(rects, entity.PaneRect{
73+ PaneID: paneID,
74+ X: int(x),
75+ Y: int(y),
76+ W: w,
77+ H: h,
78+ })
79+ }
80+
81+ return rects
82+}
83+
84+// NavigateGeometric performs geometric navigation and returns the target pane.
85+// It handles stack navigation internally: when navigating up/down and the current
86+// pane is in a stack, it will try to navigate within the stack first before
87+// escaping to external panes.
88+func (m *Manager) NavigateGeometric(
89+ ctx context.Context,
90+ ws *entity.Workspace,
91+ provider PaneGeometryProvider,
92+ direction usecase.NavigateDirection,
93+) (*entity.PaneNode, error) {
94+ log := logging.FromContext(ctx)
95+
96+ if ws == nil {
97+ return nil, nil
98+ }
99+
100+ // Get active pane node
101+ activeNode := ws.ActivePane()
102+ if activeNode == nil {
103+ log.Debug().Msg("no active pane for navigation")
104+ return nil, nil
105+ }
106+
107+ // Check if we should navigate within a stack first
108+ if direction == usecase.NavUp || direction == usecase.NavDown {
109+ if activeNode.Parent != nil && activeNode.Parent.IsStacked {
110+ // Try to navigate within the stack
111+ stackNode := activeNode.Parent
112+ canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction)
113+ if canNavigate {
114+ ws.ActivePaneID = newPaneID
115+ return ws.FindPane(newPaneID), nil
116+ }
117+ // At stack boundary - fall through to geometric navigation
118+ log.Debug().Msg("at stack boundary, using geometric navigation")
119+ }
120+ }
121+
122+ // Collect geometry from visible panes
123+ rects := m.CollectPaneRects(provider)
124+ if len(rects) == 0 {
125+ return nil, nil
126+ }
127+
128+ // Run geometric algorithm
129+ output, err := m.panesUC.NavigateFocusGeometric(ctx, usecase.GeometricNavigationInput{
130+ ActivePaneID: ws.ActivePaneID,
131+ PaneRects: rects,
132+ Direction: direction,
133+ })
134+ if err != nil {
135+ return nil, err
136+ }
137+ if !output.Found {
138+ return nil, nil
139+ }
140+
141+ // Update workspace
142+ ws.ActivePaneID = output.TargetPaneID
143+
144+ // Return target node
145+ return ws.FindPane(output.TargetPaneID), nil
146+}
147+
148+// navigateWithinStack tries to navigate within a stack.
149+// Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a
150+// boundary and navigation should escape the stack.
151+func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection) (bool, entity.PaneID) {
152+ if !stackNode.IsStacked || len(stackNode.Children) == 0 {
153+ return false, ""
154+ }
155+
156+ // Find current index in stack
157+ currentIdx := -1
158+ for i, child := range stackNode.Children {
159+ if child == currentNode {
160+ currentIdx = i
161+ break
162+ }
163+ }
164+ if currentIdx < 0 {
165+ return false, ""
166+ }
167+
168+ var newIdx int
169+ switch direction {
170+ case usecase.NavUp:
171+ newIdx = currentIdx - 1
172+ if newIdx < 0 {
173+ // At top boundary
174+ return false, ""
175+ }
176+ case usecase.NavDown:
177+ newIdx = currentIdx + 1
178+ if newIdx >= len(stackNode.Children) {
179+ // At bottom boundary
180+ return false, ""
181+ }
182+ default:
183+ return false, ""
184+ }
185+
186+ // Update stack active index
187+ stackNode.ActiveStackIndex = newIdx
188+
189+ // Get the new pane
190+ targetNode := stackNode.Children[newIdx]
191+ if targetNode.Pane != nil {
192+ return true, targetNode.Pane.ID
193+ }
194+
195+ // If the child is not a leaf, find the first leaf
196+ var leafPaneID entity.PaneID
197+ targetNode.Walk(func(n *entity.PaneNode) bool {
198+ if n.IsLeaf() && n.Pane != nil {
199+ leafPaneID = n.Pane.ID
200+ return false
201+ }
202+ return true
203+ })
204+
205+ if leafPaneID != "" {
206+ return true, leafPaneID
207+ }
208+
209+ return false, ""
210+}
M internal/ui/input/shortcuts.go +10 -0
@@ -172,6 +172,16 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work
172172 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
173173 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
174174 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
175+
176+ // Pane navigation (Alt+HJKL like vim)
177+ s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
178+ s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight
179+ s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
180+ s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
181+
182+ // Also support Alt+Arrow keys for pane navigation (except Left/Right used for back/forward)
183+ s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
184+ s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
175185 }
176186
177187 // buildTabModeShortcuts populates tab mode shortcuts from config.
@@ -172,6 +172,16 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work
172 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset172 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
173 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit173 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
174 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen174 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
175+
176+ // Pane navigation (Alt+HJKL like vim)
177+ s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
178+ s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight
179+ s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
180+ s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
181+
182+ // Also support Alt+Arrow keys for pane navigation (except Left/Right used for back/forward)
183+ s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
184+ s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
175 }185 }
176 186
177 // buildTabModeShortcuts populates tab mode shortcuts from config.187 // buildTabModeShortcuts populates tab mode shortcuts from config.
M internal/ui/layout/gtk_widgets.go +131 -0
@@ -1,6 +1,7 @@
11 package layout
22
33 import (
4+ "github.com/jwijenbergh/puregotk/v4/graphene"
45 "github.com/jwijenbergh/puregotk/v4/gtk"
56 "github.com/jwijenbergh/puregotk/v4/pango"
67 )
@@ -52,6 +53,25 @@ func (w *gtkWidget) GetParent() Widget {
5253 return &gtkWidget{inner: parent}
5354 }
5455
56+func (w *gtkWidget) GetAllocatedWidth() int { return w.inner.GetAllocatedWidth() }
57+func (w *gtkWidget) GetAllocatedHeight() int { return w.inner.GetAllocatedHeight() }
58+
59+func (w *gtkWidget) ComputePoint(target Widget) (x, y float64, ok bool) {
60+ srcPoint := &graphene.Point{X: 0, Y: 0}
61+ outPoint := &graphene.Point{}
62+
63+ var targetGtk *gtk.Widget
64+ if target != nil {
65+ targetGtk = target.GtkWidget()
66+ }
67+
68+ ok = w.inner.ComputePoint(targetGtk, srcPoint, outPoint)
69+ if !ok {
70+ return 0, 0, false
71+ }
72+ return float64(outPoint.X), float64(outPoint.Y), true
73+}
74+
5575 // gtkPaned wraps gtk.Paned to implement PanedWidget.
5676 type gtkPaned struct {
5777 inner *gtk.Paned
@@ -149,6 +169,22 @@ func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
149169 func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() }
150170 func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() }
151171
172+func (p *gtkPaned) ComputePoint(target Widget) (x, y float64, ok bool) {
173+ srcPoint := &graphene.Point{X: 0, Y: 0}
174+ outPoint := &graphene.Point{}
175+
176+ var targetGtk *gtk.Widget
177+ if target != nil {
178+ targetGtk = target.GtkWidget()
179+ }
180+
181+ ok = p.inner.ComputePoint(targetGtk, srcPoint, outPoint)
182+ if !ok {
183+ return 0, 0, false
184+ }
185+ return float64(outPoint.X), float64(outPoint.Y), true
186+}
187+
152188 // gtkBox wraps gtk.Box to implement BoxWidget.
153189 type gtkBox struct {
154190 inner *gtk.Box
@@ -234,6 +270,25 @@ func (b *gtkBox) GetSpacing() int { return b.inner.GetSpacing() }
234270 func (b *gtkBox) SetOrientation(o Orientation) { b.inner.SetOrientation(o) }
235271 func (b *gtkBox) GetOrientation() Orientation { return b.inner.GetOrientation() }
236272
273+func (b *gtkBox) GetAllocatedWidth() int { return b.inner.GetAllocatedWidth() }
274+func (b *gtkBox) GetAllocatedHeight() int { return b.inner.GetAllocatedHeight() }
275+
276+func (b *gtkBox) ComputePoint(target Widget) (x, y float64, ok bool) {
277+ srcPoint := &graphene.Point{X: 0, Y: 0}
278+ outPoint := &graphene.Point{}
279+
280+ var targetGtk *gtk.Widget
281+ if target != nil {
282+ targetGtk = target.GtkWidget()
283+ }
284+
285+ ok = b.inner.ComputePoint(targetGtk, srcPoint, outPoint)
286+ if !ok {
287+ return 0, 0, false
288+ }
289+ return float64(outPoint.X), float64(outPoint.Y), true
290+}
291+
237292 // gtkOverlay wraps gtk.Overlay to implement OverlayWidget.
238293 type gtkOverlay struct {
239294 inner *gtk.Overlay
@@ -327,6 +382,25 @@ func (o *gtkOverlay) GetMeasureOverlay(overlay Widget) bool {
327382 return o.inner.GetMeasureOverlay(overlay.GtkWidget())
328383 }
329384
385+func (o *gtkOverlay) GetAllocatedWidth() int { return o.inner.GetAllocatedWidth() }
386+func (o *gtkOverlay) GetAllocatedHeight() int { return o.inner.GetAllocatedHeight() }
387+
388+func (o *gtkOverlay) ComputePoint(target Widget) (x, y float64, ok bool) {
389+ srcPoint := &graphene.Point{X: 0, Y: 0}
390+ outPoint := &graphene.Point{}
391+
392+ var targetGtk *gtk.Widget
393+ if target != nil {
394+ targetGtk = target.GtkWidget()
395+ }
396+
397+ ok = o.inner.ComputePoint(targetGtk, srcPoint, outPoint)
398+ if !ok {
399+ return 0, 0, false
400+ }
401+ return float64(outPoint.X), float64(outPoint.Y), true
402+}
403+
330404 // gtkLabel wraps gtk.Label to implement LabelWidget.
331405 type gtkLabel struct {
332406 inner *gtk.Label
@@ -369,6 +443,25 @@ func (l *gtkLabel) SetEllipsize(mode EllipsizeMode) { l.inner.SetEllipsize(pango
369443 func (l *gtkLabel) SetMaxWidthChars(n int) { l.inner.SetMaxWidthChars(n) }
370444 func (l *gtkLabel) SetXalign(x float32) { l.inner.SetXalign(x) }
371445
446+func (l *gtkLabel) GetAllocatedWidth() int { return l.inner.GetAllocatedWidth() }
447+func (l *gtkLabel) GetAllocatedHeight() int { return l.inner.GetAllocatedHeight() }
448+
449+func (l *gtkLabel) ComputePoint(target Widget) (x, y float64, ok bool) {
450+ srcPoint := &graphene.Point{X: 0, Y: 0}
451+ outPoint := &graphene.Point{}
452+
453+ var targetGtk *gtk.Widget
454+ if target != nil {
455+ targetGtk = target.GtkWidget()
456+ }
457+
458+ ok = l.inner.ComputePoint(targetGtk, srcPoint, outPoint)
459+ if !ok {
460+ return 0, 0, false
461+ }
462+ return float64(outPoint.X), float64(outPoint.Y), true
463+}
464+
372465 // gtkButton wraps gtk.Button to implement ButtonWidget.
373466 type gtkButton struct {
374467 inner *gtk.Button
@@ -430,6 +523,25 @@ func (btn *gtkButton) ConnectClicked(callback func()) uint32 {
430523 return btn.inner.ConnectClicked(&cb)
431524 }
432525
526+func (btn *gtkButton) GetAllocatedWidth() int { return btn.inner.GetAllocatedWidth() }
527+func (btn *gtkButton) GetAllocatedHeight() int { return btn.inner.GetAllocatedHeight() }
528+
529+func (btn *gtkButton) ComputePoint(target Widget) (x, y float64, ok bool) {
530+ srcPoint := &graphene.Point{X: 0, Y: 0}
531+ outPoint := &graphene.Point{}
532+
533+ var targetGtk *gtk.Widget
534+ if target != nil {
535+ targetGtk = target.GtkWidget()
536+ }
537+
538+ ok = btn.inner.ComputePoint(targetGtk, srcPoint, outPoint)
539+ if !ok {
540+ return 0, 0, false
541+ }
542+ return float64(outPoint.X), float64(outPoint.Y), true
543+}
544+
433545 // gtkImage wraps gtk.Image to implement ImageWidget.
434546 type gtkImage struct {
435547 inner *gtk.Image
@@ -470,6 +582,25 @@ func (img *gtkImage) SetFromFile(filename string) { img.inner.SetFromFile(&filen
470582 func (img *gtkImage) SetPixelSize(size int) { img.inner.SetPixelSize(size) }
471583 func (img *gtkImage) Clear() { img.inner.Clear() }
472584
585+func (img *gtkImage) GetAllocatedWidth() int { return img.inner.GetAllocatedWidth() }
586+func (img *gtkImage) GetAllocatedHeight() int { return img.inner.GetAllocatedHeight() }
587+
588+func (img *gtkImage) ComputePoint(target Widget) (x, y float64, ok bool) {
589+ srcPoint := &graphene.Point{X: 0, Y: 0}
590+ outPoint := &graphene.Point{}
591+
592+ var targetGtk *gtk.Widget
593+ if target != nil {
594+ targetGtk = target.GtkWidget()
595+ }
596+
597+ ok = img.inner.ComputePoint(targetGtk, srcPoint, outPoint)
598+ if !ok {
599+ return 0, 0, false
600+ }
601+ return float64(outPoint.X), float64(outPoint.Y), true
602+}
603+
473604 // GtkWidgetFactory creates real GTK widgets.
474605 type GtkWidgetFactory struct{}
475606
@@ -1,6 +1,7 @@
1 package layout1 package layout
2 2
3 import (3 import (
4+ "github.com/jwijenbergh/puregotk/v4/graphene"
4 "github.com/jwijenbergh/puregotk/v4/gtk"5 "github.com/jwijenbergh/puregotk/v4/gtk"
5 "github.com/jwijenbergh/puregotk/v4/pango"6 "github.com/jwijenbergh/puregotk/v4/pango"
6 )7 )
@@ -52,6 +53,25 @@ func (w *gtkWidget) GetParent() Widget {
52 return &gtkWidget{inner: parent}53 return &gtkWidget{inner: parent}
53 }54 }
54 55
56+func (w *gtkWidget) GetAllocatedWidth() int { return w.inner.GetAllocatedWidth() }
57+func (w *gtkWidget) GetAllocatedHeight() int { return w.inner.GetAllocatedHeight() }
58+
59+func (w *gtkWidget) ComputePoint(target Widget) (x, y float64, ok bool) {
60+ srcPoint := &graphene.Point{X: 0, Y: 0}
61+ outPoint := &graphene.Point{}
62+
63+ var targetGtk *gtk.Widget
64+ if target != nil {
65+ targetGtk = target.GtkWidget()
66+ }
67+
68+ ok = w.inner.ComputePoint(targetGtk, srcPoint, outPoint)
69+ if !ok {
70+ return 0, 0, false
71+ }
72+ return float64(outPoint.X), float64(outPoint.Y), true
73+}
74+
55 // gtkPaned wraps gtk.Paned to implement PanedWidget.75 // gtkPaned wraps gtk.Paned to implement PanedWidget.
56 type gtkPaned struct {76 type gtkPaned struct {
57 inner *gtk.Paned77 inner *gtk.Paned
@@ -149,6 +169,22 @@ func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
149 func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() }169 func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() }
150 func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() }170 func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() }
151 171
172+func (p *gtkPaned) ComputePoint(target Widget) (x, y float64, ok bool) {
173+ srcPoint := &graphene.Point{X: 0, Y: 0}
174+ outPoint := &graphene.Point{}
175+
176+ var targetGtk *gtk.Widget
177+ if target != nil {
178+ targetGtk = target.GtkWidget()
179+ }
180+
181+ ok = p.inner.ComputePoint(targetGtk, srcPoint, outPoint)
182+ if !ok {
183+ return 0, 0, false
184+ }
185+ return float64(outPoint.X), float64(outPoint.Y), true
186+}
187+
152 // gtkBox wraps gtk.Box to implement BoxWidget.188 // gtkBox wraps gtk.Box to implement BoxWidget.
153 type gtkBox struct {189 type gtkBox struct {
154 inner *gtk.Box190 inner *gtk.Box
@@ -234,6 +270,25 @@ func (b *gtkBox) GetSpacing() int { return b.inner.GetSpacing() }
234 func (b *gtkBox) SetOrientation(o Orientation) { b.inner.SetOrientation(o) }270 func (b *gtkBox) SetOrientation(o Orientation) { b.inner.SetOrientation(o) }
235 func (b *gtkBox) GetOrientation() Orientation { return b.inner.GetOrientation() }271 func (b *gtkBox) GetOrientation() Orientation { return b.inner.GetOrientation() }
236 272
273+func (b *gtkBox) GetAllocatedWidth() int { return b.inner.GetAllocatedWidth() }
274+func (b *gtkBox) GetAllocatedHeight() int { return b.inner.GetAllocatedHeight() }
275+
276+func (b *gtkBox) ComputePoint(target Widget) (x, y float64, ok bool) {
277+ srcPoint := &graphene.Point{X: 0, Y: 0}
278+ outPoint := &graphene.Point{}
279+
280+ var targetGtk *gtk.Widget
281+ if target != nil {
282+ targetGtk = target.GtkWidget()
283+ }
284+
285+ ok = b.inner.ComputePoint(targetGtk, srcPoint, outPoint)
286+ if !ok {
287+ return 0, 0, false
288+ }
289+ return float64(outPoint.X), float64(outPoint.Y), true
290+}
291+
237 // gtkOverlay wraps gtk.Overlay to implement OverlayWidget.292 // gtkOverlay wraps gtk.Overlay to implement OverlayWidget.
238 type gtkOverlay struct {293 type gtkOverlay struct {
239 inner *gtk.Overlay294 inner *gtk.Overlay
@@ -327,6 +382,25 @@ func (o *gtkOverlay) GetMeasureOverlay(overlay Widget) bool {
327 return o.inner.GetMeasureOverlay(overlay.GtkWidget())382 return o.inner.GetMeasureOverlay(overlay.GtkWidget())
328 }383 }
329 384
385+func (o *gtkOverlay) GetAllocatedWidth() int { return o.inner.GetAllocatedWidth() }
386+func (o *gtkOverlay) GetAllocatedHeight() int { return o.inner.GetAllocatedHeight() }
387+
388+func (o *gtkOverlay) ComputePoint(target Widget) (x, y float64, ok bool) {
389+ srcPoint := &graphene.Point{X: 0, Y: 0}
390+ outPoint := &graphene.Point{}
391+
392+ var targetGtk *gtk.Widget
393+ if target != nil {
394+ targetGtk = target.GtkWidget()
395+ }
396+
397+ ok = o.inner.ComputePoint(targetGtk, srcPoint, outPoint)
398+ if !ok {
399+ return 0, 0, false
400+ }
401+ return float64(outPoint.X), float64(outPoint.Y), true
402+}
403+
330 // gtkLabel wraps gtk.Label to implement LabelWidget.404 // gtkLabel wraps gtk.Label to implement LabelWidget.
331 type gtkLabel struct {405 type gtkLabel struct {
332 inner *gtk.Label406 inner *gtk.Label
@@ -369,6 +443,25 @@ func (l *gtkLabel) SetEllipsize(mode EllipsizeMode) { l.inner.SetEllipsize(pango
369 func (l *gtkLabel) SetMaxWidthChars(n int) { l.inner.SetMaxWidthChars(n) }443 func (l *gtkLabel) SetMaxWidthChars(n int) { l.inner.SetMaxWidthChars(n) }
370 func (l *gtkLabel) SetXalign(x float32) { l.inner.SetXalign(x) }444 func (l *gtkLabel) SetXalign(x float32) { l.inner.SetXalign(x) }
371 445
446+func (l *gtkLabel) GetAllocatedWidth() int { return l.inner.GetAllocatedWidth() }
447+func (l *gtkLabel) GetAllocatedHeight() int { return l.inner.GetAllocatedHeight() }
448+
449+func (l *gtkLabel) ComputePoint(target Widget) (x, y float64, ok bool) {
450+ srcPoint := &graphene.Point{X: 0, Y: 0}
451+ outPoint := &graphene.Point{}
452+
453+ var targetGtk *gtk.Widget
454+ if target != nil {
455+ targetGtk = target.GtkWidget()
456+ }
457+
458+ ok = l.inner.ComputePoint(targetGtk, srcPoint, outPoint)
459+ if !ok {
460+ return 0, 0, false
461+ }
462+ return float64(outPoint.X), float64(outPoint.Y), true
463+}
464+
372 // gtkButton wraps gtk.Button to implement ButtonWidget.465 // gtkButton wraps gtk.Button to implement ButtonWidget.
373 type gtkButton struct {466 type gtkButton struct {
374 inner *gtk.Button467 inner *gtk.Button
@@ -430,6 +523,25 @@ func (btn *gtkButton) ConnectClicked(callback func()) uint32 {
430 return btn.inner.ConnectClicked(&cb)523 return btn.inner.ConnectClicked(&cb)
431 }524 }
432 525
526+func (btn *gtkButton) GetAllocatedWidth() int { return btn.inner.GetAllocatedWidth() }
527+func (btn *gtkButton) GetAllocatedHeight() int { return btn.inner.GetAllocatedHeight() }
528+
529+func (btn *gtkButton) ComputePoint(target Widget) (x, y float64, ok bool) {
530+ srcPoint := &graphene.Point{X: 0, Y: 0}
531+ outPoint := &graphene.Point{}
532+
533+ var targetGtk *gtk.Widget
534+ if target != nil {
535+ targetGtk = target.GtkWidget()
536+ }
537+
538+ ok = btn.inner.ComputePoint(targetGtk, srcPoint, outPoint)
539+ if !ok {
540+ return 0, 0, false
541+ }
542+ return float64(outPoint.X), float64(outPoint.Y), true
543+}
544+
433 // gtkImage wraps gtk.Image to implement ImageWidget.545 // gtkImage wraps gtk.Image to implement ImageWidget.
434 type gtkImage struct {546 type gtkImage struct {
435 inner *gtk.Image547 inner *gtk.Image
@@ -470,6 +582,25 @@ func (img *gtkImage) SetFromFile(filename string) { img.inner.SetFromFile(&filen
470 func (img *gtkImage) SetPixelSize(size int) { img.inner.SetPixelSize(size) }582 func (img *gtkImage) SetPixelSize(size int) { img.inner.SetPixelSize(size) }
471 func (img *gtkImage) Clear() { img.inner.Clear() }583 func (img *gtkImage) Clear() { img.inner.Clear() }
472 584
585+func (img *gtkImage) GetAllocatedWidth() int { return img.inner.GetAllocatedWidth() }
586+func (img *gtkImage) GetAllocatedHeight() int { return img.inner.GetAllocatedHeight() }
587+
588+func (img *gtkImage) ComputePoint(target Widget) (x, y float64, ok bool) {
589+ srcPoint := &graphene.Point{X: 0, Y: 0}
590+ outPoint := &graphene.Point{}
591+
592+ var targetGtk *gtk.Widget
593+ if target != nil {
594+ targetGtk = target.GtkWidget()
595+ }
596+
597+ ok = img.inner.ComputePoint(targetGtk, srcPoint, outPoint)
598+ if !ok {
599+ return 0, 0, false
600+ }
601+ return float64(outPoint.X), float64(outPoint.Y), true
602+}
603+
473 // GtkWidgetFactory creates real GTK widgets.604 // GtkWidgetFactory creates real GTK widgets.
474 type GtkWidgetFactory struct{}605 type GtkWidgetFactory struct{}
475 606
M internal/ui/layout/widgets.go +8 -4
@@ -42,6 +42,14 @@ type Widget interface {
4242 SetValign(align gtk.Align)
4343 SetSizeRequest(width, height int)
4444
45+ // Geometry - for focus navigation
46+ GetAllocatedWidth() int
47+ GetAllocatedHeight() int
48+ // ComputePoint returns the position of this widget's origin (0,0) relative to
49+ // the target widget's coordinate space. If target is nil, returns position
50+ // relative to the native window. Returns ok=false if computation fails.
51+ ComputePoint(target Widget) (x, y float64, ok bool)
52+
4553 // CSS styling
4654 AddCssClass(cssClass string)
4755 RemoveCssClass(cssClass string)
@@ -89,10 +97,6 @@ type PanedWidget interface {
8997 // Callback returns true to continue, false to stop
9098 AddTickCallback(callback func() bool) uint
9199
92- // Size information (for ratio calculation)
93- GetAllocatedWidth() int
94- GetAllocatedHeight() int
95-
96100 // Handle appearance
97101 SetWideHandle(wide bool)
98102 GetWideHandle() bool
@@ -42,6 +42,14 @@ type Widget interface {
42 SetValign(align gtk.Align)42 SetValign(align gtk.Align)
43 SetSizeRequest(width, height int)43 SetSizeRequest(width, height int)
44 44
45+ // Geometry - for focus navigation
46+ GetAllocatedWidth() int
47+ GetAllocatedHeight() int
48+ // ComputePoint returns the position of this widget's origin (0,0) relative to
49+ // the target widget's coordinate space. If target is nil, returns position
50+ // relative to the native window. Returns ok=false if computation fails.
51+ ComputePoint(target Widget) (x, y float64, ok bool)
52+
45 // CSS styling53 // CSS styling
46 AddCssClass(cssClass string)54 AddCssClass(cssClass string)
47 RemoveCssClass(cssClass string)55 RemoveCssClass(cssClass string)
@@ -89,10 +97,6 @@ type PanedWidget interface {
89 // Callback returns true to continue, false to stop97 // Callback returns true to continue, false to stop
90 AddTickCallback(callback func() bool) uint98 AddTickCallback(callback func() bool) uint
91 99
92- // Size information (for ratio calculation)
93- GetAllocatedWidth() int
94- GetAllocatedHeight() int
95-
96 // Handle appearance100 // Handle appearance
97 SetWideHandle(wide bool)101 SetWideHandle(wide bool)
98 GetWideHandle() bool102 GetWideHandle() bool
M internal/ui/theme/css.go +8 -4
@@ -227,14 +227,18 @@ func generatePaneCSS(p Palette) string {
227227 border: 1px solid var(--accent);
228228 }
229229
230-/* Pane mode active - thick blue border */
230+/* Pane mode active - thick blue inset border (for overlay) */
231231 .pane-mode-active {
232- border: 4px solid #4A90E2;
232+ background-color: transparent;
233+ box-shadow: inset 0 0 0 4px #4A90E2;
234+ border-radius: 0;
233235 }
234236
235-/* Tab mode active - thick orange border */
237+/* Tab mode active - thick orange inset border (for overlay) */
236238 .tab-mode-active {
237- border: 4px solid #FFA500;
239+ background-color: transparent;
240+ box-shadow: inset 0 0 0 4px #FFA500;
241+ border-radius: 0;
238242 }
239243 `
240244 }
@@ -227,14 +227,18 @@ func generatePaneCSS(p Palette) string {
227 border: 1px solid var(--accent);227 border: 1px solid var(--accent);
228 }228 }
229 229
230-/* Pane mode active - thick blue border */230+/* Pane mode active - thick blue inset border (for overlay) */
231 .pane-mode-active {231 .pane-mode-active {
232- border: 4px solid #4A90E2;232+ background-color: transparent;
233+ box-shadow: inset 0 0 0 4px #4A90E2;
234+ border-radius: 0;
233 }235 }
234 236
235-/* Tab mode active - thick orange border */237+/* Tab mode active - thick orange inset border (for overlay) */
236 .tab-mode-active {238 .tab-mode-active {
237- border: 4px solid #FFA500;239+ background-color: transparent;
240+ box-shadow: inset 0 0 0 4px #FFA500;
241+ border-radius: 0;
238 }242 }
239 `243 `
240 }244 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close