bloom

Focus and keyboard navigation

Track keyboard focus with bloom.Focus, move it with tab and clicks, press buttons with enter, confine it to dialogs and panels with scopes, and show the terminal cursor.

bloom.Focus keeps track of which node has keyboard focus, by ID. It lives in your model. The layout marks which nodes can take focus, and the order they appear in the layout is the tab order.

Make nodes focusable

Focus refers to nodes by ID, so give everything that can take focus an ID of the same type. A type of your own, with a constant for each node, keeps them apart from other IDs:

// control is the ID of a node that can take focus.
type control string

const (
	fieldName    control = "name"
	fieldEmail   control = "email"
	buttonSave   control = "save"
	buttonCancel control = "cancel"
)

Add a bloom.Focus of that type to your model. Its zero value has nothing focused:

type model struct {
	ui     *bloom.Root
	focus  bloom.Focus[control]
	values map[control]string
	// ...
}

Then mark the nodes Focusable:

bloom.Text(m.values[fieldName]).Style(input).ID(fieldName).Focusable()
bloom.Text(m.values[fieldEmail]).Style(input).ID(fieldEmail).Focusable()

Focus moves between focusable nodes with IDs of its type, and passes over the rest. Slots can be focusable too, such as bloom.Fill(m.editor).ID(paneEditor).Focusable(), which makes a whole pane a stop in the tab order.

Move focus with tab and clicks

m.focus.Update(msg, m.ui.Frame()) handles tab and shift+tab. It returns a command, and reports whether it handled the message:

	case tea.KeyPressMsg:
		cmd, ok := m.focus.Update(msg, m.ui.Frame())
		if !ok {
			cmd = m.key(msg) // other keys, for whatever has focus
		}
		return m, tea.Batch(cmd, m.ui.Update(msg))

m.ui.Frame() is the layout of the last frame drawn, which has the nodes that can be focused and their order. Focus wraps around from the last node to the first.

To move focus to what's clicked, give the root your focus with bloom.WithFocus:

func newModel() *model {
	m := &model{}
	m.ui = bloom.New(bloom.WithFocus(&m.focus))
	return m
}

A click then focuses the innermost focusable node under the pointer, before any mouse handler for the click runs, so a handler can move focus on from there, as a "next" button would. The root keeps a pointer to your Focus, so keep the model behind a pointer too.

You can also move focus yourself: Set(id), Next(frame), Prev(frame), and Clear(). Set takes an ID of the focus's type, so m.focus.Set("cancel") sets the control "cancel".

Press buttons with the keyboard

A button made with on.Press runs its handler when clicked, and when enter or space is pressed while it has focus. m.focus.Update runs it for the keys, and returns its command with ok set:

bloom.Text("save").Style(button).ID(buttonSave).Focusable().On(on.Press(m.save))

Enter and space on a focused node without a Press handler aren't handled, so they reach your own key handling, such as to type a space into an input.

Style the focused node

m.focus.Is(id) reports whether a node has focus, so View can style it:

func (m *model) field(id control) bloom.Child {
	style := input
	if m.focus.Is(id) {
		style = inputFocused
	}
	return bloom.Text(m.values[id]).Style(style).ID(id).Focusable()
}

Route keys to the focused node

m.focus.Current() returns the focused node's ID, and whether anything is focused. While nothing is, the ID is the type's zero value, which matches none of your constants:

		id, _ := m.focus.Current()
		switch id {
		case fieldName, fieldEmail:
			m.values[id] += msg.Text
		}

Nodes of different kinds

When the IDs of what takes focus are already different types, such as an int for each row of a table and a string for each button, make K an interface they all implement. A type switch then tells them apart:

// control is implemented by the ID types of everything that takes focus.
type control interface{ control() }

func (rowID) control()    {}
func (buttonID) control() {}
		id, _ := m.focus.Current() // a bloom.Focus[control]
		switch id := id.(type) {
		case rowID:
			m.open(id)
		case buttonID:
			return m, m.press(id)
		}

A bloom.Focus[any] moves between every focusable node, whatever its ID.

Show the cursor in an input

Cursor on a text node shows the terminal's cursor at a position inside the node. Put it after the text in the focused input:

in := bloom.Text(value).Style(style).ID(id).Focusable()
if m.focus.Is(id) {
	// one column in, for the style's left padding
	in = in.Cursor(tea.NewCursor(1+ansi.StringWidth(value), 0))
}

The position is relative to the node's top-left cell, wherever it ends up on screen. The cursor is hidden while a layer covers that cell. ansi here is github.com/charmbracelet/x/ansi, which measures the width of text the way the terminal does.

For Bubbles text inputs, use the cursor the component reports; see Using Bubbles components.

Keep focus inside a dialog

While a dialog is open, tab should only move between its buttons. A scope confines focus to the focusable nodes inside it. Modal layers with an ID are scopes:

bloom.Layer(dialog).ID(confirmDialog).Modal()

Push(scope) confines focus to the scope and clears it, and remembers what was focused before. Pop() leaves the scope and focuses that node again:

func (m *model) openDialog() tea.Cmd {
	m.focus.Push(confirmDialog)
	m.focus.Set(buttonCancel)
	return m.confirm.Show()
}

func (m *model) closeDialog() tea.Cmd {
	m.focus.Pop()
	return m.confirm.Hide()
}

Scopes nest, so a dialog opened from a dialog pushes a second scope, and closing it returns to the first. m.focus.Scope() returns the innermost scope pushed, which tells you which dialog is on top.

Group a panel into a scope

Slots can be scopes too, with Scope(). Without pushing it, a scope lets you ask whether focus is somewhere in a part of the screen, such as to highlight a panel's border while any field in it is focused:

bloom.Fixed(60, form).ID(scopeForm).Scope()
if m.focus.Within(m.ui.Frame(), scopeForm) {
	border = activeBorder
}

Last updated on

On this page