v0.23.2 bfd5eb4

manager.go RawHistoryFolder

1// Package focus provides focus state management and geometric navigation for panes.
2package focus
3
4import (
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.
15type 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.
28type Manager struct {
29	panesUC *usecase.ManagePanesUseCase
30}
31
32// NewManager creates a focus manager.
33func 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// Only visible panes are included - for stacked panes, only the active one is visible.
41func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect {
42	var rects []entity.PaneRect
43
44	container := provider.ContainerWidget()
45
46	for _, paneID := range provider.GetPaneIDs() {
47		paneWidget := provider.GetPaneWidget(paneID)
48
49		// Skip panes that aren't visible (includes inactive stacked panes)
50		if paneWidget == nil || !paneWidget.IsVisible() {
51			continue
52		}
53
54		// For stacked panes, use stack container geometry (pane itself has no size)
55		var widget layout.Widget
56		if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil {
57			widget = stackWidget
58		} else {
59			widget = paneWidget
60		}
61
62		if widget == nil || !widget.IsVisible() {
63			continue
64		}
65
66		// Get position relative to container
67		x, y, ok := widget.ComputePoint(container)
68		if !ok {
69			continue
70		}
71
72		w := widget.GetAllocatedWidth()
73		h := widget.GetAllocatedHeight()
74
75		// Skip widgets with no size (collapsed/hidden)
76		if w <= 0 || h <= 0 {
77			continue
78		}
79
80		rects = append(rects, entity.PaneRect{
81			PaneID: paneID,
82			X:      int(x),
83			Y:      int(y),
84			W:      w,
85			H:      h,
86		})
87	}
88
89	return rects
90}
91
92// NavigateGeometric performs geometric navigation and returns the target pane.
93// It handles stack navigation internally: when navigating up/down and the current
94// pane is in a stack, it will try to navigate within the stack first before
95// escaping to external panes.
96func (m *Manager) NavigateGeometric(
97	ctx context.Context,
98	ws *entity.Workspace,
99	provider PaneGeometryProvider,
100	direction usecase.NavigateDirection,
101) (*entity.PaneNode, error) {
102	log := logging.FromContext(ctx)
103
104	if ws == nil {
105		return nil, nil
106	}
107
108	// Get active pane node
109	activeNode := ws.ActivePane()
110	if activeNode == nil {
111		log.Debug().Msg("no active pane for navigation")
112		return nil, nil
113	}
114
115	// Check if we should navigate within a stack first
116	if direction == usecase.NavUp || direction == usecase.NavDown {
117		if activeNode.Parent != nil && activeNode.Parent.IsStacked {
118			// Try to navigate within the stack
119			stackNode := activeNode.Parent
120			canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction)
121			if canNavigate {
122				ws.ActivePaneID = newPaneID
123				return ws.FindPane(newPaneID), nil
124			}
125			// At stack boundary - fall through to geometric navigation
126			log.Debug().Msg("at stack boundary, using geometric navigation")
127		}
128	}
129
130	// Collect geometry from visible panes
131	rects := m.CollectPaneRects(provider)
132	if len(rects) == 0 {
133		return nil, nil
134	}
135
136	// Run geometric algorithm
137	output, err := m.panesUC.NavigateFocusGeometric(ctx, usecase.GeometricNavigationInput{
138		ActivePaneID: ws.ActivePaneID,
139		PaneRects:    rects,
140		Direction:    direction,
141	})
142	if err != nil {
143		return nil, err
144	}
145	if !output.Found {
146		return nil, nil
147	}
148
149	// Update workspace
150	ws.ActivePaneID = output.TargetPaneID
151
152	// Return target node
153	return ws.FindPane(output.TargetPaneID), nil
154}
155
156// navigateWithinStack tries to navigate within a stack.
157// Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a
158// boundary and navigation should escape the stack.
159func (m *Manager) navigateWithinStack(
160	stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection,
161) (bool, entity.PaneID) {
162	if !stackNode.IsStacked || len(stackNode.Children) == 0 {
163		return false, ""
164	}
165
166	currentIdx := m.findCurrentStackIndex(stackNode, currentNode)
167	if currentIdx < 0 {
168		return false, ""
169	}
170
171	newIdx, ok := m.calculateNewStackIndex(currentIdx, len(stackNode.Children), direction)
172	if !ok {
173		return false, ""
174	}
175
176	stackNode.ActiveStackIndex = newIdx
177	return m.getPaneIDFromStackChild(stackNode.Children[newIdx])
178}
179
180// findCurrentStackIndex returns the current pane's index in the stack.
181// Uses ActiveStackIndex from domain model, with fallback to pointer search.
182func (*Manager) findCurrentStackIndex(stackNode, currentNode *entity.PaneNode) int {
183	idx := stackNode.ActiveStackIndex
184	if idx >= 0 && idx < len(stackNode.Children) {
185		return idx
186	}
187	// Fallback to pointer search if ActiveStackIndex is invalid
188	for i, child := range stackNode.Children {
189		if child == currentNode {
190			return i
191		}
192	}
193	return -1
194}
195
196// calculateNewStackIndex computes the new index after navigation.
197// Returns (newIndex, ok) where ok is false if at a boundary.
198func (*Manager) calculateNewStackIndex(
199	currentIdx, childCount int, direction usecase.NavigateDirection,
200) (int, bool) {
201	switch direction {
202	case usecase.NavUp:
203		if currentIdx <= 0 {
204			return 0, false
205		}
206		return currentIdx - 1, true
207	case usecase.NavDown:
208		if currentIdx >= childCount-1 {
209			return 0, false
210		}
211		return currentIdx + 1, true
212	default:
213		return 0, false
214	}
215}
216
217// getPaneIDFromStackChild extracts the pane ID from a stack child node.
218// If the child is not a leaf, finds the first leaf pane.
219func (*Manager) getPaneIDFromStackChild(targetNode *entity.PaneNode) (bool, entity.PaneID) {
220	if targetNode.Pane != nil {
221		return true, targetNode.Pane.ID
222	}
223
224	var leafPaneID entity.PaneID
225	targetNode.Walk(func(n *entity.PaneNode) bool {
226		if n.IsLeaf() && n.Pane != nil {
227			leafPaneID = n.Pane.ID
228			return false
229		}
230		return true
231	})
232
233	if leafPaneID != "" {
234		return true, leafPaneID
235	}
236	return false, ""
237}

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