gitstack

grindlemire/go-tui code browser

17.2 KB markdown 516 lines 2026-03-23 · a47141f 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
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
# Refs and Click Handling

## Overview

Refs let you hold a reference to a rendered element so you can interact with it from Go code. The most common use is mouse click handling: attach a ref to a button, then check if a click landed on that button. go-tui provides three ref types (`Ref`, `RefList`, and `RefMap[K]`) for single elements, indexed collections, and keyed collections.

## What is a Ref

A `Ref` is a pointer to a single element in the rendered tree. Create one with `tui.NewRef()` and attach it to an element using the `ref` attribute:

```go
type myApp struct {
    saveBtn *tui.Ref
}

func MyApp() *myApp {
    return &myApp{
        saveBtn: tui.NewRef(),
    }
}
```

```gsx
templ (a *myApp) Render() {
    <button ref={a.saveBtn} class="px-2">Save</button>
}
```

After the first render, `a.saveBtn.El()` returns the `*tui.Element` for that button. Before the first render it returns `nil`, so always check:

```go
if el := a.saveBtn.El(); el != nil {
    // safe to use el
}
```

The generated code calls `.Set()` on the ref each render cycle, so the ref always points to the current element instance.

## Ref Types

go-tui provides three ref types for different use cases:

| Type | Constructor | Use When |
|------|------------|----------|
| `*tui.Ref` | `tui.NewRef()` | You have a single element to reference |
| `*tui.RefList` | `tui.NewRefList()` | You have elements in a `for` loop, accessed by index |
| `*tui.RefMap[K]` | `tui.NewRefMap[string]()` | You have elements keyed by a value (string, int, etc.) |

`Ref` stores one element. `RefList` stores elements by their loop index; use `.At(i)` to bind in the template and `.El(i)` to read back. `RefMap[K]` stores elements by an arbitrary key; use `.At(key)` to bind and `.El(key)` to read back.

## Click Handling Pattern

Mouse click handling follows a three-step pattern:

**1. Create refs** in your constructor:

```go
func MyApp() *myApp {
    return &myApp{
        saveBtn:   tui.NewRef(),
        cancelBtn: tui.NewRef(),
    }
}
```

**2. Bind refs** in your template:

```gsx
<button ref={a.saveBtn} class="px-2">Save</button>
<button ref={a.cancelBtn} class="px-2">Cancel</button>
```

**3. Wire up HandleMouse** with `HandleClicks`:

```go
func (a *myApp) HandleMouse(me tui.MouseEvent) bool {
    return tui.HandleClicks(me,
        tui.Click(a.saveBtn, a.onSave),
        tui.Click(a.cancelBtn, a.onCancel),
    )
}

func (a *myApp) onSave()   { /* ... */ }
func (a *myApp) onCancel() { /* ... */ }
```

`HandleClicks` checks each `Click` binding in order. When the mouse event is a left-click press that lands within a ref's element bounds, it calls that binding's handler and returns `true`. If no binding matches, it returns `false`.

## HandleClicks Details

`tui.HandleClicks` only responds to left-click press events (`MouseLeft` with `MousePress`). It does not fire on release, drag, or right-click. The bindings are checked in order, and the first match wins.

Each `tui.Click` binding takes a ref (any of the three types) and a `func()` handler. The framework checks whether the click coordinates fall within the element's rendered bounds. For `RefList` and `RefMap`, it checks all stored elements.

The `HandleMouse` method on your component implements the `MouseListener` interface. Return `true` if you handled the event, `false` to let it propagate.

## Refs in Loops

When you use `ref=` inside a `for` loop, the generator automatically uses the right ref type based on whether a `key` attribute is present.

### RefList (no key)

Without a `key`, the ref becomes a `RefList`, an ordered collection populated with `Append` on each iteration:

```go
type listApp struct {
    items    []string
    itemRefs *tui.RefList
}

func ListApp() *listApp {
    return &listApp{
        items:    []string{"Alpha", "Beta", "Gamma"},
        itemRefs: tui.NewRefList(),
    }
}
```

```gsx
for _, item := range a.items {
    <span ref={a.itemRefs} class="p-1">{item}</span>
}
```

The generated code calls `a.itemRefs.Append(el)` for each iteration. Access elements with `.At(i)` or `.All()`.

### RefMap with key

Add a `key` attribute to turn the ref into a `RefMap[K]`. Each element is stored under its key:

```go
type tabApp struct {
    presetBtns *tui.RefMap[string]
}
```

```gsx
for _, p := range presets {
    <button ref={a.presetBtns} key={p.name} class="px-1">{p.name}</button>
}
```

