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
|
# Focus Management
## Overview
Focus determines which element receives keyboard input. go-tui handles element-level focus automatically. Mark elements as focusable and use `app.FocusNext()` / `app.FocusPrev()` to cycle between them. For section-level switching (e.g., sidebar vs content panel), `FocusGroup` manages mutual-exclusion state using `*State[bool]` values with built-in Tab/Shift+Tab bindings.
## Making Elements Focusable
Mark an element as focusable with the `focusable` attribute in your `.gsx` template:
```gsx
<button focusable={true} class="px-2 border-single">Click me</button>
```
You can also attach focus and blur callbacks. These fire when the element gains or loses focus:
```gsx
<div
focusable={true}
onFocus={func(el *tui.Element) { el.SetBorderStyle(tui.NewStyle().Foreground(tui.ANSIColor(tui.Cyan))) }}
onBlur={func(el *tui.Element) { el.SetBorderStyle(tui.NewStyle()) }}
class="border-single p-1"
>
<span>Focus me</span>
</div>
```
Setting `onFocus` or `onBlur` implicitly makes the element focusable, so you don't need to also set `focusable={true}`.
In Go code, the same behavior is available through option functions:
```go
elem := tui.New(
tui.WithFocusable(true),
tui.WithOnFocus(func(el *tui.Element) {
el.SetBorderStyle(tui.NewStyle().Foreground(tui.ANSIColor(tui.Cyan)))
}),
tui.WithOnBlur(func(el *tui.Element) {
el.SetBorderStyle(tui.NewStyle())
}),
)
```
To check whether an element has focus, call `IsFocused()`:
```go
if s.myRef.El() != nil && s.myRef.El().IsFocused() {
<span class="text-cyan">Focused</span>
} else {
<span class="text-dim">Not focused</span>
}
```
## Focus Navigation
The `App` provides three methods for focus control:
- `app.FocusNext()` -- move focus to the next focusable element
- `app.FocusPrev()` -- move focus to the previous focusable element
- `app.Focused()` -- get the currently focused `Focusable`, or nil
Focus order follows document order (depth-first traversal of the element tree). `FocusNext` wraps from the last element back to the first; `FocusPrev` wraps from the first to the last.
Wire these up in your `KeyMap` to give users Tab/Shift+Tab navigation:
```go
func (f *myForm) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.KeyTab, func(ke tui.KeyEvent) { ke.App().FocusNext() }),
tui.On(tui.KeyTab.Shift(), func(ke tui.KeyEvent) { ke.App().FocusPrev() }),
}
}
```
When the app starts, the first focusable element in the tree is automatically focused. If no elements are focusable, nothing receives focus.
## FocusGroup
`FocusGroup` manages Tab/Shift+Tab cycling between logical sections of your UI. Instead of tracking individual elements, it works with `*State[bool]` values. Each member state is `true` when that section is active and `false` otherwise. The group maintains mutual exclusion: exactly one member is active at a time.
Create a focus group with two or more `*State[bool]` members:
```go
type myForm struct {
sidebarActive *tui.State[bool]
contentActive *tui.State[bool]
footerActive *tui.State[bool]
focus *tui.FocusGroup
}
func MyForm() *myForm {
sidebar := tui.NewState(true) // starts active
content := tui.NewState(false)
footer := tui.NewState(false)
return &myForm{
sidebarActive: sidebar,
contentActive: content,
footerActive: footer,
focus: tui.MustNewFocusGroup(sidebar, content, footer),
}
}
```
`NewFocusGroup` sets the first member to `true` and all others to `false`. It returns an error if you pass fewer than 2 members. `MustNewFocusGroup` panics on error, which is fine for constructors where the member count is known at compile time.
### Navigation
`FocusGroup` has three methods:
- `fg.Next()` -- deactivate the current member, activate the next (wraps around)
- `fg.Prev()` -- deactivate the current member, activate the previous (wraps around)
- `fg.Current()` -- return the index of the currently active member
### Built-in KeyMap
`FocusGroup` implements the `KeyListener` interface. Its `KeyMap()` returns bindings for Tab (calls `Next`) and Shift+Tab (calls `Prev`). Include it in your component's `KeyMap` by spreading the group's bindings:
```go
func (f *myForm) KeyMap() tui.KeyMap {
return append(f.focus.KeyMap(), []tui.KeyBinding{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
// section-specific bindings...
}...)
}
```
### Using Active State in Render
Read each member state in your template to change styles based on which section is active:
```gsx
templ (f *myForm) Render() {
<div class="flex gap-1 h-full">
if f.sidebarActive.Get() {
<div class="flex-col border-rounded border-cyan p-1" width={20}>
<span class="text-cyan font-bold">Sidebar</span>
<span>Use Tab to switch</span>
</div>
} else {
<div class="flex-col border-rounded border-black p-1" width={20}>
<span class="font-dim">Sidebar</span>
</div>
}
if f.contentActive.Get() {
<div class="flex-col grow border-rounded border-cyan p-1">
<span class="text-cyan font-bold">Content</span>
<span>This panel is active</span>
</div>
} else {
<div class="flex-col grow border-rounded border-black p-1">
<span class="font-dim">Content</span>
</div>
}
if f.footerActive.Get() {
<div class="flex-col border-rounded border-cyan p-1" width={20}>
<span class="text-cyan font-bold">Footer</span>
</div>
} else {
<div class="flex-col border-rounded border-black p-1" width={20}>
<span class="font-dim">Footer</span>
</div>
}
</div>
}
```
## Programmatic Focus
Elements provide direct focus control through these methods:
```go
element.Focus() // give this element focus
element.Blur() // remove focus from this element
element.IsFocused() bool // check if this element has focus
element.IsFocusable() bool // check if this element can receive focus
element.SetFocusable(bool) // enable or disable focusability at runtime
```
`Focus()` and `Blur()` trigger the `onFocus` and `onBlur` callbacks if they were set via `WithOnFocus`/`WithOnBlur` or `SetOnFocus`/`SetOnBlur`.
You can also set callbacks programmatically after creation:
```go
element.SetOnFocus(func(el *tui.Element) {
// called when element gains focus
})
element.SetOnBlur(func(el *tui.Element) {
// called when element loses focus
})
```
Both `SetOnFocus` and `SetOnBlur` implicitly set the element as focusable.
## Modal Focus Trapping
When a `<modal>` has `trapFocus` enabled (the default), Tab and Shift+Tab only cycle through focusable elements inside the modal. Elements outside the modal are unreachable until it closes. A catch-all binding also blocks unhandled keys from parent handlers.
With `trapFocus={false}`, Tab and other keys propagate to parent components normally. You can combine this with the `keyMap` attribute to add custom hotkeys while letting everything else pass through.
The modal handles Enter by triggering the focused element's `onActivate` callback regardless of the `trapFocus` setting. When the modal closes, focus returns to the previously focused element.
Focusable elements with borders receive an automatic cyan border highlight when focused. You can override this behavior by providing your own `onFocus` and `onBlur` handlers.
## Complete Example
This form has three panels that highlight when active. Tab and Shift+Tab cycle between them using `FocusGroup`:
```gsx
package main
import (
"fmt"
tui "github.com/grindlemire/go-tui"
)
type panelForm struct {
panels []string
panel1 *tui.State[bool]
panel2 *tui.State[bool]
panel3 *tui.State[bool]
focus *tui.FocusGroup
clickCount *tui.State[int]
}
func PanelForm() *panelForm {
p1 := tui.NewState(true)
p2 := tui.NewState(false)
p3 := tui.NewState(false)
return &panelForm{
panels: []string{"Inbox", "Drafts", "Sent"},
panel1: p1,
panel2: p2,
panel3: p3,
focus: tui.MustNewFocusGroup(p1, p2, p3),
clickCount: tui.NewState(0),
}
}
func (p *panelForm) KeyMap() tui.KeyMap {
return append(p.focus.KeyMap(), []tui.KeyBinding{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune(' '), func(ke tui.KeyEvent) {
p.clickCount.Update(func(v int) int { return v + 1 })
}),
}...)
}
templ (p *panelForm) Render() {
<div class="flex-col gap-1 p-1">
<span class="font-bold text-gradient-cyan-magenta">Focus Demo — Tab to switch, Space to interact</span>
<div class="flex gap-1">
for i, name := range p.panels {
if i == p.focus.Current() {
<div class="flex-col border-rounded border-cyan p-1" width={20}>
<span class="text-cyan font-bold">{name}</span>
<span class="text-bright-white">{fmt.Sprintf("Actions: %d", p.clickCount.Get())}</span>
</div>
} else {
<div class="flex-col border-rounded border-black p-1" width={20}>
<span class="font-dim">{name}</span>
</div>
}
}
</div>
<span class="text-dim">Press Esc to quit</span>
</div>
}
```
With `main.go`:
```go
package main
import (
"fmt"
"os"
tui "github.com/grindlemire/go-tui"
)
func main() {
app, err := tui.NewApp(
tui.WithRootComponent(PanelForm()),
)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer app.Close()
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
```
Generate and run:
```bash
tui generate ./...
go run .
```
Tab between the panels to see focus in action:

## Next Steps
- [Building a Dashboard](dashboard) -- Build a live metrics dashboard from scratch
- [Events Guide](events) -- Keyboard and mouse event handling
|