gitstack

grindlemire/go-tui code browser

10.8 KB markdown 429 lines 2026-04-03 ยท 2dcff81 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
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
# Focus Reference

## Overview

Focus determines which element receives keyboard input. go-tui provides three layers of focus management:

- **Element-level** -- individual elements marked as focusable, with `Focus()` and `Blur()` methods
- **focusManager** -- internal tracking used by `App` to cycle through focusable elements with `FocusNext()` and `FocusPrev()`
- **FocusGroup** -- a state-driven helper for Tab/Shift+Tab cycling between logical sections of your UI
- **Modal focus trapping** -- when a modal is open with `trapFocus`, Tab/Shift+Tab only cycle through elements inside the modal overlay, and unhandled keys don't reach parent handlers

Most applications use element-level focus through `.gsx` attributes and `App`-level navigation. `FocusGroup` is useful when you need to toggle between distinct panels or sections rather than individual elements. Modals with `trapFocus` enabled restrict both Tab navigation and keyboard input to the overlay content. With `trapFocus` disabled, keys pass through to parents normally.

## Focusable Interface

```go
type Focusable interface {
    IsFocusable() bool
    HandleEvent(event Event) bool
    Focus()
    Blur()
}
```

`Focusable` is the contract for anything that can receive keyboard focus. `Element` satisfies this interface directly, so you rarely need to implement it yourself.

### IsFocusable

```go
IsFocusable() bool
```

Returns whether this element can currently receive focus. Returns the value set by `WithFocusable` or `SetFocusable` (defaults to `false`).

### HandleEvent

```go
HandleEvent(event Event) bool
```

Processes a keyboard or mouse event. Returns `true` if the event was consumed (preventing further propagation), `false` otherwise. For scrollable elements, `Element.HandleEvent` automatically handles arrow keys, Page Up/Down, Home/End, and mouse wheel events.

### Focus

```go
Focus()
```

Called when the element gains focus. Implementations typically update visual state, like changing a border color or adding a highlight. `Element.Focus` sets an internal `focused` flag and fires the `onFocus` callback if one was registered.

### Blur

```go
Blur()
```

Called when the element loses focus. Reverses whatever visual change `Focus` applied. `Element.Blur` clears the internal `focused` flag and fires the `onBlur` callback if one was registered.

## Element Focus API

`Element` implements `Focusable` and adds several focus-related methods and options.

### Option Functions

#### WithFocusable

```go
func WithFocusable(focusable bool) Option
```

Sets whether the element can receive focus. Elements are not focusable by default.

In `.gsx`:

```gsx
<div focusable={true}>
    <span>I can receive focus</span>
</div>
```

#### WithOnFocus

```go
func WithOnFocus(fn func(*Element)) Option
```

Sets a callback that fires when the element gains focus. The callback receives the element itself. Implicitly sets `focusable = true`.

In `.gsx`:

```gsx
<div onFocus={s.handleFocus}>
    <span>Focus me</span>
</div>
```

#### WithOnBlur

```go
func WithOnBlur(fn func(*Element)) Option
```

Sets a callback that fires when the element loses focus. The callback receives the element itself. Implicitly sets `focusable = true`.

In `.gsx`:

```gsx
<div onBlur={s.handleBlur}>
    <span>Blur me</span>
</div>
```

### Methods

#### IsFocused

```go
func (e *Element) IsFocused() bool
```

Returns whether this element currently has focus. Use in render methods to apply conditional styling.

```gsx
// Helper function for focus-dependent styling
func panelBorderStyle(ref *tui.Ref) tui.Style {
    if ref.El() != nil && ref.El().IsFocused() {
        return tui.NewStyle().Foreground(tui.ANSIColor(tui.Cyan))
    }
    return tui.NewStyle().Foreground(tui.ANSIColor(tui.White))
}

templ (s *myComp) Render() {
    <div ref={s.panel} focusable={true} borderStyle={panelBorderStyle(s.panel)} class="border-rounded p-1">
        <span>Panel content</span>
    </div>
}
```

#### SetFocusable

```go
func (e *Element) SetFocusable(focusable bool)
```

Changes whether the element can receive focus at runtime.

#### SetOnFocus

```go
func (e *Element) SetOnFocus(fn func(*Element))
```

Sets the focus callback at runtime. Implicitly sets `focusable = true`.

#### SetOnBlur

```go
func (e *Element) SetOnBlur(fn func(*Element))
```

Sets the blur callback at runtime. Implicitly sets `focusable = true`.

#### ContainsPoint

```go
func (e *Element) ContainsPoint(x, y int) bool
```

Returns `true` if the point `(x, y)` falls within the element's layout bounds. Useful for mouse-based hit testing in `HandleMouse` implementations.

### Focus Tree Discovery

The `App` uses these methods internally to find and register focusable elements. You normally don't need to call them directly.

#### WalkFocusables

```go
func (e *Element) WalkFocusables(fn func(Focusable))
```

Walks the element tree depth-first, calling `fn` for each focusable element. Skips hidden elements and their subtrees. The `App` calls this after each render to discover new focusable elements.

#### SetOnFocusableAdded

```go
func (e *Element) SetOnFocusableAdded(fn func(Focusable))
```

Sets a callback that fires when a focusable descendant is added to the tree. The `App` uses this to auto-register focusable elements as they appear.

## App Focus Methods

The `App` delegates focus navigation to its internal focus manager through three methods.

### FocusNext

```go
func (a *App) FocusNext()
```

