gitstack

grindlemire/go-tui code browser

9.7 KB Go 354 lines 2026-06-03 ยท d15bb9f raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
package tui

import "slices"

import "github.com/grindlemire/go-tui/internal/debug"

// Focusable is implemented by elements that can receive keyboard focus.
// Element implements this interface directly. For custom focus handling,
// use WithOnFocus, WithOnBlur, or WithOnEvent options on Element.
type Focusable interface {
	// IsFocusable returns whether this element can currently receive focus.
	// May return false for disabled elements.
	IsFocusable() bool

	// IsTabStop returns whether this element participates in Tab/Shift+Tab
	// navigation. Elements like scrollable containers are focusable (can
	// receive keyboard events) but are not tab stops by default.
	IsTabStop() bool

	// HandleEvent processes a keyboard event.
	// Returns true if the event was consumed, false to allow propagation.
	HandleEvent(event Event) bool

	// Focus is called when this element gains focus.
	// Implementations typically update visual state (e.g., highlight border).
	Focus()

	// Blur is called when this element loses focus.
	// Implementations typically revert visual state.
	Blur()
}

// focusManager tracks focus state for a set of focusable elements.
// It does NOT automatically handle Tab navigation; the user controls
// when focus moves by calling Next(), Prev(), or SetFocus().
type focusManager struct {
	elements     []Focusable // Registered focusable elements in order
	current      int         // Index of currently focused element (-1 = none)
	focusApplied bool        // true after focus has been set (prevents re-applying autoFocus)
	scope        *Element    // if set, only elements within this subtree are navigable
}

// newFocusManager creates an empty focusManager.
// Use Register to add focusable elements.
func newFocusManager() *focusManager {
	return &focusManager{
		current: -1,
	}
}

// Register adds a focusable element to the manager.
// Does not auto-focus; use Tab or SetFocus to focus an element.
func (f *focusManager) Register(elem Focusable) {
	debug.Log("FocusManager.Register: adding element %T (focusable=%v)", elem, elem.IsFocusable())
	f.elements = append(f.elements, elem)
	debug.Log("FocusManager.Register: total elements=%d, current=%d", len(f.elements), f.current)
}

// Unregister removes a focusable element from the manager.
func (f *focusManager) Unregister(elem Focusable) {
	// Find the element
	idx := -1
	for i, e := range f.elements {
		if e == elem {
			idx = i
			break
		}
	}
	if idx == -1 {
		return // Not found
	}

	// Check if we're removing the focused element
	wasCurrentlyFocused := idx == f.current

	// Call Blur if this element was focused
	if wasCurrentlyFocused {
		elem.Blur()
	}

	// Remove from slice
	f.elements = append(f.elements[:idx], f.elements[idx+1:]...)

	// Adjust current index
	if len(f.elements) == 0 {
		f.current = -1
	} else if wasCurrentlyFocused {
		// Focus the next element (or wrap to beginning)
		f.current = idx
		if f.current >= len(f.elements) {
			f.current = 0
		}
		// Find next focusable element
		f.focusNextFrom(f.current)
	} else if idx < f.current {
		// Shift current down since an element before it was removed
		f.current--
	}
}

// Focused returns the currently focused element, or nil if none.
func (f *focusManager) Focused() Focusable {
	if f.current < 0 || f.current >= len(f.elements) {
		return nil
	}
	return f.elements[f.current]
}

// IsFocused returns true if the given Focusable is the currently focused element.
func (f *focusManager) IsFocused(elem Focusable) bool {
	if f.current < 0 || f.current >= len(f.elements) {
		return false
	}
	return f.elements[f.current] == elem
}

// focusedIndex returns the current focus index, or -1 if nothing is focused.
func (f *focusManager) focusedIndex() int {
	return f.current
}

// setFocusIndex restores focus to the element at the given index.
// Used by modal to restore focus after close. The Blur/Focus calls here
// target stale elements from the previous render tree, but are harmless;
// refreshFromTree runs immediately after and calls Focus on the fresh
// element at the preserved index.
func (f *focusManager) setFocusIndex(idx int) {
	if idx < 0 || idx >= len(f.elements) {
		return
	}
	if f.current >= 0 && f.current < len(f.elements) && f.current != idx {
		f.elements[f.current].Blur()
	}
	f.current = idx
	f.focusApplied = true
	f.elements[idx].Focus()
}

// SetFocus moves focus to the specified element.
// Does nothing if the element is not registered or not focusable.
func (f *focusManager) SetFocus(elem Focusable) {
	// Find the element
	idx := -1
	for i, e := range f.elements {
		if e == elem {
			idx = i
			break
		}
	}
	if idx == -1 || !elem.IsFocusable() {
		return // Not found or not focusable
	}

	// Blur current if different
	if f.current >= 0 && f.current < len(f.elements) && f.current != idx {
		f.elements[f.current].Blur()
	}

	// Focus new element
	f.current = idx
	f.focusApplied = true
	elem.Focus()
}

