Files
ilovetui/layout/README.md
T

11 KiB

layout

Arrange panes in a binary split tree (BSP, tmux/i3-style), navigate between them with spatial ctrl+hjkl, and get a help bar that always reflects whatever's focused - no matter how deep the tree is, or how many of those splits are themselves other layout trees nested inside a pane.

layout only owns geometry, focus and routing. It draws no border and imposes no style: every pane decides how to render itself for the size and focus state it's given.

Concepts

Three things:

  • Pane is your content: Init() tea.Cmd, Update(tea.Msg) (Pane, tea.Cmd), View() string - the same shape used by every other custom component in this repo.
  • Node is the shape of the tree. Leaf(id, pane) is a slot holding one Pane, addressed everywhere else (SendMsg, RequestFocusMsg, SplitLeaf, CloseLeaf, Resize) by that id. Split/HSplit/VSplit divide space between two child Nodes.
  • Model is the running layout: a Node tree plus focus, sizing and the help bar. Build one with layout.New(root, layout.AsRoot()).

Quick start

package main

import (
	"fmt"
	"os"

	tea "charm.land/bubbletea/v2"

	"github.com/anotherhadi/ilovetui/layout"
)

func main() {
	root := layout.HSplit(0.3,
		layout.Leaf("sidebar", newSidebarPane()),
		layout.Leaf("content", newContentPane()),
	)
	m := layout.New(root, layout.AsRoot())

	if err := layout.Run(m); err != nil {
		fmt.Println("Error running program:", err)
		os.Exit(1)
	}
}

That's a working two-pane app: ctrl+h/ctrl+l move focus between "sidebar" and "content", ? toggles the help bar, everything resizes with the terminal. layout.Run(m, opts ...tea.ProgramOption) wraps m for tea.NewProgram and runs it, alt-screen included. layout itself reserves no quit key - deciding how (and whether) the app quits is the host's call, same as any other app-level policy; see the examples for the usual ctrl+c/q pattern.

Writing a pane

type Pane interface {
	Init() tea.Cmd
	Update(tea.Msg) (Pane, tea.Cmd)
	View() string
}

layout tells a pane its size and focus state entirely through messages - never assume either any other way:

  • SizeMsg{ID, Width, Height} whenever the pane's allocated space changes (first layout, terminal resize, a sibling split/close/resize...). ID is the pane's own id, learned here so it can later fill RequestFocusMsg.Source (see below).
  • FocusMsg{} / BlurMsg{} whenever the pane gains or loses keyboard focus. Named distinctly from bubbletea's own tea.FocusMsg/tea.BlurMsg, which are about terminal focus, not pane focus.

Two rules to actually get right:

  • View() must render exactly the last width/height you were told. layout composes panes side by side with lipgloss.JoinHorizontal/JoinVertical - if a pane renders the wrong size, the whole layout visibly misaligns.
  • You own your own chrome. layout never draws a border. layout.Bordered(focused, w, h, content) covers the common case (border color follows focus, via style.S.Primary/style.S.Subtle) as an optional helper - draw nothing, or draw something else entirely, if you want.

Building the tree

root := layout.HSplit(0.3,
	layout.Leaf("sidebar", newSidebarPane()),
	layout.Leaf("content", newContentPane()),
)
  • layout.Leaf(id, pane) - one pane, addressed by id everywhere else in the API.
  • layout.HSplit(ratio, first, second) / layout.VSplit(ratio, first, second) - divide space horizontally (side by side) or vertically (stacked), giving ratio (0 to 1) to first and the rest to second. layout.Split(id, dir, ratio, first, second) is the general form if the split itself needs an id (see "Resizing at runtime" below). Nest freely:
root := layout.HSplit(0.25,
	sidebar,
	layout.VSplit(0.7,
		layout.HSplit(0.5, topLeft, topRight),
		bottom,
	),
)

Sizing

layout.HSplit(0.3, sidebar, content)                          // sidebar gets 30%, content the rest
layout.HSplit(0.3, sidebar, content).WithMinimum(20)           // 30%, but never below 20 cells
layout.HSplit(0.3, sidebar, content).WithMaximum(40)           // 30%, but never above 40 cells
layout.HSplit(0.3, sidebar, content).WithMinimum(20).WithMaximum(20) // fixed at 20 cells

WithMinimum/WithMaximum clamp the resolved size of the split's first child (in cells: columns for a horizontal split, rows for a vertical one). Setting both to the same value pins it regardless of ratio - the way to get an exact-width sidebar.

Navigation and the help bar

ctrl+h/ctrl+l/ctrl+j/ctrl+k move focus spatially - whichever pane is actually adjacent in that direction (tmux's select-pane -L/-D/-U/-R), not "next in the tree". ? toggles the help bar between its short and full form. Both work out of the box.

To customize the keys:

km := layout.DefaultKeyMap()
km.FocusLeft = key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "focus left"))
m := layout.New(root, layout.AsRoot(), layout.WithKeyMap(km))

Making a pane show up in the help bar

Implement HelpProvider:

type HelpProvider interface {
	HelpBindings() []key.Binding
}

layout reads it fresh every render from whichever pane is currently focused, at whatever depth (see "Composing bigger apps" below) - nothing to push or keep in sync. A pane that doesn't implement it just contributes nothing; the bar still shows layout's own controls (?, ctrl+hjkl), it never disappears.

Only the outermost Model should render a help bar. Pass layout.AsRoot() only to the one actually handed to Run/tea.NewProgram - an embedded Model (see "Composing bigger apps") built without it contributes its focused pane's bindings to the outer bar instead of drawing a second one of its own.

