gitstack

grindlemire/go-tui code browser

6.8 KB Go 254 lines 2026-06-27 · af23124 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
package tui

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

// --- Focus API ---

// IsFocusable returns whether this element can receive focus.
func (e *Element) IsFocusable() bool {
	return e.focusable
}

// IsTabStop returns whether this element participates in Tab/Shift+Tab navigation.
func (e *Element) IsTabStop() bool {
	return e.tabStop
}

// IsAutoFocus returns whether this element should receive focus automatically
// when the element tree is first applied.
func (e *Element) IsAutoFocus() bool {
	return e.autoFocus
}

// IsFocused returns whether this element currently has focus.
func (e *Element) IsFocused() bool {
	return e.focused
}

// Focus marks this element as focused and calls onFocus callback if set.
// Idempotent: no-op if already focused.
// Does not cascade to children — only the FocusManager target receives focus.
//
// For elements with a border, no explicit onFocus handler, and no
// focusBorderStyle configured, a default cyan border highlight is applied
// automatically.
func (e *Element) Focus() {
	if e.focused {
		return
	}
	e.focused = true
	if e.onFocus != nil {
		e.onFocus(e)
		if e.focusBorderStyle != nil {
			e.MarkDirty()
		}
	} else if e.focusBorderStyle != nil {
		e.MarkDirty()
	} else if e.border != BorderNone {
		e.savedBorderStyle = e.borderStyle
		e.hasSavedBorder = true
		e.borderStyle = NewStyle().Foreground(Cyan)
		e.MarkDirty()
	}
}

// Blur marks this element as not focused and calls onBlur callback if set.
// Idempotent: no-op if already blurred.
// Does not cascade to children — only the FocusManager target loses focus.
//
// Restores the original border style if a default focus highlight was applied.
func (e *Element) Blur() {
	if !e.focused {
		return
	}
	e.focused = false
	if e.onBlur != nil {
		e.onBlur(e)
		if e.focusBorderStyle != nil {
			e.MarkDirty()
		}
	} else if e.focusBorderStyle != nil {
		if e.hasSavedBorder {
			e.borderStyle = e.savedBorderStyle
			e.hasSavedBorder = false
		}
		e.MarkDirty()
	} else if e.hasSavedBorder {
		e.borderStyle = e.savedBorderStyle
		e.hasSavedBorder = false
		e.MarkDirty()
	}
}

// Activate triggers the onActivate callback if set.
// Returns true if the callback existed and was called.
func (e *Element) Activate() bool {
	if e.onActivate != nil {
		e.onActivate()
		return true
	}
	return false
}

// SetFocusable sets whether this element can receive focus.
// Also sets tabStop to the same value.
func (e *Element) SetFocusable(focusable bool) {
	e.focusable = focusable
	e.tabStop = focusable
}

// SetOnFocus sets a handler that's called when this element gains focus.
// The handler receives the element as its first parameter (self-inject).
// Implicitly sets focusable = true and tabStop = true.
func (e *Element) SetOnFocus(fn func(*Element)) {
	e.focusable = true
	e.tabStop = true
	e.onFocus = fn
}

// SetOnBlur sets a handler that's called when this element loses focus.
// The handler receives the element as its first parameter (self-inject).
// Implicitly sets focusable = true and tabStop = true.
func (e *Element) SetOnBlur(fn func(*Element)) {
	e.focusable = true
	e.tabStop = true
	e.onBlur = fn
}

// HandleEvent dispatches an event to this element's handler.
// Only handles scroll events for scrollable elements.
// Returns true if the event was consumed.
func (e *Element) HandleEvent(event Event) bool {
	debug.Log("Element.HandleEvent: event=%T text=%q scrollMode=%v", event, e.text, e.scrollMode)

	// Handle scroll events for scrollable elements
	if e.scrollMode != ScrollNone {
		if e.handleScrollEvent(event) {
			return true
		}
	}

	debug.Log("Element.HandleEvent: event not consumed")
	return false
}

