Files
ilovetui/layout/README.md
T

310 lines
11 KiB
Markdown

# 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
`Node`s.
- **`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
```go
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
```go
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
```go
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:
```go
root := layout.HSplit(0.25,
sidebar,
layout.VSplit(0.7,
layout.HSplit(0.5, topLeft, topRight),
bottom,
),
)
```
### Sizing
```go
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:
```go
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`:
```go
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"):
```go
// 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
```go
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:
```go
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
```go
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`:
```go
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:
```go
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`:
```go
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.