gitstack

grindlemire/go-tui code browser

10.8 KB markdown 429 lines 2026-04-03 · 2dcff81 raw

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

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

IsFocusable() bool

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

HandleEvent

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

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

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

func WithFocusable(focusable bool) Option

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

In .gsx:

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

WithOnFocus

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:

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

WithOnBlur

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:

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

Methods

IsFocused

func (e *Element) IsFocused() bool

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

// 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

func (e *Element) SetFocusable(focusable bool)

Changes whether the element can receive focus at runtime.

SetOnFocus

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

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

SetOnBlur

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

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

ContainsPoint

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

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

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

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.

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

FocusPrev

func (a *App) FocusPrev()

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

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

Focused

func (a *App) Focused() Focusable

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

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

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.

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

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

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

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

Next

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

func (fg *FocusGroup) Prev()

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

Current

func (fg *FocusGroup) Current() int

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

KeyMap

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.

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:

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.

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