focus

package
v0.0.0-...-64e189b Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 4 Imported by: 0

Documentation

Overview

Package focus is a small focus-traversal helper: a Ring tracks which of n items has focus and moves it with Tab and Shift+Tab, wrapping at the ends and skipping disabled items. It replaces the modulo arithmetic every multi-field screen otherwise hand-rolls (see examples/focus).

Route and Bind go one step further: they blur the old widget, focus the new one and send every other message to the focused widget only, so a screen needs no per-widget switch (see examples/focus).

Tab follows index order unless the Ring is given another with WithOrder. LayoutOrder derives one from a layout.Node, reading order of where each named field is drawn, and Zones gives RouteAuto the matching click regions, so neither tab order nor mouse focus is maintained by hand.

Ring is a value type like the widget Models: every method returns the new Ring and leaves the receiver alone, so copies held elsewhere are never changed behind your back.

Example

A form with three fields: Tab cycles forward, Shift+Tab back.

ring := New(3)
for _, k := range []tui.Key{tab(), tab(), tab(), shiftTab()} {
	ring, _ = ring.Update(k)
	fmt.Print(ring.Current(), " ")
}
Output:
1 2 0 2

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func LayoutOrder

func LayoutOrder(root layout.Node, s layout.Size, names ...string) []int

LayoutOrder returns the indices of names (item i is names[i]) in the order a reader meets them on screen: by where root, laid out at size s, draws the node labelled with that name (see layout.Named), top to bottom and then left to right. Items whose name is not drawn (absent, or clipped to nothing) follow the drawn ones in index order, so every index appears once. Pass the result to WithOrder:

ring = ring.WithOrder(focus.LayoutOrder(root, size, "name", "email", "submit"))

Recompute it when the layout changes shape (a resize past a layout.Responsive breakpoint, say); focus itself is kept by WithOrder.

Example

Tab order from the layout: fields are numbered in the order the model keeps them, and Tab visits them in the order they appear on screen.

package main

import (
	"fmt"

	"github.com/ows4444/tui/focus"
	"github.com/ows4444/tui/layout"
)

func main() {
	cell := func(name string) layout.FlexChild {
		return layout.FlexChild{Node: layout.Named(name, layout.Block(name)), Basis: 1}
	}
	form := layout.Row(2,
		layout.FlexChild{Node: layout.Column(0, cell("name"), cell("email")), Basis: 8},
		layout.FlexChild{Node: layout.Column(0, cell("city"), cell("submit")), Basis: 8},
	)
	names := []string{"name", "email", "city", "submit"}
	size := layout.Size{W: 20, H: 2}

	ring := focus.New(len(names)).WithOrder(focus.LayoutOrder(form, size, names...))
	for range names {
		fmt.Print(names[ring.Current()], " ")
		ring = ring.Next()
	}
	fmt.Println()
}
Output:
name city email submit

func Zones

func Zones(root layout.Node, s layout.Size, names ...string) hittest.Map[int]

Zones returns the click regions of names, laid out as LayoutOrder does, with each region's ID the item's index: the Map RouteAuto takes, so a left click focuses the item under it. Where named nodes overlap the later one in drawing order wins, as in hittest.HitMap. Names not drawn have no region.

Types

type Field

type Field struct {
	Focus  func() tui.Cmd
	Blur   func()
	Update func(tui.Msg) tui.Cmd
	// Consumes reports whether the item wants msg for itself, so RouteAuto
	// gives it the message instead of moving focus (a Tab that indents, a
	// completion popup). Nil means the item never consumes anything.
	Consumes func(tui.Msg) bool
}

Field is one ring item as the Ring drives it: how to focus it, blur it, and hand it a message. Build one with Bind, or fill the funcs yourself for a widget that does not follow the Model conventions. Nil funcs are skipped.

func Bind

func Bind[M interface {
	Update(tui.Msg) (M, tui.Cmd)
}, P interface {
	*M
	Focusable
}](p P) Field

Bind returns the Field for the widget p points to, typically a field of your model:

fields := []focus.Field{focus.Bind(&m.name), focus.Bind(&m.bio)}

Focus and Blur are p's methods, and Update runs p's Update and stores the new Model back through p, returning its Cmd. Bind the fields of the model value you are about to return, inside Update (models are values, so the pointer must point into the copy you keep).

type Focusable

type Focusable interface {
	Focus() tui.Cmd
	Blur()
}

Focusable is what a widget offers so a Ring can move focus onto and off it: Focus gives it focus (returning any Cmd it needs, such as a cursor blink) and Blur takes focus away. textinput, passwordinput, textarea and the other input widgets satisfy it through a pointer to their Model.

type Ring

type Ring struct {
	// contains filtered or unexported fields
}

Ring is a set of n focusable items, numbered 0 to n-1, with one focused. The zero Ring has no items.

func New

func New(n int) Ring

New returns a Ring of n items with item 0 focused. n <= 0 gives an empty Ring.

func (Ring) Current

func (r Ring) Current() int

