Keys, focus and mouse¶
The key vocabulary¶
The same keys mean the same thing in every component.
| Keys | Meaning everywhere |
|---|---|
↑/k, ↓/j |
move the cursor, or scroll, one row |
g / G |
top / bottom |
ctrl+u / ctrl+d |
half a page up / down |
←/h, →/l |
scroll horizontally |
0/home, $/end |
horizontal start / end |
/ |
filter or search |
n / N |
next / previous match |
enter |
open the focused selection |
tab / shift+tab |
move focus between panes |
esc |
leave a field; at screen level, go back |
Arrows are aliases for hjkl, not separate verbs, and no component binds an
arrow or hjkl to anything but scrolling. That's why tree and
inspector expand with space and E/C instead of ←/→, and why
pkg/tab switches tabs with shift+←/shift+→ and 1–9 rather than tab.
Single letters are for app-wide affordances (q, t, ?, /). Per-screen
verbs ("delete", "restart") belong in a menu: a list and enter, or the
action menu.
The full per-component list is in Default key bindings.
Overriding bindings¶
Every interactive component exposes its keymap as Options.Keys, built from
DefaultKeys(). Each binding carries both its keys and its help label, so an
override shows up in the hint strip and overlay without restating anything:
opts := t.Table()
opts.Keys.Filter = key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter"))
opts.Keys.Pane.Left = key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "scroll left"))
Unset fields fall back to the defaults field by field. Horizontal scroll lives
on the nested Keys.Pane.
Capturing keys¶
When a text field has focus, global keys must reach it as text. A screen
reports that through IsCapturingKeys(), and the shell then suppresses q,
theme cycling, esc-pop and its other globals:
func (s *Screen) IsCapturingKeys() bool { return s.list.IsCapturingKeys() }
Every component that swallows printable keys implements IsCapturingKeys():
true while its filter or field has focus. A screen with a modal up returns
true too.
Focus groups¶
A screen with more than one interactive component holds a focus.Group:
s.focus = focus.NewGroup(&s.query, &s.results, &s.caseToggle)
func (s *Screen) Init() tea.Cmd { return s.focus.Init() }
func (s *Screen) Update(msg tea.Msg) (screen.Screen, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
s.focus, cmd = s.focus.Update(msg) // every message, not just tab
cmds = append(cmds, cmd)
switch msg.(type) {
case tea.KeyMsg: // keys go to the focused component only
switch {
case s.focus.Is(&s.query):
s.query, cmd = s.query.Update(msg)
case s.focus.Is(&s.results):
s.results, cmd = s.results.Update(msg)
case s.focus.Is(&s.caseToggle):
s.caseToggle, cmd = s.caseToggle.Update(msg)
}
cmds = append(cmds, cmd)
default: // mouse and everything else go to every component
s.query, cmd = s.query.Update(msg)
cmds = append(cmds, cmd)
s.results, cmd = s.results.Update(msg)
cmds = append(cmds, cmd)
s.caseToggle, cmd = s.caseToggle.Update(msg)
cmds = append(cmds, cmd)
}
return s, tea.Batch(cmds...)
}
func (s *Screen) IsCapturingKeys() bool { return s.focus.IsCapturingKeys() }
func (s *Screen) Help() []key.Binding { return s.focus.Help() }
func (s *Screen) HelpSections() []help.Section { return s.focus.HelpSections() }
The group:
- cycles with
tab/shift+tab(WithKeysrelabels them,WithoutWrapstops at the ends); - blurs everything and focuses one;
- grants focus to a component that was clicked: the component sends a
focus.RequestMsg, which is why the group needs every message, not just keys; - declines to cycle while the focused component is capturing keys, so a
half-typed filter isn't stranded (and
pkg/tablecan usetabto complete akey:valueterm). Leave the field withenteroresc, then cycle; - answers
Help()with the cycling keys plus the focused component's, andHelpSections()with every pane's groups, qualified by pane title.
Route mouse to every component
Keys go to the focused component; mouse events go to all of them, and each tests the position against its own rect. Sending mouse only to the focused component means a click on another pane never reaches it, so it can't take focus. This mistake reads as a broken component rather than a routing bug.
On a theme swap, rebuild the group over the same field addresses and restore
the index with SetIndex. See examples/patterns/focus.
Filterable components have two regions¶
A filterable list, table, tree, inspector, logview or textview has two
focusable regions behind one Focusable: the filter row and the body.
Focus()lights the pane and never blurs the filter.Blur()clears both, so a blurred pane can't keep swallowing keys.Focused()is true if either region is active.FocusFilter()/BlurFilter()move input between them.- Any click inside the pane other than on the filter row hands input back to the body. A click on the scrollbar is the exception and leaves a half-typed query alone.
examples/patterns/filters is a screen with two filterable panes.
Mouse¶
Turn it on per app:
app.New(app.Options{ /* … */ Mouse: app.MouseClick })
It's off by default because mouse reporting takes the terminal's own
click-drag text selection away. With it on, the shell translates each
tea.MouseMsg into a mouse.Msg with a resolved click count, so components
never see the raw event and none of them needs a double-click threshold.
The verbs, library-wide:
| Gesture | Effect |
|---|---|
| click | focuses the component and moves the cursor to the clicked row |
| double-click | does what enter does: the component emits an ActivatedMsg |
| wheel | does what ↓/j does, for the component under the pointer, focused or not; it never takes focus |
| shift+click | extends a marked range (multi-select) |
| right-click | opens the action menu at the pointer (when ActionsKey is set) |
| click on chrome | table headers sort, tree/inspector ▸/▾ toggle, modal buttons commit, tab labels switch, breadcrumb crumbs unwind, scrollbars jump and drag, the statusbar message opens the console |
Inputs too: clicking an input focuses it, clicking a side of a toggle
picks it, clicking a form field moves the form's focus there.
Opting in to double-click¶
A component reports activation; the screen decides what "open" means. Use one predicate for both inputs:
if s.table.IsActivate(msg) { // enter, or this table's double-click
if row, ok := s.table.Selected(); ok {
return s, screen.Push(newDetail(row, s.t))
}
}
list, table and eventlog provide IsActivate. The ActivatedMsg carries
a focus.Token, so two lists on one screen never claim each other's double
clicks. A single click only focuses and selects; it's always safe to explore
with.
Double-click timing¶
The window is app.Options.DoubleClickInterval, else the user's
double_click_ms config setting, else 500ms. It's a per-machine preference, so
no component carries mouse configuration.
Writing a mouse-aware component¶
Match mouse.Msg, test your rect with Hit, and return the message untouched
when it isn't yours:
case mouse.Msg:
if !m.rect.Hit(msg.X, msg.Y) {
return m, nil // not ours; a sibling may claim it
}
if msg.IsDoubleClick() {
return m, m.activate()
}
if msg.IsPress() {
return m, focus.RequestSelf(m.token)
}
See examples/patterns/mouse.