Moves focus to the next focusable element in document order (depth-first traversal of the element tree). Wraps around to the first element when reaching the end.

```go
func (s *myApp) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyTab, func(ke tui.KeyEvent) {
            ke.App().FocusNext()
        }),
    }
}
```

### FocusPrev

```go
func (a *App) FocusPrev()
```

Moves focus to the previous focusable element. Wraps around to the last element when reaching the beginning.

```go
tui.On(tui.KeyTab, func(ke tui.KeyEvent) {
    if ke.Mod.Has(tui.ModShift) {
        ke.App().FocusPrev()
    } else {
        ke.App().FocusNext()
    }
})
```

### Focused

```go
func (a *App) Focused() Focusable
```

Returns the currently focused element, or `nil` if nothing has focus.

```go
if focused := app.Focused(); focused != nil {
    // Something has focus
}
```

## FocusGroup

`FocusGroup` manages Tab/Shift+Tab cycling between logical sections of a UI. Each section is represented by a `*State[bool]` that indicates whether that section is active. `FocusGroup` enforces mutual exclusion: exactly one member is active at a time.

`FocusGroup` implements `KeyListener` (it has a `KeyMap()` method) but is not a `Component`. It participates in the key dispatch system without rendering anything.

### NewFocusGroup

```go
func NewFocusGroup(members ...*State[bool]) (*FocusGroup, error)
```

Creates a `FocusGroup` managing the given members. The constructor always activates the first member (`true`) and deactivates the rest (`false`), regardless of their initial values. Returns an error if fewer than 2 members are provided.

```go
panelA := tui.NewState(false)
panelB := tui.NewState(false)
panelC := tui.NewState(false)

fg, err := tui.NewFocusGroup(panelA, panelB, panelC)
// panelA is now true, panelB and panelC are false
if err != nil {
    // handle error
}
```

### MustNewFocusGroup

```go
func MustNewFocusGroup(members ...*State[bool]) *FocusGroup
```

Same as `NewFocusGroup` but panics on error. Use when the member count is known at compile time.

```go
fg := tui.MustNewFocusGroup(panelA, panelB, panelC)
```

### Next

```go
func (fg *FocusGroup) Next()
```

Deactivates the current member (sets its state to `false`) and activates the next one (sets to `true`), wrapping from the last member back to the first.

### Prev

```go
func (fg *FocusGroup) Prev()
```

Deactivates the current member and activates the previous one, wrapping from the first member to the last.

### Current

```go
func (fg *FocusGroup) Current() int
```

Returns the zero-based index of the currently active member.

### KeyMap

```go
func (fg *FocusGroup) KeyMap() KeyMap
```

Returns two key bindings:

- **Tab** (no modifiers) calls `Next()`
- **Shift+Tab** calls `Prev()`

Both bindings use `Stop: false`, so the events continue propagating after the focus group handles them.

```go
func (s *myApp) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        // FocusGroup bindings first
        s.focusGroup.KeyMap()[0],
        s.focusGroup.KeyMap()[1],
        // Then app-level bindings
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) {
            ke.App().Stop()
        }),
    }
}
```

Or spread the entire KeyMap:

```go
func (s *myApp) KeyMap() tui.KeyMap {
    km := s.focusGroup.KeyMap()
    km = append(km,
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) {
            ke.App().Stop()
        }),
    )
    return km
}
```

## Complete Example

A three-panel layout with FocusGroup-driven Tab cycling. Each panel highlights its border when active.

```gsx
package main

import (
    "fmt"

    tui "github.com/grindlemire/go-tui"
)

type panels struct {
    panelA     *tui.State[bool]
    panelB     *tui.State[bool]
    panelC     *tui.State[bool]
    focusGroup *tui.FocusGroup
}

func Panels() *panels {
    a := tui.NewState(false)
    b := tui.NewState(false)
    c := tui.NewState(false)
    return &panels{
        panelA:     a,
        panelB:     b,
        panelC:     c,
        focusGroup: tui.MustNewFocusGroup(a, b, c),
    }
}

func (p *panels) KeyMap() tui.KeyMap {
    km := p.focusGroup.KeyMap()
    km = append(km,
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) {
            ke.App().Stop()
        }),
    )
    return km
}

func panelStyle(active bool) string {
    if active {
        return "text-cyan font-bold"
    }
    return "font-dim"
}

func panelBorder(active bool) tui.Style {
    if active {
        return tui.NewStyle().Foreground(tui.ANSIColor(tui.Cyan))
    }
    return tui.NewStyle().Foreground(tui.ANSIColor(tui.White))
}

templ (p *panels) Render() {
    <div class="flex-row gap-1 p-1" height={20}>
        <Panel title="Panel A" active={p.panelA.Get()} />
        <Panel title="Panel B" active={p.panelB.Get()} />
        <Panel title="Panel C" active={p.panelC.Get()} />
    </div>
}

templ Panel(title string, active bool) {
    <div class="flex-col flex-1 border-rounded p-1" borderStyle={panelBorder(active)}>
        <span class={panelStyle(active)}>{title}</span>
        if active {
            <span class="text-cyan">{fmt.Sprintf("%s is active", title)}</span>
        } else {
            <span class="text-dim">Press Tab to focus</span>
        }
    </div>
}
```

## See Also

- [Events Reference](events.md) -- key and mouse event types
- [State Reference](state.md) -- `State[T]` used by FocusGroup members
- [App Reference](app.md) -- `FocusNext`, `FocusPrev`, `Focused` methods
- [Focus Guide](../guides/13-focus.md) -- practical patterns for focus management