Current is the index of the focused item, or -1 for an empty Ring.

func (Ring) Depth

func (r Ring) Depth() int

Depth is the number of open scopes pushed over the root: 0 for a Ring that never had Push called.

func (Ring) Enabled

func (r Ring) Enabled(i int) bool

Enabled reports whether item i exists and is not disabled.

func (Ring) Focused

func (r Ring) Focused(i int) bool

Focused reports whether item i has focus.

func (Ring) Len

func (r Ring) Len() int

Len is the number of items, disabled or not.

func (Ring) Next

func (r Ring) Next() Ring

Next moves focus to the next enabled item, wrapping from the last to the first. With no enabled item other than the current one, or none at all, focus stays where it is.

func (Ring) Order

func (r Ring) Order() []int

Order returns the traversal order Next follows: the order given to WithOrder, completed and cleaned, or 0..Len()-1 when none was set. The slice is a copy.

func (Ring) Pop

func (r Ring) Pop() Ring

Pop closes the innermost scope and returns the Ring as it was when Push was called, including which item had focus and which were disabled, so focus goes back to the widget that had it before the scope opened. Call Sync (or focus the item yourself) to tell the widget. Pop on a Ring with no open scope returns it unchanged.

func (Ring) Prev

func (r Ring) Prev() Ring

Prev moves focus to the previous enabled item, wrapping from the first to the last.

func (Ring) Push

func (r Ring) Push(n int) Ring

Push opens a new focus scope of n items over the receiver, with item 0 focused, and returns it. While the scope is open Next, Prev, Set, Update and Route act on its items only, so Tab cannot reach the items of the scopes beneath it; this is how a modal traps focus. n <= 0 gives an empty scope. Pop returns to the scope the Push was made from, exactly as it was.

func (Ring) Route

func (r Ring) Route(msg tui.Msg, fields ...Field) (Ring, tui.Cmd)

Route is the whole focus dispatch for a screen of several widgets. On Tab and Shift+Tab it moves focus to the next or previous enabled item, calls Blur on the item that had it and Focus on the item that gets it, and returns the Focus Cmd. Every other message goes to the focused item's Update only and its Cmd is returned; a message the focused item does not want costs nothing. Route never touches a disabled item or an index outside fields.

ring, cmd := m.ring.Route(msg, fields...)
m.ring = ring
return m, cmd

fields must have one entry per ring item, in order. Messages that every widget needs, such as cursor blink ticks for an unfocused field, are not broadcast: forward those yourself.

func (Ring) RouteAuto

func (r Ring) RouteAuto(msg tui.Msg, zones hittest.Map[int], fields ...Field) (Ring, tui.Cmd)

RouteAuto is Route with the two rules a screen otherwise hand-writes:

  • Tab and Shift+Tab move focus (wrapping, skipping disabled items) unless the focused item's Consumes reports it wants the key, in which case the key goes to that item's Update and focus stays.
  • A left-button press on a zones region focuses the item whose index is the region's ID (blurring the old one), then delivers the press to it. A press outside every region, or on a disabled or unknown item, goes to the focused item as any other message would.

Every other message goes to the focused item only, as in Route. Pass a zero hittest.Map to turn mouse focusing off. Programs that do not call RouteAuto are unaffected.

func (Ring) Set

func (r Ring) Set(i int) Ring

Set moves focus to item i. An index out of range, or a disabled item, leaves focus unchanged.

func (Ring) SetDisabled

func (r Ring) SetDisabled(i int, disabled bool) Ring

SetDisabled marks item i disabled (skipped by Next and Prev) or enabled again. Disabling the focused item moves focus to the next enabled one, if there is one. An out-of-range i is ignored.

func (Ring) Sync

func (r Ring) Sync(fields ...Field) tui.Cmd

Sync makes the fields agree with the Ring: it blurs every item but the focused one and focuses that one, returning its Focus Cmd. Call it once at start-up (from the initial model, returning the Cmd from Init) and after SetDisabled or Set changes which item has focus.

func (Ring) Update

func (r Ring) Update(msg tui.Msg) (Ring, bool)

Update moves focus on Tab (next) and Shift+Tab (previous) and reports whether msg was one of those keys, so a caller can stop handling it:

if ring, moved := m.ring.Update(msg); moved {
	m.ring = ring
	return m, nil
}

Shift+Tab arrives as a Tab key with the Shift modifier, which the input reader decodes from both ESC [ Z and the kitty keyboard protocol.

func (Ring) WithOrder

func (r Ring) WithOrder(order []int) Ring

WithOrder returns r with Next and Prev (and so Tab, Shift+Tab, Update, Route and RouteAuto) moving along order instead of index order: order[0] is first, and the last wraps to it. Indices out of range and repeats are ignored, and items order leaves out are visited after the listed ones in index order, so every item stays reachable. A nil or empty order restores index order. Which item has focus, and which are disabled, do not change. LayoutOrder computes an order from a layout. A scope opened with Push starts in index order; Pop brings back the order of the scope beneath.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL