Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
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:
Paneis 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.Nodeis the shape of the tree.Leaf(id, pane)is a slot holding onePane, addressed everywhere else (SendMsg,RequestFocusMsg,SplitLeaf,CloseLeaf,Resize) by thatid.Split/HSplit/VSplitdivide space between two childNodes.Modelis the running layout: aNodetree plus focus, sizing and the help bar. Build one withlayout.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...).IDis the pane's own id, learned here so it can later fillRequestFocusMsg.Source(see below).FocusMsg{}/BlurMsg{}whenever the pane gains or loses keyboard focus. Named distinctly from bubbletea's owntea.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.layoutcomposes panes side by side withlipgloss.JoinHorizontal/JoinVertical- if a pane renders the wrong size, the whole layout visibly misaligns.- You own your own chrome.
layoutnever draws a border.layout.Bordered(focused, w, h, content)covers the common case (border color follows focus, viastyle.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 byideverywhere else in the API.layout.HSplit(ratio, first, second)/layout.VSplit(ratio, first, second)- divide space horizontally (side by side) or vertically (stacked), givingratio(0 to 1) tofirstand the rest tosecond.layout.Split(id, dir, ratio, first, second)is the general form if the split itself needs anid(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 leafidinto two:idkeeps its original pane on one side,Leaf(newID, newModel)takes the other, joined by a 50/50 split by default. Override withWithSplitID,WithSplitRatio,WithSplitMinimum,WithSplitMaximum.CloseLeaf(id)removes a leaf; its sibling takes the place of their parent split. Focus moves elsewhere automatically ifidwas 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(orWithSplitIDwhen it was created bySplitLeaf) -HSplit/VSplitleave 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 isInit'd and immediately told its size; it's toldFocusMsgtoo ifidcurrently 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+hjkltries 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/RequestFocusMsgreach 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 vialayout.Bordered, following focus.examples/layout/nested- a wholelayout.Modelembedded 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 viaSendMsg/RequestFocusMsg.examples/layout/help- the help bar changing to match whatever's focused, including a pane that implements no bindings at all.