// ContainsPoint returns true if the screen-space point (x, y) is within the
// element's bounds. For elements inside scrollable containers, this
// automatically accounts for scroll offsets by adjusting the click point
// through scrollable ancestors.
func (e *Element) ContainsPoint(x, y int) bool {
	// The element's layout.Rect is in content-space (unscrolled coordinates).
	// Screen-space coordinates need the cumulative scroll offset added so
	// they match content-space. Each scrollable ancestor shifts content
	// upward/leftward by its scroll offset, so we add scroll offsets to
	// convert the screen point into the same coordinate system as the rect.
	cx, cy := x, y
	for p := e.parent; p != nil; p = p.parent {
		if p.scrollMode != ScrollNone {
			cx += p.scrollX
			cy += p.scrollY
		}
	}
	return e.layout.Rect.Contains(cx, cy)
}

// hasWrapOverflow returns true if this element has wrapped text that overflows
// its content area, enabling auto-scroll with no visible scrollbar.
func (e *Element) hasWrapOverflow() bool {
	if e.scrollMode != ScrollNone || e.text == "" || e.noWrap {
		return false
	}
	return e.contentHeight > 0 && e.contentHeight > e.ContentRect().Height
}

// scrollWrapOverflow adjusts scrollY for wrap-overflow elements.
// This bypasses the normal ScrollTo/ScrollBy which require scrollMode.
func (e *Element) scrollWrapOverflow(dy int) {
	cr := e.ContentRect()
	maxY := max(e.contentHeight-cr.Height, 0)
	newY := min(max(e.scrollY+dy, 0), maxY)
	if newY != e.scrollY {
		e.scrollY = newY
		e.MarkDirty()
	}
}

// handleScrollEvent handles keyboard and mouse wheel events for scrolling.
func (e *Element) handleScrollEvent(event Event) bool {
	// Handle mouse wheel events
	if mouse, ok := event.(MouseEvent); ok {
		switch mouse.Button {
		case MouseWheelUp:
			if e.scrollMode == ScrollVertical || e.scrollMode == ScrollBoth {
				e.ScrollBy(0, -1)
				return true
			}
			// Auto-scroll for wrap-overflow text
			if e.hasWrapOverflow() {
				e.scrollWrapOverflow(-1)
				return true
			}
		case MouseWheelDown:
			if e.scrollMode == ScrollVertical || e.scrollMode == ScrollBoth {
				e.ScrollBy(0, 1)
				return true
			}
			// Auto-scroll for wrap-overflow text
			if e.hasWrapOverflow() {
				e.scrollWrapOverflow(1)
				return true
			}
		}
		return false
	}

	key, ok := event.(KeyEvent)
	if !ok {
		return false
	}

	_, viewportHeight := e.ViewportSize()

	switch key.Key {
	case KeyUp:
		if e.scrollMode == ScrollVertical || e.scrollMode == ScrollBoth {
			e.ScrollBy(0, -1)
			return true
		}
	case KeyDown:
		if e.scrollMode == ScrollVertical || e.scrollMode == ScrollBoth {
			e.ScrollBy(0, 1)
			return true
		}
	case KeyLeft:
		if e.scrollMode == ScrollHorizontal || e.scrollMode == ScrollBoth {
			e.ScrollBy(-1, 0)
			return true
		}
	case KeyRight:
		if e.scrollMode == ScrollHorizontal || e.scrollMode == ScrollBoth {
			e.ScrollBy(1, 0)
			return true
		}
	case KeyPageUp:
		if e.scrollMode == ScrollVertical || e.scrollMode == ScrollBoth {
			e.ScrollBy(0, -viewportHeight)
			return true
		}
	case KeyPageDown:
		if e.scrollMode == ScrollVertical || e.scrollMode == ScrollBoth {
			e.ScrollBy(0, viewportHeight)
			return true
		}
	case KeyHome:
		e.ScrollTo(0, 0)
		return true
	case KeyEnd:
		e.ScrollToBottom()
		return true
	}

	return false
}