v0.28.0 3cb3770

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

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