Talking between panes

A pane never holds a reference to another - it returns a tea.Cmd and lets layout deliver it, by id, wherever that id lives in the tree (including inside a nested layout.Model - see "Composing bigger apps"):

// deliver an arbitrary message to another pane's Update, regardless of focus
return p, func() tea.Msg {
	return layout.SendMsg{Target: "content", Msg: pageChangedMsg{page: selected}}
}

An unknown Target is silently ignored.

Asking layout to move focus

return p, func() tea.Msg {
	return layout.RequestFocusMsg{Source: p.id, Target: "content"}
}

p.id is whatever the pane last learned from SizeMsg.ID. Only honored when Source is the pane that currently, genuinely holds focus - a blurred pane (say, reacting to a SendMsg while in the background) can't redirect focus this way, for itself or anyone else; only the pane actually focused right now can hand focus off to another. An unauthorized Source, or an unknown Target, is silently ignored.

A concrete pattern: a sidebar list drives and jumps to a content pane, purely through messages:

func (p sidebarPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
	prevIndex := p.list.Index()
	var cmd tea.Cmd
	p.list, cmd = p.list.Update(msg)

	if p.list.Index() != prevIndex {
		selected := p.list.SelectedItem().(page)
		cmd = tea.Batch(cmd,
			func() tea.Msg { return layout.SendMsg{Target: "content", Msg: pageChangedMsg{selected}} },
			func() tea.Msg { return layout.RequestFocusMsg{Source: p.id, Target: "content"} },
		)
	}
	return p, cmd
}

Reshaping the tree at runtime

m, cmd := m.SplitLeaf("editor", layout.Vertical, "terminal", newTerminalPane())
m, cmd = m.CloseLeaf("terminal")
m, cmd = m.Resize("main-split", 0.6)
m, cmd = m.SetPane("workspace", newSecondPagePane())
  • SplitLeaf(id, dir, newID, newModel, opts ...SplitOption) splits the leaf id into two: id keeps its original pane on one side, Leaf(newID, newModel) takes the other, joined by a 50/50 split by default. Override with WithSplitID, WithSplitRatio, WithSplitMinimum, WithSplitMaximum.
  • CloseLeaf(id) removes a leaf; its sibling takes the place of their parent split. Focus moves elsewhere automatically if id was focused. The tree's own last remaining leaf can't be closed this way.
  • Resize(splitID, ratio) changes a split's ratio. Only reachable if the split was given an id, via (*Node).WithID (or WithSplitID when it was created by SplitLeaf) - HSplit/VSplit leave it unaddressable ("") by default.
  • SetPane(id, newPane) swaps what's rendered at an existing leaf without touching the tree's shape - the way an app switches its content area between entirely different pages/sub-apps, each potentially its own package, as opposed to a pane updating its own internal state in response to a message. The new pane is Init'd and immediately told its size; it's told FocusMsg too if id currently holds focus, since whatever it's replacing never will be again.

A pane never holds a reference to the Model it lives in, so from inside a pane's own Update, use the message forms instead - SplitLeafMsg, CloseLeafMsg, ResizeMsg, SetPaneMsg:

return p, func() tea.Msg {
	return layout.SplitLeafMsg{ID: "editor", Dir: layout.Vertical, NewID: "terminal", NewModel: newTerminalPane()}
}

Composing bigger apps

A layout.Model is itself a Pane (and a Navigable, see below) - embed one inside another directly, no wrapper needed:

func newWorkspace() layout.Model {
	root := layout.VSplit(0.7,
		layout.Leaf("editor", newEditorPane()),
		layout.Leaf("terminal", newTerminalPane()),
	)
	return layout.New(root) // no AsRoot(): the outer Model already renders one help bar
}

root := layout.HSplit(0.25,
	layout.Leaf("sidebar", newSidebarPane()),
	layout.Leaf("workspace", newWorkspace()),
)
m := layout.New(root, layout.AsRoot())

Once embedded this way, everything works transparently:

  • ctrl+hjkl tries moving focus inside whatever's currently focused first; only once that reports being at its own edge does the level above move between its own direct children instead.
  • The help bar keeps showing exactly one bar, reflecting whatever's focused anywhere in the nesting.
  • SendMsg/RequestFocusMsg reach an id inside a nested tree automatically, without the outer tree needing to know it's there.
  • The nested tree's own shape stays its own business - nothing from outside reaches into it structurally, only messages cross that boundary.

This all works because layout.Model implements Navigable:

type Navigable interface {
	Pane
	Leaves() []LeafRect
	MoveFocus(dir FocusDirection) bool
	Route(target string, msg tea.Msg) (handled bool, cmd tea.Cmd)
	Focus(id string) (handled bool, cmd tea.Cmd)
	FocusedHelp() []key.Binding
}

A hand-rolled Pane never needs to implement this itself - it's what lets one layout.Model recognize another layout.Model sitting in one of its leaves and delegate to it, at arbitrary nesting depth. You'll only reach for it directly if you're building something that itself wants to compose with layout the same way layout composes with itself.

Examples

  • examples/layout/basic - a 2x2 grid, no custom border, the minimum to get started.
  • examples/layout/bordered - each pane draws its own border via layout.Bordered, following focus.
  • examples/layout/nested - a whole layout.Model embedded as a pane, ctrl+hjkl and the help bar both working transparently across the boundary.
  • examples/layout/messaging - a "control" pane driving and focusing an "editor" pane by id via SendMsg/RequestFocusMsg.
  • examples/layout/help - the help bar changing to match whatever's focused, including a pane that implements no bindings at all.