// Next moves focus to the next focusable element.
// Wraps around to the first element if at the end.
// Does nothing if there are no focusable elements.
func (f *focusManager) Next() {
	if len(f.elements) == 0 {
		return
	}

	// Blur current
	if f.current >= 0 && f.current < len(f.elements) {
		f.elements[f.current].Blur()
	}

	// Find next tab-stop element, skipping the current one.
	// If no other tab-stop exists, clear focus instead of re-focusing the same element.
	startIdx := f.current
	if startIdx < 0 {
		startIdx = -1
	}

	for i := 0; i < len(f.elements); i++ {
		nextIdx := (startIdx + 1 + i) % len(f.elements)
		if nextIdx == f.current {
			// Wrapped back to the same element; clear focus instead
			f.current = -1
			return
		}
		if f.elements[nextIdx].IsTabStop() && f.isInScope(f.elements[nextIdx]) {
			f.current = nextIdx
			f.elements[nextIdx].Focus()
			return
		}
	}

	// No focusable elements found
	f.current = -1
}

// Prev moves focus to the previous focusable element.
// If no other tab-stop exists, clears focus.
func (f *focusManager) Prev() {
	if len(f.elements) == 0 {
		return
	}

	// Blur current
	if f.current >= 0 && f.current < len(f.elements) {
		f.elements[f.current].Blur()
	}

	// Find previous tab-stop element, skipping the current one.
	startIdx := max(f.current, 0)

	for i := 0; i < len(f.elements); i++ {
		prevIdx := startIdx - 1 - i
		if prevIdx < 0 {
			prevIdx += len(f.elements)
		}
		if prevIdx == f.current {
			// Wrapped back to the same element; clear focus instead
			f.current = -1
			return
		}
		if f.elements[prevIdx].IsTabStop() && f.isInScope(f.elements[prevIdx]) {
			f.current = prevIdx
			f.elements[prevIdx].Focus()
			return
		}
	}

	// No focusable elements found
	f.current = -1
}

// ScopeTo restricts Tab/Shift+Tab navigation to focusable elements
// within the given element's subtree. Used by modal focus trapping.
func (f *focusManager) ScopeTo(el *Element) {
	f.scope = el
}

// ClearScope removes focus navigation restriction.
func (f *focusManager) ClearScope() {
	f.scope = nil
}

// isInScope returns true if the focusable element is within the current scope.
// If no scope is set, all elements are in scope.
func (f *focusManager) isInScope(elem Focusable) bool {
	if f.scope == nil {
		return true
	}
	el, ok := elem.(*Element)
	if !ok {
		return false
	}
	for e := el; e != nil; e = e.parent {
		if e == f.scope {
			return true
		}
	}
	return false
}

// ClearFocus blurs the currently focused element and sets focus to none.
func (f *focusManager) ClearFocus() {
	if f.current >= 0 && f.current < len(f.elements) {
		f.elements[f.current].Blur()
	}
	f.current = -1
}

// Dispatch sends an event to the currently focused element.
// Returns true if the event was handled.
func (f *focusManager) Dispatch(event Event) bool {
	focused := f.Focused()
	debug.Log("FocusManager.Dispatch: event=%T focused=%v (current=%d, total=%d)", event, focused != nil, f.current, len(f.elements))
	if focused == nil {
		debug.Log("FocusManager.Dispatch: no focused element, returning false")
		return false
	}
	result := focused.HandleEvent(event)
	debug.Log("FocusManager.Dispatch: HandleEvent returned %v", result)
	return result
}

// refreshFromTree rebuilds the focusable element list from the current
// element tree while preserving the focus index. This is needed because
// re-renders produce new Element objects, making old references stale.
func (f *focusManager) refreshFromTree(root *Element) {
	if root == nil {
		return
	}
	savedIdx := f.current
	f.elements = f.elements[:0]
	root.WalkFocusables(func(elem Focusable) {
		f.elements = append(f.elements, elem)
	})
	if savedIdx >= 0 && savedIdx < len(f.elements) {
		f.current = savedIdx
		// The new element was just created by Render() and doesn't know
		// it's focused. Call Focus() to sync so that Blur() on the next
		// Tab press targets the correct element state.
		f.elements[savedIdx].Focus()
	} else {
		f.current = -1
		// On first refresh with no prior focus, check for autoFocus elements
		if !f.focusApplied {
			f.applyAutoFocus(root)
		}
	}
}

// applyAutoFocus finds the first autoFocus element in the tree and sets
// focus on it. Only called when no focus has been applied yet.
func (f *focusManager) applyAutoFocus(root *Element) {
	if root == nil {
		return
	}
	var target Focusable
	var walk func(e *Element) bool
	walk = func(e *Element) bool {
		if e.hidden {
			return false
		}
		if e.autoFocus && e.IsTabStop() {
			target = e
			return true
		}
		return slices.ContainsFunc(e.children, walk)
	}
	walk(root)
	if target != nil {
		f.SetFocus(target)
	}
}

// focusNextFrom finds the next tab-stop element starting from idx.
// Used internally after unregister.
func (f *focusManager) focusNextFrom(startIdx int) {
	for i := 0; i < len(f.elements); i++ {
		idx := (startIdx + i) % len(f.elements)
		if f.elements[idx].IsTabStop() && f.isInScope(f.elements[idx]) {
			f.current = idx
			f.elements[idx].Focus()
			return
		}
	}
	// No focusable element found
	f.current = -1
}