The generated code calls `a.presetBtns.Put(p.name, el)` for each iteration. Look up elements with `.Get(key)` or iterate with `.All()`.

This is useful when you need to identify *which* element was clicked by its key rather than by position.

## Combining Keyboard and Mouse

The color mixer example wires both keyboard shortcuts and clickable buttons to the same actions:

```go
func (c *colorMixer) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
        tui.On(tui.Rune('r'), func(ke tui.KeyEvent) { c.adjustRed(16) }),
        tui.On(tui.Rune('R'), func(ke tui.KeyEvent) { c.adjustRed(-16) }),
        tui.On(tui.Rune('g'), func(ke tui.KeyEvent) { c.adjustGreen(16) }),
        tui.On(tui.Rune('G'), func(ke tui.KeyEvent) { c.adjustGreen(-16) }),
        tui.On(tui.Rune('b'), func(ke tui.KeyEvent) { c.adjustBlue(16) }),
        tui.On(tui.Rune('B'), func(ke tui.KeyEvent) { c.adjustBlue(-16) }),
    }
}

func (c *colorMixer) HandleMouse(me tui.MouseEvent) bool {
    // Check single-ref button clicks
    if tui.HandleClicks(me,
        tui.Click(c.redUpBtn, func() { c.adjustRed(16) }),
        tui.Click(c.redDnBtn, func() { c.adjustRed(-16) }),
        tui.Click(c.greenUpBtn, func() { c.adjustGreen(16) }),
        tui.Click(c.greenDnBtn, func() { c.adjustGreen(-16) }),
        tui.Click(c.blueUpBtn, func() { c.adjustBlue(16) }),
        tui.Click(c.blueDnBtn, func() { c.adjustBlue(-16) }),
    ) {
        return true
    }

    // Check keyed-ref preset button clicks via RefMap
    if me.Button == tui.MouseLeft && me.Action == tui.MousePress {
        for name, el := range c.presetBtns.All() {
            if el != nil && el.ContainsPoint(me.X, me.Y) {
                c.applyPreset(name)
                return true
            }
        }
    }

    return false
}
```

Both input methods call the same `adjust*` methods, so the behavior stays consistent. The preset buttons use `RefMap.All()` to iterate and hit-test by key, letting us identify which preset was clicked by name.

## Click Handling in Modals

Modals handle mouse clicks internally. You don't need refs or `HandleMouse` for buttons inside a modal. Use the `onActivate` attribute on focusable elements instead:

```gsx
<modal open={s.showDialog} class="justify-center items-center">
    <div class="border-rounded p-2 flex-col gap-1 w-40">
        <span class="font-bold">Delete Item?</span>
        <div class="flex gap-2 justify-center">
            <button class="px-2 border-rounded focusable" onActivate={s.onDelete}>Delete</button>
            <button class="px-2 border-rounded focusable" onActivate={s.onCancel}>Cancel</button>
        </div>
    </div>
</modal>
```

Clicking a button fires its `onActivate` callback. Clicking the backdrop (the dimmed area outside the dialog) closes the modal by setting the `open` state to `false`.

Enter does the same thing as a click: it fires `onActivate` on whichever button is focused. Tab and Shift+Tab cycle between focusable elements inside the modal. Escape closes it.

Outside of modals, use refs and `HandleClicks` for click targets. Inside modals, `onActivate` is all you need.

## Complete Example

This color mixer uses ref-based click handling for the +/- buttons and presets, plus a modal confirmation dialog for the reset action. Each color channel has a visual bar, a value readout, and clickable buttons. Preset colors use `RefMap` with `key`:

```gsx
package main

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

type colorMixer struct {
    red   *tui.State[int]
    green *tui.State[int]
    blue  *tui.State[int]

    redUpBtn     *tui.Ref
    redDnBtn     *tui.Ref
    greenUpBtn   *tui.Ref
    greenDnBtn   *tui.Ref
    blueUpBtn    *tui.Ref
    blueDnBtn    *tui.Ref
    presetBtns   *tui.RefMap[string]
    activePreset *tui.State[string]
    showReset    *tui.State[bool]
}

func ColorMixer() *colorMixer {
    return &colorMixer{
        red:          tui.NewState(128),
        green:        tui.NewState(64),
        blue:         tui.NewState(200),
        redUpBtn:     tui.NewRef(),
        redDnBtn:     tui.NewRef(),
        greenUpBtn:   tui.NewRef(),
        greenDnBtn:   tui.NewRef(),
        blueUpBtn:    tui.NewRef(),
        blueDnBtn:    tui.NewRef(),
        presetBtns:   tui.NewRefMap[string](),
        activePreset: tui.NewState(""),
        showReset:    tui.NewState(false),
    }
}

type preset struct {
    name    string
    r, g, b int
}

var presets = []preset{
    {"Sunset", 255, 128, 0},
    {"Ocean", 0, 100, 255},
    {"Forest", 34, 180, 34},
    {"Rose", 255, 64, 128},
}

func clamp(v, min, max int) int {
    if v < min {
        return min
    }
    if v > max {
        return max
    }
    return v
}

func (c *colorMixer) adjustRed(delta int) {
    c.red.Set(clamp(c.red.Get()+delta, 0, 255))
    c.activePreset.Set("")
}

func (c *colorMixer) adjustGreen(delta int) {
    c.green.Set(clamp(c.green.Get()+delta, 0, 255))
    c.activePreset.Set("")
}

func (c *colorMixer) adjustBlue(delta int) {
    c.blue.Set(clamp(c.blue.Get()+delta, 0, 255))
    c.activePreset.Set("")
}

func (c *colorMixer) confirmReset() {
    c.showReset.Set(false)
    c.red.Set(128)
    c.green.Set(64)
    c.blue.Set(200)
    c.activePreset.Set("")
}

func (c *colorMixer) cancelReset() {
    c.showReset.Set(false)
}

func (c *colorMixer) applyPreset(name string) {
    for _, p := range presets {
        if p.name == name {
            c.red.Set(p.r)
            c.green.Set(p.g)
            c.blue.Set(p.b)
            c.activePreset.Set(name)
            return
        }
    }
}

func (c *colorMixer) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
        tui.On(tui.Rune('q'), func(ke tui.KeyEvent) { ke.App().Stop() }),
        tui.On(tui.Rune('r'), func(ke tui.KeyEvent) { c.adjustRed(16) }),
        tui.On(tui.Rune('R'), func(ke tui.KeyEvent) { c.adjustRed(-16) }),
        tui.On(tui.Rune('g'), func(ke tui.KeyEvent) { c.adjustGreen(16) }),
        tui.On(tui.Rune('G'), func(ke tui.KeyEvent) { c.adjustGreen(-16) }),
        tui.On(tui.Rune('b'), func(ke tui.KeyEvent) { c.adjustBlue(16) }),
        tui.On(tui.Rune('B'), func(ke tui.KeyEvent) { c.adjustBlue(-16) }),
        tui.On(tui.Rune('x'), func(ke tui.KeyEvent) { c.showReset.Set(true) }),
    }
}

func (c *colorMixer) HandleMouse(me tui.MouseEvent) bool {
    // Check single-ref button clicks
    if tui.HandleClicks(me,
        tui.Click(c.redUpBtn, func() { c.adjustRed(16) }),
        tui.Click(c.redDnBtn, func() { c.adjustRed(-16) }),
        tui.Click(c.greenUpBtn, func() { c.adjustGreen(16) }),
        tui.Click(c.greenDnBtn, func() { c.adjustGreen(-16) }),
        tui.Click(c.blueUpBtn, func() { c.adjustBlue(16) }),
        tui.Click(c.blueDnBtn, func() { c.adjustBlue(-16) }),
    ) {
        return true
    }

    // Check keyed-ref preset button clicks via RefMap
    if me.Button == tui.MouseLeft && me.Action == tui.MousePress {
        for name, el := range c.presetBtns.All() {
            if el != nil && el.ContainsPoint(me.X, me.Y) {
                c.applyPreset(name)
                return true
            }
        }
    }

    return false
}

func colorBar(value int) string {
    filled := value * 20 / 255
    bar := ""
    for i := 0; i < 20; i++ {
        if i < filled {
            bar += "█"
        } else {
            bar += "░"
        }
    }
    return bar
}

templ (c *colorMixer) Render() {
    <div class="flex-col p-1 border-rounded border-cyan">
        <span class="text-gradient-cyan-magenta font-bold">Color Mixer</span>

        // Color preview
        <div class="flex-col items-center border-rounded p-1">
            <span class="text-gradient-cyan-magenta font-bold">Preview</span>
            <div backgroundGradient={tui.NewGradient(tui.Black, tui.RGBColor(uint8(c.red.Get()), uint8(c.green.Get()), uint8(c.blue.Get())))} height={2} width={30}>
                <span>{" "}</span>
            </div>
            <div class="flex gap-2 justify-center">
                <span class="text-red font-bold">{fmt.Sprintf("R: %d", c.red.Get())}</span>
                <span class="text-green font-bold">{fmt.Sprintf("G: %d", c.green.Get())}</span>
                <span class="text-blue font-bold">{fmt.Sprintf("B: %d", c.blue.Get())}</span>
            </div>
        </div>

        // Color bars
        <div class="flex-col border-rounded p-1">
            <div class="flex gap-1">
                <span class="text-red font-bold w-5">Red</span>
                <span class="text-red">{colorBar(c.red.Get())}</span>
                <span class="text-red font-bold">{fmt.Sprintf("%3d", c.red.Get())}</span>
            </div>
            <div class="flex gap-1">
                <span class="text-green font-bold w-5">Grn</span>
                <span class="text-green">{colorBar(c.green.Get())}</span>
                <span class="text-green font-bold">{fmt.Sprintf("%3d", c.green.Get())}</span>
            </div>
            <div class="flex gap-1">
                <span class="text-blue font-bold w-5">Blu</span>
                <span class="text-blue">{colorBar(c.blue.Get())}</span>
                <span class="text-blue font-bold">{fmt.Sprintf("%3d", c.blue.Get())}</span>
            </div>
        </div>

        // Channel controls with refs
        <div class="flex gap-1">
            <div class="flex-col border-rounded p-1 items-center" flexGrow={1.0}>
                <span class="font-bold text-red">Red</span>
                <div class="flex gap-1 items-center">
                    <button ref={c.redDnBtn} class="px-1">{"-"}</button>
                    <span class="font-bold text-red">{fmt.Sprintf("%3d", c.red.Get())}</span>
                    <button ref={c.redUpBtn} class="px-1">{"+"}</button>
                </div>
            </div>
            <div class="flex-col border-rounded p-1 items-center" flexGrow={1.0}>
                <span class="font-bold text-green">Green</span>
                <div class="flex gap-1 items-center">
                    <button ref={c.greenDnBtn} class="px-1">{"-"}</button>
                    <span class="font-bold text-green">{fmt.Sprintf("%3d", c.green.Get())}</span>
                    <button ref={c.greenUpBtn} class="px-1">{"+"}</button>
                </div>
            </div>
            <div class="flex-col border-rounded p-1 items-center" flexGrow={1.0}>
                <span class="font-bold text-blue">Blue</span>
                <div class="flex gap-1 items-center">
                    <button ref={c.blueDnBtn} class="px-1">{"-"}</button>
                    <span class="font-bold text-blue">{fmt.Sprintf("%3d", c.blue.Get())}</span>
                    <button ref={c.blueUpBtn} class="px-1">{"+"}</button>
                </div>
            </div>
        </div>

        // Preset colors using RefMap with key
        <div class="flex gap-1 border-rounded p-1 items-center">
            <span class="font-bold">Presets:</span>
            for _, p := range presets {
                if p.name == c.activePreset.Get() {
                    <button ref={c.presetBtns} key={p.name} class="px-1 font-bold text-cyan">{p.name}</button>
                } else {
                    <button ref={c.presetBtns} key={p.name} class="px-1 font-dim">{p.name}</button>
                }
            }
        </div>

        <div class="flex justify-center">
            <span class="font-dim">r/g/b increase | R/G/B decrease | x reset | click buttons/presets | q quit</span>
        </div>
    </div>

    // Reset confirmation modal (uses onActivate, not refs)
    <modal open={c.showReset} class="justify-center items-center">
        <div class="border-rounded p-2 flex-col gap-1 w-40 border-cyan">
            <span class="font-bold text-cyan">Reset Colors?</span>
            <span>This restores the default RGB values.</span>
            <div class="flex gap-2 justify-center">
                <button class="px-2 border-rounded focusable" onActivate={c.confirmReset}>Yes</button>
                <button class="px-2 border-rounded focusable" onActivate={c.cancelReset}>No</button>
            </div>
        </div>
    </modal>
}
```

With `main.go`:

```go
package main

import (
    "fmt"
    "os"

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

func main() {
    app, err := tui.NewApp(
        tui.WithRootComponent(ColorMixer()),
    )
    if err != nil {
        fmt.Fprintf(os.Stderr, "Failed to create app: %v\n", err)
        os.Exit(1)
    }
    defer app.Close()

    if err := app.Run(); err != nil {
        fmt.Fprintf(os.Stderr, "App error: %v\n", err)
        os.Exit(1)
    }
}
```

Generate and run:

```bash
tui generate ./...
go run .
```

Click the +/- buttons, use r/g/b keys to adjust colors, or click a preset to apply it:

![Refs and Click Handling screenshot](/guides/09a.png)

Click "reset" to open the confirmation dialog and click on an option.

![Reset confirmation modal](/guides/09b.png)

## Next Steps

- [Built-in Elements](elements) - Reference guide to every HTML-like element in go-tui
- [Streaming Data](streaming) - Build a live data viewer with channels and auto-scroll