init layout, notifications, tabs & more

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-08-17 22:09:32 +02:00
parent f3a1877832
commit 5f99fa7521
68 changed files with 6375 additions and 330 deletions
+309
View File
@@ -0,0 +1,309 @@
# 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.
+64
View File
@@ -0,0 +1,64 @@
package layout
import "math"
// Rect is an axis-aligned screen region in terminal cells, origin top-left.
type Rect struct {
X, Y, W, H int
}
// LeafRect pairs a Leaf's id with the Rect it was allocated by the most
// recent layout pass.
type LeafRect struct {
ID string
Rect Rect
}
// computeLayout descends the tree rooted at n, allocating r between its
// leaves according to each Split's ratio/min/max, and returns a flat
// registry of every leaf's resolved Rect. Order is deterministic (a
// depth-first walk, first child before second), which is what makes it safe
// to use directly as a stable iteration order elsewhere (Init, routing).
func computeLayout(n *Node, r Rect) []LeafRect {
if n == nil {
return nil
}
if n.leaf {
return []LeafRect{{ID: n.id, Rect: r}}
}
var firstRect, secondRect Rect
if n.dir == Horizontal {
w1 := resolveSize(n, r.W)
firstRect = Rect{X: r.X, Y: r.Y, W: w1, H: r.H}
secondRect = Rect{X: r.X + w1, Y: r.Y, W: r.W - w1, H: r.H}
} else {
h1 := resolveSize(n, r.H)
firstRect = Rect{X: r.X, Y: r.Y, W: r.W, H: h1}
secondRect = Rect{X: r.X, Y: r.Y + h1, W: r.W, H: r.H - h1}
}
leaves := computeLayout(n.first, firstRect)
return append(leaves, computeLayout(n.second, secondRect)...)
}
// resolveSize returns the cell size a Split's first child gets out of total,
// starting from n.ratio and then clamped to [n.min, n.max] (0 on either side
// means that bound is unset). n.min == n.max fixes the size outright,
// regardless of ratio.
func resolveSize(n *Node, total int) int {
size := int(math.Round(n.ratio * float64(total)))
if n.min > 0 && size < n.min {
size = n.min
}
if n.max > 0 && size > n.max {
size = n.max
}
if size < 0 {
size = 0
}
if size > total {
size = total
}
return size
}
+89
View File
@@ -0,0 +1,89 @@
package layout
import "testing"
func TestComputeLayoutEvenSplit(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
leaves := computeLayout(root, Rect{W: 100, H: 40})
want := map[string]Rect{
"a": {X: 0, Y: 0, W: 50, H: 40},
"b": {X: 50, Y: 0, W: 50, H: 40},
}
assertLeafRects(t, leaves, want)
}
func TestComputeLayoutVerticalRatio(t *testing.T) {
root := VSplit(0.25, Leaf("top", newStub()), Leaf("bottom", newStub()))
leaves := computeLayout(root, Rect{W: 80, H: 40})
want := map[string]Rect{
"top": {X: 0, Y: 0, W: 80, H: 10},
"bottom": {X: 0, Y: 10, W: 80, H: 30},
}
assertLeafRects(t, leaves, want)
}
func TestComputeLayoutMinimum(t *testing.T) {
root := HSplit(0.1, Leaf("a", newStub()), Leaf("b", newStub())).WithMinimum(20)
leaves := computeLayout(root, Rect{W: 100, H: 10})
want := map[string]Rect{
"a": {X: 0, Y: 0, W: 20, H: 10},
"b": {X: 20, Y: 0, W: 80, H: 10},
}
assertLeafRects(t, leaves, want)
}
func TestComputeLayoutMaximum(t *testing.T) {
root := HSplit(0.9, Leaf("a", newStub()), Leaf("b", newStub())).WithMaximum(20)
leaves := computeLayout(root, Rect{W: 100, H: 10})
want := map[string]Rect{
"a": {X: 0, Y: 0, W: 20, H: 10},
"b": {X: 20, Y: 0, W: 80, H: 10},
}
assertLeafRects(t, leaves, want)
}
func TestComputeLayoutFixedWhenMinEqualsMax(t *testing.T) {
root := HSplit(0.9, Leaf("a", newStub()), Leaf("b", newStub())).WithMinimum(20).WithMaximum(20)
leaves := computeLayout(root, Rect{W: 100, H: 10})
want := map[string]Rect{
"a": {X: 0, Y: 0, W: 20, H: 10},
"b": {X: 20, Y: 0, W: 80, H: 10},
}
assertLeafRects(t, leaves, want)
}
func TestComputeLayoutNested(t *testing.T) {
root := HSplit(0.3,
Leaf("sidebar", newStub()),
VSplit(0.5, Leaf("top", newStub()), Leaf("bottom", newStub())),
)
leaves := computeLayout(root, Rect{W: 100, H: 20})
want := map[string]Rect{
"sidebar": {X: 0, Y: 0, W: 30, H: 20},
"top": {X: 30, Y: 0, W: 70, H: 10},
"bottom": {X: 30, Y: 10, W: 70, H: 10},
}
assertLeafRects(t, leaves, want)
}
func assertLeafRects(t *testing.T, leaves []LeafRect, want map[string]Rect) {
t.Helper()
if len(leaves) != len(want) {
t.Fatalf("got %d leaves, want %d (%v)", len(leaves), len(want), leaves)
}
for _, lr := range leaves {
wr, ok := want[lr.ID]
if !ok {
t.Fatalf("unexpected leaf %q", lr.ID)
}
if lr.Rect != wr {
t.Errorf("leaf %q: got %+v, want %+v", lr.ID, lr.Rect, wr)
}
}
}
+130
View File
@@ -0,0 +1,130 @@
package layout
import (
"charm.land/bubbles/v2/key"
"charm.land/lipgloss/v2"
)
// HelpProvider is how a pane opts into the help bar: implement it and
// return whatever bindings you want shown while you're focused. A pane
// that doesn't implement it just contributes nothing - the help bar still
// shows layout's own controls (ctrl+hjkl, ?), it never disappears entirely.
type HelpProvider interface {
HelpBindings() []key.Binding
}
// HelpBindings implements HelpProvider by delegating to FocusedHelp, so a
// Model embedded as a HelpProvider behaves identically to one consulted as
// a Navigable.
func (m Model) HelpBindings() []key.Binding {
return m.FocusedHelp()
}
// FocusedHelp implements Navigable: the bindings for whatever pane
// currently has focus, drilling into a nested Navigable automatically until
// it reaches the real pane at the bottom. Returns nil if the focused pane
// (at any depth) implements neither Navigable nor HelpProvider.
func (m Model) FocusedHelp() []key.Binding {
focused, ok := findNode(m.root, m.state.id)
if !ok {
return nil
}
if nav, ok := focused.model.(Navigable); ok {
return nav.FocusedHelp()
}
if hp, ok := focused.model.(HelpProvider); ok {
return hp.HelpBindings()
}
return nil
}
// helpKeyMap adapts a focused pane's flat HelpProvider bindings plus
// layout's own KeyMap into the shape bubbles/help.KeyMap expects.
type helpKeyMap struct {
pane []key.Binding
own KeyMap
width int
}
// ShortHelp implements help.KeyMap. own leads (its ToggleHelp is always
// first, see KeyMap.ShortHelp), the focused pane's own bindings follow.
func (h helpKeyMap) ShortHelp() []key.Binding {
return append(append([]key.Binding{}, h.own.ShortHelp()...), h.pane...)
}
// FullHelp implements help.KeyMap. Rather than the fixed grouping ShortHelp
// mirrors, it flattens every binding into one ordered list - own first, so
// ToggleHelp lands in the first column's first row, then the pane's - and
// re-flows it into as many columns as fit within width, maximizing columns
// to minimize the number of rows the full help view takes.
func (h helpKeyMap) FullHelp() [][]key.Binding {
all := append(append([]key.Binding{}, flattenGroups(h.own.FullHelp())...), h.pane...)
return flowColumns(all, h.width)
}
func flattenGroups(groups [][]key.Binding) []key.Binding {
var flat []key.Binding
for _, g := range groups {
flat = append(flat, g...)
}
return flat
}
// flowColumns arranges bindings into as many columns as fit within width
// without overflowing. It fills each column top-to-bottom before moving to
// the next, which is what bubbles/help.FullHelpView expects: one inner
// slice per column, rendered as a vertical stack.
func flowColumns(bindings []key.Binding, width int) [][]key.Binding {
enabled := make([]key.Binding, 0, len(bindings))
for _, kb := range bindings {
if kb.Enabled() {
enabled = append(enabled, kb)
}
}
if len(enabled) == 0 {
return nil
}
if width <= 0 {
return [][]key.Binding{enabled}
}
for rows := 1; rows <= len(enabled); rows++ {
groups := chunkRows(enabled, rows)
if columnsWidth(groups) <= width {
return groups
}
}
return [][]key.Binding{enabled}
}
// chunkRows splits bindings into groups of at most rows items each, filling
// each group before moving to the next - the column-major order help's
// FullHelpView renders (first group is the leftmost column).
func chunkRows(bindings []key.Binding, rows int) [][]key.Binding {
var groups [][]key.Binding
for i := 0; i < len(bindings); i += rows {
end := min(i+rows, len(bindings))
groups = append(groups, bindings[i:end])
}
return groups
}
// columnsWidth mirrors bubbles/help.FullHelpView's own width accounting:
// each column is as wide as its longest key plus a space plus its longest
// description, columns separated by FullSeparator's width (4 cells, " ").
func columnsWidth(groups [][]key.Binding) int {
const separatorWidth = 4
total := 0
for i, group := range groups {
if i > 0 {
total += separatorWidth
}
var keyWidth, descWidth int
for _, kb := range group {
keyWidth = max(keyWidth, lipgloss.Width(kb.Help().Key))
descWidth = max(descWidth, lipgloss.Width(kb.Help().Desc))
}
total += keyWidth + 1 + descWidth
}
return total
}
+49
View File
@@ -0,0 +1,49 @@
package layout
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
)
// stubPane is a minimal Pane used across the test suite: it records every
// message it receives (and counts Focus/Blur specifically) and can be told
// to return a fixed cmd on its next Update, so tests can assert on both
// sides of the layout <-> pane contract without a real component.
type stubPane struct {
focusN, blurN int
w, h int
msgs []tea.Msg
nextCmd tea.Cmd
help []key.Binding
}
func newStub() *stubPane { return &stubPane{} }
func (p *stubPane) Init() tea.Cmd { return nil }
func (p *stubPane) Update(msg tea.Msg) (Pane, tea.Cmd) {
p.msgs = append(p.msgs, msg)
switch m := msg.(type) {
case FocusMsg:
p.focusN++
case BlurMsg:
p.blurN++
case SizeMsg:
p.w, p.h = m.Width, m.Height
}
cmd := p.nextCmd
p.nextCmd = nil
return p, cmd
}
func (p *stubPane) View() string { return "" }
// HelpBindings implements HelpProvider.
func (p *stubPane) HelpBindings() []key.Binding { return p.help }
func (p *stubPane) last() tea.Msg {
if len(p.msgs) == 0 {
return nil
}
return p.msgs[len(p.msgs)-1]
}
+77
View File
@@ -0,0 +1,77 @@
package layout
import "charm.land/bubbles/v2/key"
// KeyMap holds the bindings Model itself reacts to: directional focus
// movement and toggling the help bar. A pane's own bindings are separate
// (see HelpProvider) - these are only the ones layout intercepts before a
// key ever reaches the focused pane.
type KeyMap struct {
FocusLeft key.Binding
FocusRight key.Binding
FocusUp key.Binding
FocusDown key.Binding
ToggleHelp key.Binding
// ShowFocusInShortHelp also lists ctrl+hjkl on the short help line, not
// just the full one. Off by default (see DefaultKeyMap): the four
// bindings crowd a single line for little gain, since they're always
// one '?' away in the full view regardless. Set it on a KeyMap passed
// to WithKeyMap to opt back in.
ShowFocusInShortHelp bool
}
// DefaultKeyMap returns the standard tmux/vim-style bindings: ctrl+h/j/k/l
// to move focus, ? to toggle the help bar.
func DefaultKeyMap() KeyMap {
return KeyMap{
FocusLeft: key.NewBinding(
key.WithKeys("ctrl+h"),
key.WithHelp("ctrl+h", "focus left"),
),
FocusRight: key.NewBinding(
key.WithKeys("ctrl+l"),
key.WithHelp("ctrl+l", "focus right"),
),
FocusUp: key.NewBinding(
key.WithKeys("ctrl+k"),
key.WithHelp("ctrl+k", "focus up"),
),
FocusDown: key.NewBinding(
key.WithKeys("ctrl+j"),
key.WithHelp("ctrl+j", "focus down"),
),
ToggleHelp: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
}
}
// focusBindings returns the four directional bindings in reading order.
func (k KeyMap) focusBindings() []key.Binding {
return []key.Binding{k.FocusLeft, k.FocusDown, k.FocusUp, k.FocusRight}
}
// ShortHelp implements help.KeyMap so KeyMap can be fed to bubbles/help
// directly for layout's own controls. ToggleHelp always leads - see
// helpKeyMap.ShortHelp, which relies on that to put '?' first in the
// composed bar too.
func (k KeyMap) ShortHelp() []key.Binding {
bindings := []key.Binding{k.ToggleHelp}
if k.ShowFocusInShortHelp {
bindings = append(bindings, k.focusBindings()...)
}
return bindings
}
// FullHelp implements help.KeyMap. ToggleHelp always leads, same reasoning
// as ShortHelp; the focus bindings show here unconditionally, regardless of
// ShowFocusInShortHelp.
func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.ToggleHelp},
{k.FocusLeft, k.FocusRight},
{k.FocusUp, k.FocusDown},
}
}
+542
View File
@@ -0,0 +1,542 @@
// Package layout arranges Pane content in a binary split tree (BSP,
// tmux/i3-style), with spatial ctrl+hjkl focus navigation, message routing
// between panes by id, and a help bar that always reflects whatever pane is
// currently focused, however deep it's nested. It owns geometry, focus and
// routing only - it draws no border and imposes no style: each pane decides
// how to render itself for the size and focus state it's given (see SizeMsg,
// FocusMsg, BlurMsg).
package layout
import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/style"
)
// Pane is a leaf's content. It's the same Init/Update/View shape used by
// every other custom component in this repo (see tabs.Tab): distinct from
// the real tea.Model, whose View returns tea.View rather than string -
// that's the top level's job (see Run), not a nested pane's.
type Pane interface {
Init() tea.Cmd
Update(tea.Msg) (Pane, tea.Cmd)
View() string
}
// Navigable is what makes a Model composable: embed one layout.Model inside
// another (Leaf(id, innerModel)) and it works transparently, because
// layout.Model itself implements Navigable. ctrl+hjkl first tries
// MoveFocus on whatever's currently focused; SendMsg/RequestFocusMsg reach
// into nested trees via Route/Focus; the help bar drills in via
// FocusedHelp. A pane that isn't itself a layout.Model just doesn't
// implement this, and is treated as an ordinary leaf everywhere.
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
}
// focusState holds the pieces of Model's state that Navigable's MoveFocus
// and Focus must be able to mutate despite having value receivers - a
// requirement of Model being usable by value as a Leaf's tea.Model and
// still satisfying Navigable when type-asserted back out of that interface.
// Boxed behind a pointer so the mutation persists across every copy of
// Model that shares it.
type focusState struct {
id string
// pendingCmd queues cmds produced by BlurMsg/FocusMsg dispatch that
// happened inside MoveFocus, which - being bool-only, per Navigable -
// has no return path for them. Drained by the nearest Update that
// actually returns a tea.Cmd; delivery lags by at most one Update
// cycle, never user-visible in practice.
pendingCmd tea.Cmd
}
// Model is a running layout: a Node tree, focus, sizing, and (if AsRoot)
// the help bar. Build one with New.
type Model struct {
root *Node
state *focusState
leaves []LeafRect
width, height int
keyMap KeyMap
help help.Model
showHelp bool
asRoot bool
}
// Option configures a Model at construction. See AsRoot, WithKeyMap.
type Option func(*Model)
// AsRoot marks this Model as the outermost one: only a root Model renders
// its own help bar in View. Off by default, so an embedded Model (see
// Navigable) never shows a duplicate bar - only pass this to the Model
// actually handed to Run/tea.NewProgram.
func AsRoot() Option {
return func(m *Model) { m.asRoot = true }
}
// WithKeyMap overrides the default ctrl+hjkl/? bindings.
func WithKeyMap(k KeyMap) Option {
return func(m *Model) { m.keyMap = k }
}
// New builds a Model from root. The first leaf (depth-first, first child
// before second) starts focused.
func New(root *Node, opts ...Option) Model {
m := Model{
root: root,
state: &focusState{id: firstLeafID(root)},
keyMap: DefaultKeyMap(),
help: bubbles.NewHelp(),
}
for _, opt := range opts {
opt(&m)
}
return m
}
// program adapts a Model (a Pane, like any other layout leaf content) into
// a real tea.Model for tea.NewProgram: the only place a Model's View needs
// to become a tea.View instead of a string (see Pane's doc comment).
type program struct{ m Model }
func (p program) Init() tea.Cmd { return p.m.Init() }
func (p program) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
updated, cmd := p.m.Update(msg)
p.m = updated.(Model)
return p, cmd
}
func (p program) View() tea.View {
view := tea.NewView(p.m.View())
view.AltScreen = true
return view
}
// Run builds and starts a tea.Program for m (which should have been
// constructed with AsRoot). A convenience for the common standalone-binary
// case; an app assembling layout into a bigger tea.Program of its own can
// wrap m the same way program does above instead.
func Run(m Model, opts ...tea.ProgramOption) error {
_, err := tea.NewProgram(program{m: m}, opts...).Run()
return err
}
func firstLeafID(n *Node) string {
for n != nil && !n.leaf {
n = n.first
}
if n == nil {
return ""
}
return n.id
}
// walk visits every leaf in the tree rooted at n, depth-first, first child
// before second - the same order computeLayout produces, so it's safe to
// rely on for anything that should stay in step with the flat registry.
func walk(n *Node, fn func(*Node)) {
if n == nil {
return
}
if n.leaf {
fn(n)
return
}
walk(n.first, fn)
walk(n.second, fn)
}
func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd
walk(m.root, func(n *Node) {
if cmd := n.model.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
})
// Only the actual root originates the initial FocusMsg. An embedded
// Model's own state.id already defaults to its first leaf (see New),
// but it must stay quiet about it until its parent actually focuses the
// leaf hosting it - which happens naturally through the ordinary
// FocusMsg/BlurMsg case in Update, cascading down as deep as needed.
// Without this guard, every nested Model fires its own initial
// FocusMsg independently, so a leaf that isn't even the outer tree's
// initial focus still shows as focused until the first real move.
if m.asRoot {
if n, ok := findNode(m.root, m.state.id); ok {
updated, cmd := n.model.Update(FocusMsg{})
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
}
return tea.Batch(cmds...)
}
func (m Model) Update(msg tea.Msg) (Pane, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m.resize()
case SizeMsg:
// A nested Model receiving its own allocation from a parent
// layout.Model - equivalent to tea.WindowSizeMsg at the root.
m.width, m.height = msg.Width, msg.Height
return m.resize()
case FocusMsg, BlurMsg:
// This whole (sub)tree just gained/lost focus at the parent's
// level: redistribute to whichever of our own leaves is focused,
// not to the tree "globally" (see Navigable doc).
return m, m.deliverToFocused(msg)
case SendMsg:
_, cmd := m.Route(msg.Target, msg.Msg)
return m, cmd
case RequestFocusMsg:
return m, m.handleRequestFocus(msg)
case SetPaneMsg:
return m.SetPane(msg.ID, msg.NewPane)
case SplitLeafMsg:
return m.SplitLeaf(msg.ID, msg.Dir, msg.NewID, msg.NewModel, msg.Opts...)
case CloseLeafMsg:
return m.CloseLeaf(msg.ID)
case ResizeMsg:
return m.Resize(msg.SplitID, msg.Ratio)
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.keyMap.ToggleHelp):
m.showHelp = !m.showHelp
m.help.ShowAll = m.showHelp
return m.resize()
case key.Matches(msg, m.keyMap.FocusLeft):
m.MoveFocus(FocusLeft)
return m, m.drainCmd()
case key.Matches(msg, m.keyMap.FocusRight):
m.MoveFocus(FocusRight)
return m, m.drainCmd()
case key.Matches(msg, m.keyMap.FocusUp):
m.MoveFocus(FocusUp)
return m, m.drainCmd()
case key.Matches(msg, m.keyMap.FocusDown):
m.MoveFocus(FocusDown)
return m, m.drainCmd()
default:
return m, m.deliverToFocused(msg)
}
default:
return m, m.broadcast(msg)
}
}
func (m Model) drainCmd() tea.Cmd {
cmd := m.state.pendingCmd
m.state.pendingCmd = nil
return cmd
}
// deliverToFocused sends msg to the currently focused leaf's model only.
// Used for ordinary key presses (only the focused pane should react to
// keyboard input) and for relaying FocusMsg/BlurMsg into a nested subtree.
func (m Model) deliverToFocused(msg tea.Msg) tea.Cmd {
n, ok := findNode(m.root, m.state.id)
if !ok {
return nil
}
updated, cmd := n.model.Update(msg)
n.model = updated
return cmd
}
// broadcast sends msg to every leaf's model, focused or not - the default
// for anything that isn't one of layout's own reserved message types or a
// key press, so a blurred pane can still receive its own async messages
// (a tick driving a spinner, an HTTP response, ...).
func (m Model) broadcast(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
walk(m.root, func(n *Node) {
updated, cmd := n.model.Update(msg)
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
})
return tea.Batch(cmds...)
}
// setFocus moves focus to id (assumed already validated as an existing
// leaf), dispatching BlurMsg to the old focus and FocusMsg to the new one,
// and returns the resulting batched cmd. A no-op (nil cmd) if id is already
// focused.
func (m Model) setFocus(id string) tea.Cmd {
if id == m.state.id {
return nil
}
var cmds []tea.Cmd
if old, ok := findNode(m.root, m.state.id); ok {
updated, cmd := old.model.Update(BlurMsg{})
old.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
m.state.id = id
if n, ok := findNode(m.root, id); ok {
updated, cmd := n.model.Update(FocusMsg{})
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
// SplitLeaf splits the leaf identified by id into two: id keeps its
// original pane on one side, a new Leaf(newID, newModel) takes the other,
// joined by a 50/50 Split (override via WithSplitRatio, WithSplitID,
// WithSplitMinimum, WithSplitMaximum). A no-op (m unchanged, nil cmd) if id
// doesn't identify an existing leaf.
func (m Model) SplitLeaf(id string, dir Direction, newID string, newModel Pane, opts ...SplitOption) (Model, tea.Cmd) {
newRoot, ok := splitLeaf(m.root, id, dir, newID, newModel, opts...)
if !ok {
return m, nil
}
m.root = newRoot
var cmds []tea.Cmd
if cmd := newModel.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
resized, cmd := m.resize()
if cmd != nil {
cmds = append(cmds, cmd)
}
return resized, tea.Batch(cmds...)
}
// SetPane replaces the Pane at leaf id with newPane, keeping its place and
// shape in the tree unchanged - unlike SplitLeaf/CloseLeaf, which reshape
// the tree, this only swaps what's rendered at an existing slot (the way an
// app switches its content area between entirely different sub-apps/pages,
// each its own package). newPane is Init'd and immediately told its size
// via SizeMsg (using id's current Rect, which by definition hasn't changed);
// it's also told FocusMsg if id currently holds focus, since the pane it's
// replacing never will. A no-op if id doesn't identify an existing leaf.
func (m Model) SetPane(id string, newPane Pane) (Model, tea.Cmd) {
n, ok := findNode(m.root, id)
if !ok || !n.leaf {
return m, nil
}
n.model = newPane
var cmds []tea.Cmd
if cmd := newPane.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
if lr, ok := m.leafRect(id); ok {
updated, cmd := n.model.Update(SizeMsg{ID: id, Width: lr.Rect.W, Height: lr.Rect.H})
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
if m.state.id == id {
updated, cmd := n.model.Update(FocusMsg{})
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
// CloseLeaf removes the leaf identified by id, promoting its sibling to take
// the place of their parent Split. If id currently has focus, focus moves to
// the tree's new first leaf. A no-op if id is the tree's own root (the last
// remaining pane can't be closed this way) or doesn't exist.
func (m Model) CloseLeaf(id string) (Model, tea.Cmd) {
newRoot, ok := closeLeaf(m.root, id)
if !ok {
return m, nil
}
m.root = newRoot
var focusCmd tea.Cmd
if m.state.id == id {
focusCmd = m.setFocus(firstLeafID(m.root))
}
resized, resizeCmd := m.resize()
return resized, tea.Batch(focusCmd, resizeCmd)
}
// Resize sets the ratio of the first child of the Split identified by
// splitID (only reachable if it was given one, via (*Node).WithID or
// WithSplitID). A no-op if splitID isn't found or identifies a Leaf.
func (m Model) Resize(splitID string, ratio float64) (Model, tea.Cmd) {
n, ok := findNode(m.root, splitID)
if !ok || n.leaf {
return m, nil
}
n.ratio = ratio
return m.resize()
}
// resize recomputes the flat leaf registry from the current root/width/
// height and dispatches SizeMsg to every leaf whose Rect actually changed
// (not just the ones directly touched by whatever triggered this - a
// sibling's size can shift too). Shared by every path that can change
// geometry: tea.WindowSizeMsg, SizeMsg (nested), SplitLeaf, CloseLeaf,
// Resize, and toggling the help bar (which changes how much height the tree
// itself gets).
func (m Model) resize() (Model, tea.Cmd) {
if m.width <= 0 || m.height <= 0 {
return m, nil
}
m.help.SetWidth(m.width)
rect := m.treeRect()
newLeaves := computeLayout(m.root, rect)
old := make(map[string]Rect, len(m.leaves))
for _, lr := range m.leaves {
old[lr.ID] = lr.Rect
}
var cmds []tea.Cmd
for _, lr := range newLeaves {
if prev, ok := old[lr.ID]; ok && prev == lr.Rect {
continue
}
if n, ok := findNode(m.root, lr.ID); ok {
updated, cmd := n.model.Update(SizeMsg{ID: lr.ID, Width: lr.Rect.W, Height: lr.Rect.H})
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
}
m.leaves = newLeaves
return m, tea.Batch(cmds...)
}
// treeRect is the region left for the split tree once the help bar (if
// AsRoot) has taken its share of the height. resize and View both go
// through this so they can never disagree about where the tree ends and
// the help bar begins.
func (m Model) treeRect() Rect {
h := m.height - m.helpHeight()
if h < 0 {
h = 0
}
return Rect{W: m.width, H: h}
}
func (m Model) helpHeight() int {
if !m.asRoot {
return 0
}
if rendered := m.renderHelp(); rendered != "" {
return lipgloss.Height(rendered)
}
return 0
}
func (m Model) renderHelp() string {
return m.help.View(helpKeyMap{pane: m.FocusedHelp(), own: m.keyMap, width: m.width})
}
// Leaves implements Navigable.
func (m Model) Leaves() []LeafRect {
return m.leaves
}
func (m Model) View() string {
if m.width <= 0 || m.height <= 0 {
return ""
}
rect := m.treeRect()
tree := renderNode(m.root, rect.W, rect.H)
if !m.asRoot {
return tree
}
help := m.renderHelp()
if help == "" {
return tree
}
return lipgloss.JoinVertical(lipgloss.Left, tree, help)
}
// renderNode mirrors computeLayout's own allocation (same resolveSize calls
// on the same w/h at each level), so what's rendered here always matches
// the SizeMsg values leaves were already told via resize.
func renderNode(n *Node, w, h int) string {
if n.leaf {
// A misbehaving Pane that renders wider/taller than the SizeMsg it
// was given would otherwise desync every ancestor Join*, so clip it
// here rather than trusting the contract to hold. MaxWidth/MaxHeight
// truncate via ansi.Truncate internally, so this stays escape-code
// safe instead of mangling a Pane's own styling mid-sequence.
return lipgloss.NewStyle().MaxWidth(w).MaxHeight(h).Render(n.model.View())
}
if n.dir == Horizontal {
w1 := resolveSize(n, w)
return lipgloss.JoinHorizontal(lipgloss.Top,
renderNode(n.first, w1, h),
renderNode(n.second, w-w1, h),
)
}
h1 := resolveSize(n, h)
return lipgloss.JoinVertical(lipgloss.Left,
renderNode(n.first, w, h1),
renderNode(n.second, w, h-h1),
)
}
// Bordered is an optional helper for panes that want the common look: a
// border that follows focus (style.S.Primary focused, style.S.Subtle
// blurred) drawn with the configured BorderType. Not required - a pane
// that wants something else, or nothing, just doesn't call this. Renders
// to exactly w by h, border included, as View() must (see FocusMsg/BlurMsg
// and SizeMsg docs).
func Bordered(focused bool, w, h int, content string) string {
color := style.S.Subtle
if focused {
color = style.S.Primary
}
// lipgloss's Width/Height already count the border as part of the box
// (they subtract its size internally before sizing the content), so w
// and h go straight through - no manual -2 here, or the box comes out
// two cells smaller than asked in both dimensions.
return lipgloss.NewStyle().
Border(style.S.BorderType).
BorderForeground(color).
Width(max(w, 0)).
Height(max(h, 0)).
Render(content)
}
+277
View File
@@ -0,0 +1,277 @@
package layout
import (
"testing"
tea "charm.land/bubbletea/v2"
)
func newTestModel(t *testing.T, root *Node) (Model, map[string]*stubPane) {
t.Helper()
panes := map[string]*stubPane{}
walk(root, func(n *Node) {
if s, ok := n.model.(*stubPane); ok {
panes[n.id] = s
}
})
m := New(root, AsRoot())
m.Init()
return m, panes
}
func TestInitFocusesFirstLeaf(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
if m.state.id != "a" {
t.Fatalf("focusedID = %q, want %q", m.state.id, "a")
}
if panes["a"].focusN != 1 {
t.Fatalf("a.focusN = %d, want 1", panes["a"].focusN)
}
if panes["b"].focusN != 0 {
t.Fatalf("b.focusN = %d, want 0", panes["b"].focusN)
}
}
func TestWindowSizeDispatchesSizeMsg(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
// newTestModel builds with AsRoot(), so the tree gets 40 minus the
// 1-row help bar - see treeRect.
if panes["a"].w != 50 || panes["a"].h != 39 {
t.Fatalf("a size = %dx%d, want 50x39", panes["a"].w, panes["a"].h)
}
if panes["b"].w != 50 || panes["b"].h != 39 {
t.Fatalf("b size = %dx%d, want 50x39", panes["b"].w, panes["b"].h)
}
if got := len(m.Leaves()); got != 2 {
t.Fatalf("len(Leaves()) = %d, want 2", got)
}
}
func TestCtrlLMovesFocusAndDispatchesBlurFocus(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
updated, _ = m.Update(tea.KeyPressMsg{Code: 'l', Mod: tea.ModCtrl, Text: ""})
m = updated.(Model)
if m.state.id != "b" {
t.Fatalf("focusedID = %q, want %q", m.state.id, "b")
}
if panes["a"].blurN != 1 {
t.Fatalf("a.blurN = %d, want 1", panes["a"].blurN)
}
if panes["b"].focusN != 1 {
t.Fatalf("b.focusN = %d, want 1", panes["b"].focusN)
}
}
func TestKeyPressOnlyReachesFocusedPane(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
updated, _ = m.Update(tea.KeyPressMsg{Text: "x"})
_ = updated.(Model)
if _, ok := panes["a"].last().(tea.KeyPressMsg); !ok {
t.Fatalf("focused pane a should have received the key press, got %#v", panes["a"].last())
}
if _, ok := panes["b"].last().(tea.KeyPressMsg); ok {
t.Fatalf("blurred pane b should not have received the key press")
}
}
func TestSplitLeafAddsAndSizesNewPane(t *testing.T) {
root := Leaf("a", newStub())
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
newPane := newStub()
m, _ = m.SplitLeaf("a", Horizontal, "b", newPane)
if got := len(m.Leaves()); got != 2 {
t.Fatalf("len(Leaves()) = %d, want 2", got)
}
if newPane.w == 0 || newPane.h == 0 {
t.Fatalf("new pane never received a SizeMsg: w=%d h=%d", newPane.w, newPane.h)
}
if panes["a"].w != 50 {
t.Fatalf("a.w = %d, want 50 after 50/50 split", panes["a"].w)
}
}
func TestSetPaneSwapsContentKeepingShape(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
// "a" is focused (first leaf).
replacement := newStub()
m, _ = m.SetPane("a", replacement)
if got := len(m.Leaves()); got != 2 {
t.Fatalf("len(Leaves()) = %d, want 2 (SetPane must not reshape the tree)", got)
}
if replacement.w != 50 || replacement.h != 39 {
t.Fatalf("replacement size = %dx%d, want 50x39 (a's existing Rect)", replacement.w, replacement.h)
}
if replacement.focusN != 1 {
t.Fatalf("replacement.focusN = %d, want 1: \"a\" currently holds focus", replacement.focusN)
}
if panes["b"].w != 50 {
t.Fatalf("b.w = %d, want unchanged 50", panes["b"].w)
}
}
func TestSetPaneOnBlurredLeafDoesNotFocus(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, _ := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
// "b" is blurred (only "a" is focused initially).
replacement := newStub()
m, _ = m.SetPane("b", replacement)
if replacement.focusN != 0 {
t.Fatalf("replacement.focusN = %d, want 0: \"b\" isn't focused", replacement.focusN)
}
if replacement.w != 50 || replacement.h != 39 {
t.Fatalf("replacement size = %dx%d, want 50x39", replacement.w, replacement.h)
}
}
func TestSetPaneUnknownIDIsNoop(t *testing.T) {
root := Leaf("a", newStub())
m, _ := newTestModel(t, root)
m2, cmd := m.SetPane("nope", newStub())
if cmd != nil {
t.Fatalf("expected nil cmd for an unknown SetPane id, got %v", cmd)
}
if len(m2.Leaves()) != len(m.Leaves()) {
t.Fatalf("tree changed after a no-op SetPane")
}
}
func TestCloseLeafReassignsFocus(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, _ := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
// focus is on "a"; closing it must move focus to what remains.
m, _ = m.CloseLeaf("a")
if got := len(m.Leaves()); got != 1 {
t.Fatalf("len(Leaves()) = %d, want 1", got)
}
if m.state.id != "b" {
t.Fatalf("focusedID = %q, want %q after closing the focused leaf", m.state.id, "b")
}
}
func TestCloseLeafRootIsNoop(t *testing.T) {
root := Leaf("only", newStub())
m, _ := newTestModel(t, root)
m2, cmd := m.CloseLeaf("only")
if cmd != nil {
t.Fatalf("expected nil cmd closing the tree's only leaf, got %v", cmd)
}
if got := len(m2.Leaves()); got != len(m.Leaves()) {
t.Fatalf("tree changed after a no-op close")
}
}
func TestResizeChangesRatio(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub())).WithID("split")
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
m, _ = m.Resize("split", 0.8)
if panes["a"].w != 80 {
t.Fatalf("a.w = %d, want 80 after Resize to 0.8", panes["a"].w)
}
if panes["b"].w != 20 {
t.Fatalf("b.w = %d, want 20 after Resize to 0.8", panes["b"].w)
}
}
func TestSendMsgDeliversToTarget(t *testing.T) {
type payload struct{ n int }
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
// a is focused via Init's initial FocusMsg; b never received anything yet.
updated, _ := m.Update(SendMsg{Target: "b", Msg: payload{n: 42}})
m = updated.(Model)
if got, ok := panes["b"].last().(payload); !ok || got.n != 42 {
t.Fatalf("b should have received payload{42}, got %#v", panes["b"].last())
}
for _, msg := range panes["a"].msgs {
if _, ok := msg.(payload); ok {
t.Fatalf("a should not have received the SendMsg meant for b")
}
}
}
func TestSendMsgUnknownTargetIsIgnored(t *testing.T) {
root := Leaf("a", newStub())
m, _ := newTestModel(t, root)
updated, cmd := m.Update(SendMsg{Target: "nope", Msg: struct{}{}})
_ = updated.(Model)
if cmd != nil {
t.Fatalf("expected nil cmd for an unknown SendMsg target, got %v", cmd)
}
}
func TestRequestFocusFromFocusedPaneSucceeds(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
updated, _ = m.Update(RequestFocusMsg{Source: "a", Target: "b"})
m = updated.(Model)
if m.state.id != "b" {
t.Fatalf("focusedID = %q, want %q", m.state.id, "b")
}
if panes["b"].focusN != 1 {
t.Fatalf("b.focusN = %d, want 1", panes["b"].focusN)
}
}
func TestRequestFocusFromBlurredPaneIsIgnored(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
third := Leaf("c", newStub())
_ = third
m, _ := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
// "a" is focused; "b" (blurred) tries to redirect focus to itself.
updated, _ = m.Update(RequestFocusMsg{Source: "b", Target: "b"})
m = updated.(Model)
if m.state.id != "a" {
t.Fatalf("focusedID = %q, want %q (unauthorized request must be ignored)", m.state.id, "a")
}
}
+80
View File
@@ -0,0 +1,80 @@
package layout
import tea "charm.land/bubbletea/v2"
// SizeMsg tells a pane the exact width/height it's been allocated, and its
// own id (so it can identify itself as the Source of a later
// RequestFocusMsg). Sent to every leaf whose Rect changed, whenever the tree
// is resized, split, closed or reshaped - never assume a pane can deduce its
// size any other way.
type SizeMsg struct {
ID string
Width, Height int
}
// FocusMsg tells a pane it just gained keyboard focus. Named distinctly from
// bubbletea's own tea.FocusMsg (which is about terminal focus, not pane
// focus) to avoid any confusion between the two.
type FocusMsg struct{}
// BlurMsg tells a pane it just lost keyboard focus. See FocusMsg.
type BlurMsg struct{}
// SendMsg delivers Msg to the pane identified by Target, wherever it lives
// in the tree (including inside a nested layout.Model), regardless of which
// pane currently has focus. A pane never holds a reference to another, so
// this is how they talk: return
//
// func() tea.Msg { return layout.SendMsg{Target: "editor", Msg: myMsg{}} }
//
// as a tea.Cmd from Update. An unknown Target is silently ignored.
type SendMsg struct {
Target string
Msg tea.Msg
}
// RequestFocusMsg asks the layout to move keyboard focus to Target. Only
// honored when Source is the id of the pane that currently holds focus (a
// blurred pane can send other panes messages via SendMsg, but can't move
// focus itself or on anyone's behalf) - fill Source from the ID a pane
// learned via SizeMsg:
//
// func() tea.Msg { return layout.RequestFocusMsg{Source: p.id, Target: "content"} }
//
// A mismatched Source, or an unknown Target, is silently ignored.
type RequestFocusMsg struct {
Source string
Target string
}
// SetPaneMsg is the message form of Model.SetPane, for a pane that wants to
// swap another leaf's content without holding a reference to its Model
// (which it never does).
type SetPaneMsg struct {
ID string
NewPane Pane
}
// SplitLeafMsg is the message form of Model.SplitLeaf, for a pane that wants
// to reshape the tree without holding a reference to its Model (which it
// never does). See Model.SplitLeaf for parameters.
type SplitLeafMsg struct {
ID string
Dir Direction
NewID string
NewModel Pane
Opts []SplitOption
}
// CloseLeafMsg is the message form of Model.CloseLeaf.
type CloseLeafMsg struct {
ID string
}
// ResizeMsg is the message form of Model.Resize: sets the ratio of the
// first child of the Split identified by SplitID (a Split only reachable by
// id if it was given one via WithID or WithSplitID).
type ResizeMsg struct {
SplitID string
Ratio float64
}
+151
View File
@@ -0,0 +1,151 @@
package layout
import tea "charm.land/bubbletea/v2"
// FocusDirection is a screen-space direction for moving focus between
// panes: ctrl+h/j/k/l. Distinct from Direction (a Split's axis) because a
// split only ever has two sides, while focus needs to move one of four
// ways.
type FocusDirection int
const (
FocusLeft FocusDirection = iota
FocusRight
FocusUp
FocusDown
)
// FindNeighbor picks the leaf, among leaves, that's geometrically closest to
// from in dir - tmux's select-pane -L/-D/-U/-R, not a tree walk. A
// candidate must be strictly positioned beyond from's edge in dir and share
// some extent with it on the perpendicular axis. Ranked by, in order: edge
// gap (smaller wins), then shared perpendicular extent (larger wins - the
// real tie-breaker between two equally-close neighbors), then
// center-to-center distance on the perpendicular axis (last resort). Pure
// and independent of Model so it's testable on its own.
func FindNeighbor(leaves []LeafRect, from LeafRect, dir FocusDirection) (id string, ok bool) {
type candidate struct {
id string
gap, overlap, cross int
}
var best *candidate
for _, lr := range leaves {
if lr.ID == from.ID {
continue
}
gap, overlap, cross, ok := edgeScore(from.Rect, lr.Rect, dir)
if !ok {
continue
}
c := candidate{id: lr.ID, gap: gap, overlap: overlap, cross: cross}
if best == nil ||
c.gap < best.gap ||
(c.gap == best.gap && c.overlap > best.overlap) ||
(c.gap == best.gap && c.overlap == best.overlap && c.cross < best.cross) {
best = &c
}
}
if best == nil {
return "", false
}
return best.id, true
}
// edgeScore returns the ranking tuple used by FindNeighbor for a single
// (from, other) pair, and ok=false if other isn't a valid candidate in dir
// at all (wrong side, or zero overlap on the perpendicular axis).
func edgeScore(from, other Rect, dir FocusDirection) (gap, overlap, cross int, ok bool) {
switch dir {
case FocusLeft:
if other.X+other.W > from.X {
return 0, 0, 0, false
}
gap = from.X - (other.X + other.W)
overlap = spanOverlap(from.Y, from.Y+from.H, other.Y, other.Y+other.H)
cross = abs((from.Y + from.H/2) - (other.Y + other.H/2))
case FocusRight:
if other.X < from.X+from.W {
return 0, 0, 0, false
}
gap = other.X - (from.X + from.W)
overlap = spanOverlap(from.Y, from.Y+from.H, other.Y, other.Y+other.H)
cross = abs((from.Y + from.H/2) - (other.Y + other.H/2))
case FocusUp:
if other.Y+other.H > from.Y {
return 0, 0, 0, false
}
gap = from.Y - (other.Y + other.H)
overlap = spanOverlap(from.X, from.X+from.W, other.X, other.X+other.W)
cross = abs((from.X + from.W/2) - (other.X + other.W/2))
case FocusDown:
if other.Y < from.Y+from.H {
return 0, 0, 0, false
}
gap = other.Y - (from.Y + from.H)
overlap = spanOverlap(from.X, from.X+from.W, other.X, other.X+other.W)
cross = abs((from.X + from.W/2) - (other.X + other.W/2))
}
if overlap <= 0 {
return 0, 0, 0, false
}
return gap, overlap, cross, true
}
// spanOverlap returns the length shared by [aStart,aEnd) and [bStart,bEnd).
func spanOverlap(aStart, aEnd, bStart, bEnd int) int {
start := max(aStart, bStart)
end := min(aEnd, bEnd)
if end <= start {
return 0
}
return end - start
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// leafRect looks up id's current Rect in this Model's own flat registry.
func (m Model) leafRect(id string) (LeafRect, bool) {
for _, lr := range m.leaves {
if lr.ID == id {
return lr, true
}
}
return LeafRect{}, false
}
// MoveFocus implements Navigable. If the currently focused leaf holds a
// nested Navigable (an embedded layout.Model), it's given first refusal -
// only once it reports being at its own edge (false) does this Model try
// moving focus among its own direct children instead. Cmds produced by the
// BlurMsg/FocusMsg this triggers can't be returned directly (Navigable's
// signature is bool-only); they're queued on m.state.pendingCmd instead
// (see focusState) and drained by the next Update that actually returns a
// tea.Cmd - a lag of at most one Update cycle, never user-visible.
func (m Model) MoveFocus(dir FocusDirection) bool {
if focused, ok := findNode(m.root, m.state.id); ok {
if nav, ok := focused.model.(Navigable); ok {
if nav.MoveFocus(dir) {
focused.model = nav
return true
}
}
}
from, ok := m.leafRect(m.state.id)
if !ok {
return false
}
id, ok := FindNeighbor(m.leaves, from, dir)
if !ok {
return false
}
m.state.pendingCmd = tea.Batch(m.state.pendingCmd, m.setFocus(id))
return true
}
+75
View File
@@ -0,0 +1,75 @@
package layout
import "testing"
func TestFindNeighborBasicDirections(t *testing.T) {
leaves := []LeafRect{
{ID: "left", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
{ID: "right", Rect: Rect{X: 10, Y: 0, W: 10, H: 10}},
}
if id, ok := FindNeighbor(leaves, leaves[0], FocusRight); !ok || id != "right" {
t.Fatalf("FocusRight from left: got (%q, %v), want (right, true)", id, ok)
}
if id, ok := FindNeighbor(leaves, leaves[1], FocusLeft); !ok || id != "left" {
t.Fatalf("FocusLeft from right: got (%q, %v), want (left, true)", id, ok)
}
if _, ok := FindNeighbor(leaves, leaves[0], FocusLeft); ok {
t.Fatalf("FocusLeft from left (edge): expected no neighbor")
}
}
// Two candidates at the exact same gap: the one that actually shares more
// of from's edge should win, not an arbitrary pick. This is the overlap
// tie-break, the one that matters most for a real tmux/i3-style layout
// (picking the pane that's genuinely alongside you, not just "a" neighbor).
func TestFindNeighborPrefersLargerOverlapOnEqualGap(t *testing.T) {
from := LeafRect{ID: "from", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}}
leaves := []LeafRect{
from,
{ID: "full", Rect: Rect{X: 10, Y: 0, W: 10, H: 10}},
{ID: "partial", Rect: Rect{X: 10, Y: 5, W: 10, H: 10}},
}
id, ok := FindNeighbor(leaves, from, FocusRight)
if !ok || id != "full" {
t.Fatalf("got (%q, %v), want (full, true): equal gap, full's overlap (10) beats partial's (5)", id, ok)
}
}
// When gap AND overlap are tied, center-to-center distance on the
// perpendicular axis is the last resort.
func TestFindNeighborCrossIsLastResortTiebreak(t *testing.T) {
from := LeafRect{ID: "from", Rect: Rect{X: 0, Y: 10, W: 10, H: 10}} // Y 10..20, center 15
leaves := []LeafRect{
from,
{ID: "near", Rect: Rect{X: 10, Y: 8, W: 10, H: 20}}, // Y 8..28, overlap 10, center 18
{ID: "far", Rect: Rect{X: 10, Y: 0, W: 10, H: 20}}, // Y 0..20, overlap 10, center 10
}
id, ok := FindNeighbor(leaves, from, FocusRight)
if !ok || id != "near" {
t.Fatalf("got (%q, %v), want (near, true): equal gap and overlap, near's center is closer (3 vs 5)", id, ok)
}
}
func TestFindNeighborNoCandidateBeyondEdge(t *testing.T) {
leaves := []LeafRect{
{ID: "only", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
}
if _, ok := FindNeighbor(leaves, leaves[0], FocusDown); ok {
t.Fatal("expected no neighbor with a single leaf")
}
}
func TestFindNeighborRequiresPerpendicularOverlap(t *testing.T) {
leaves := []LeafRect{
{ID: "from", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
// Directly to the right on the X axis, but no shared Y extent at
// all: not a valid candidate even though it's the only one there.
{ID: "diagonal", Rect: Rect{X: 10, Y: 10, W: 10, H: 10}},
}
if _, ok := FindNeighbor(leaves, leaves[0], FocusRight); ok {
t.Fatal("expected no neighbor: candidate shares no Y extent with from")
}
}
+210
View File
@@ -0,0 +1,210 @@
package layout
// Direction is the axis a Split divides its space along.
type Direction int
const (
// Horizontal places the first child on the left, the second on the right.
Horizontal Direction = iota
// Vertical stacks the first child on top of the second.
Vertical
)
// Node is one slot in the layout tree: either a Leaf (holding a Pane) or a
// Split (dividing its space between two child Nodes).
// Build a tree with Leaf and Split/HSplit/VSplit, then hand the root to New.
//
// Node is pointer-based on purpose: panes need a stable identity across
// arbitrarily nested splits, so there's no flat index to keep in sync the
// way a slice-based component would.
type Node struct {
id string // "" means unaddressable; always non-empty for a Leaf
leaf bool
model Pane // set when leaf
dir Direction // set when !leaf
ratio float64 // proportion of space given to first; set when !leaf
min, max int // clamp on first's resolved cell size, in cells; 0 = unset
first, second *Node // set when !leaf
}
// Leaf wraps a single pane. id must be non-empty and unique within the tree
// it ends up in: it's how the pane is targeted later by SendMsg,
// RequestFocusMsg, SplitLeaf, CloseLeaf and Resize.
func Leaf(id string, model Pane) *Node {
return &Node{id: id, leaf: true, model: model}
}
// Split divides its space between first and second along dir, giving ratio
// (0 to 1) of it to first and the rest to second. id may be "" if the split
// itself never needs to be addressed by Resize; it plays no role in pane
// addressing (only Leaf ids do).
func Split(id string, dir Direction, ratio float64, first, second *Node) *Node {
return &Node{id: id, dir: dir, ratio: ratio, first: first, second: second}
}
// HSplit is Split with Horizontal and no id: layout.HSplit(0.3, left, right).
func HSplit(ratio float64, first, second *Node) *Node {
return Split("", Horizontal, ratio, first, second)
}
// VSplit is Split with Vertical and no id: layout.VSplit(0.3, top, bottom).
func VSplit(ratio float64, first, second *Node) *Node {
return Split("", Vertical, ratio, first, second)
}
// WithID sets the id used to address this node later (currently only
// meaningful on a Split, for Resize; a Leaf already gets its id from Leaf).
func (n *Node) WithID(id string) *Node {
n.id = id
return n
}
// WithMinimum clamps first's resolved size to never go below cells. No-op on
// a Leaf, which has no size of its own to constrain.
func (n *Node) WithMinimum(cells int) *Node {
if !n.leaf {
n.min = cells
}
return n
}
// WithMaximum clamps first's resolved size to never exceed cells. No-op on a
// Leaf. Setting both WithMinimum and WithMaximum to the same value fixes
// first's size regardless of ratio.
func (n *Node) WithMaximum(cells int) *Node {
if !n.leaf {
n.max = cells
}
return n
}
// findNode returns the node with the given id anywhere in the tree rooted at
// n, without mutating anything. id == "" never matches (it's the
// unaddressable sentinel, and several Splits may share it).
func findNode(n *Node, id string) (*Node, bool) {
if n == nil || id == "" {
return nil, false
}
if n.id == id {
return n, true
}
if n.leaf {
return nil, false
}
if found, ok := findNode(n.first, id); ok {
return found, true
}
return findNode(n.second, id)
}
// splitConfig collects SplitOption values applied by SplitLeaf.
type splitConfig struct {
id string
ratio float64
}
// SplitOption configures the Split node SplitLeaf creates in place of the
// leaf it splits.
type SplitOption func(*Node, *splitConfig)
// WithSplitID addresses the split SplitLeaf creates, so it can later be
// targeted by Resize.
func WithSplitID(id string) SplitOption {
return func(_ *Node, c *splitConfig) { c.id = id }
}
// WithSplitRatio overrides SplitLeaf's default 50/50 split.
func WithSplitRatio(ratio float64) SplitOption {
return func(_ *Node, c *splitConfig) { c.ratio = ratio }
}
// WithSplitMinimum clamps the size of the original (pre-split) leaf's side
// of the new split. Equivalent to calling (*Node).WithMinimum on the split
// SplitLeaf produces.
func WithSplitMinimum(cells int) SplitOption {
return func(n *Node, _ *splitConfig) { n.WithMinimum(cells) }
}
// WithSplitMaximum clamps the size of the original (pre-split) leaf's side
// of the new split. Equivalent to calling (*Node).WithMaximum on the split
// SplitLeaf produces.
func WithSplitMaximum(cells int) SplitOption {
return func(n *Node, _ *splitConfig) { n.WithMaximum(cells) }
}
// splitLeaf replaces the leaf identified by id with a Split holding the
// original leaf as first and a new Leaf(newID, newModel) as second. Returns
// the (possibly new) tree root and whether id was found and was a leaf.
func splitLeaf(root *Node, id string, dir Direction, newID string, newModel Pane, opts ...SplitOption) (*Node, bool) {
target, ok := findNode(root, id)
if !ok || !target.leaf {
return root, false
}
cfg := splitConfig{ratio: 0.5}
split := Split("", dir, 0.5, target, Leaf(newID, newModel))
for _, opt := range opts {
opt(split, &cfg)
}
split.id = cfg.id
split.ratio = cfg.ratio
newRoot, _ := replaceNode(root, id, func(*Node) *Node { return split })
return newRoot, true
}
// replaceNode returns a copy of the tree rooted at n with the node
// identified by id swapped for transform's result. Only the path down to
// that node is cloned, everything else is shared. Returns n unchanged and
// false if id isn't found.
func replaceNode(n *Node, id string, transform func(*Node) *Node) (*Node, bool) {
if n == nil || id == "" {
return n, false
}
if n.id == id {
return transform(n), true
}
if n.leaf {
return n, false
}
if newFirst, ok := replaceNode(n.first, id, transform); ok {
clone := *n
clone.first = newFirst
return &clone, true
}
if newSecond, ok := replaceNode(n.second, id, transform); ok {
clone := *n
clone.second = newSecond
return &clone, true
}
return n, false
}
// closeLeaf removes the leaf identified by id, promoting its sibling to take
// the place of their parent Split. Returns the (possibly new) tree root and
// whether id was found as a direct child of some Split (the tree's own root
// leaf, with no parent, can never be closed this way).
func closeLeaf(root *Node, id string) (*Node, bool) {
if root == nil || root.leaf || id == "" {
return root, false
}
if root.first.leaf && root.first.id == id {
return root.second, true
}
if root.second.leaf && root.second.id == id {
return root.first, true
}
if newFirst, ok := closeLeaf(root.first, id); ok {
clone := *root
clone.first = newFirst
return &clone, true
}
if newSecond, ok := closeLeaf(root.second, id); ok {
clone := *root
clone.second = newSecond
return &clone, true
}
return root, false
}
+112
View File
@@ -0,0 +1,112 @@
package layout
import tea "charm.land/bubbletea/v2"
// Route implements Navigable: attempts to deliver msg to the leaf
// identified by target. Checks this Model's own direct leaves first, then
// recurses into any leaf whose model is itself Navigable (an embedded
// layout.Model), depth-first. Returns handled=false without touching
// anything if target isn't found anywhere in this (sub)tree - the caller
// (Model.Update, or a parent Model's own Route) is responsible for treating
// that as "silently ignore."
func (m Model) Route(target string, msg tea.Msg) (bool, tea.Cmd) {
if n, ok := findNode(m.root, target); ok && n.leaf {
updated, cmd := n.model.Update(msg)
n.model = updated
return true, cmd
}
var (
handled bool
cmd tea.Cmd
)
walk(m.root, func(n *Node) {
if handled {
return
}
if nav, ok := n.model.(Navigable); ok {
if h, c := nav.Route(target, msg); h {
n.model = nav
handled, cmd = true, c
}
}
})
return handled, cmd
}
// Focus implements Navigable: moves this (sub)tree's focus straight to id,
// wherever it is - a direct leaf, or nested inside a leaf's own Navigable.
// Unlike MoveFocus (one geometric step in a direction), this is "jump to
// this specific pane." When id lives inside a nested Navigable, that
// child's own internal focus is set first, and then this Model's own focus
// is brought to the leaf hosting it too, so the whole chain agrees on what's
// focused (required for FocusMsg/BlurMsg propagation and for MoveFocus's
// "ask the focused child first" rule to keep working afterward). That outer
// step re-notifies the leaf hosting the nested tree regardless of whether
// the nested Focus call already notified id directly - the two can't always
// be told apart cheaply (id might have already been that subtree's
// untouched default focus, which never got an initial FocusMsg at all, see
// Model.Init) - so id's pane may occasionally see FocusMsg twice for one
// real transition. Delivery is at-least-once, not exactly-once: a Pane
// should treat FocusMsg/BlurMsg as idempotent, the same way it would have
// to tolerate a redundant terminal focus event.
func (m Model) Focus(id string) (bool, tea.Cmd) {
if _, ok := m.leafRect(id); ok {
return true, m.setFocus(id)
}
var (
handled bool
innerCmd tea.Cmd
outerID string
)
walk(m.root, func(n *Node) {
if handled {
return
}
if nav, ok := n.model.(Navigable); ok {
if h, c := nav.Focus(id); h {
n.model = nav
handled, innerCmd, outerID = true, c, n.id
}
}
})
if !handled {
return false, nil
}
return true, tea.Batch(m.setFocus(outerID), innerCmd)
}
// sourceIsFocused reports whether id is the leaf currently focused
// somewhere along this (sub)tree's active focus chain: either this Model's
// own focused leaf, or - recursively - whatever's focused inside that leaf
// if it's itself a nested Navigable. Used to authorize RequestFocusMsg: only
// the pane that genuinely holds focus right now, at whatever depth, is
// allowed to redirect focus elsewhere. A blurred pane reaching this code
// (e.g. reacting to a SendMsg while in the background) is correctly refused
// since it can never appear on the active chain.
func (m Model) sourceIsFocused(id string) bool {
if m.state.id == id {
return true
}
focused, ok := findNode(m.root, m.state.id)
if !ok {
return false
}
checker, ok := focused.model.(interface{ sourceIsFocused(string) bool })
if !ok {
return false
}
return checker.sourceIsFocused(id)
}
// handleRequestFocus honors a RequestFocusMsg only once sourceIsFocused
// clears its Source, then resolves Target the same way Focus does. An
// unauthorized Source, or an unknown Target, is silently ignored.
func (m Model) handleRequestFocus(msg RequestFocusMsg) tea.Cmd {
if !m.sourceIsFocused(msg.Source) {
return nil
}
_, cmd := m.Focus(msg.Target)
return cmd
}
+150
View File
@@ -0,0 +1,150 @@
package layout
import (
"testing"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
)
// buildNested returns an outer Model with two direct leaves: "sidebar" and
// "inner-root", the latter being a nested layout.Model (itself split into
// "inner-a"/"inner-b") embedded as an ordinary Pane. Both models are sized
// before being handed back so their leaf registries are populated.
func buildNested(t *testing.T) (outer Model, sidebar *stubPane, innerA, innerB *stubPane) {
t.Helper()
sidebar = newStub()
innerA = newStub()
innerB = newStub()
// inner is deliberately built WITHOUT AsRoot(): it's embedded, so it
// must not act focused on its own until outer actually focuses the
// leaf that hosts it (see Model.Init's asRoot guard).
inner := New(HSplit(0.5, Leaf("inner-a", innerA), Leaf("inner-b", innerB)))
root := HSplit(0.3, Leaf("sidebar", sidebar), Leaf("inner-root", inner))
outer = New(root, AsRoot())
outer.Init()
updated, _ := outer.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
outer = updated.(Model)
return outer, sidebar, innerA, innerB
}
// Regression test: a nested Model used to fire its own initial FocusMsg to
// its first leaf unconditionally on Init, regardless of whether the outer
// tree's actual initial focus ever lands on the leaf hosting it - so a
// leaf buried in a subtree that isn't even initially focused would still
// show up as focused, alongside whatever the outer tree really focused.
func TestEmbeddedModelDoesNotSelfFocusOnInit(t *testing.T) {
outer, sidebar, innerA, innerB := buildNested(t)
if sidebar.focusN != 1 {
t.Fatalf("sidebar.focusN = %d, want 1 (it's the outer tree's real initial focus)", sidebar.focusN)
}
if innerA.focusN != 0 || innerB.focusN != 0 {
t.Fatalf("innerA.focusN=%d innerB.focusN=%d, want 0/0: the nested tree isn't focused yet", innerA.focusN, innerB.focusN)
}
if outer.state.id != "sidebar" {
t.Fatalf("outer focusedID = %q, want %q", outer.state.id, "sidebar")
}
}
func TestSendMsgReachesNestedLeaf(t *testing.T) {
type payload struct{ n int }
outer, _, innerA, _ := buildNested(t)
updated, _ := outer.Update(SendMsg{Target: "inner-a", Msg: payload{n: 7}})
_ = updated.(Model)
if got, ok := innerA.last().(payload); !ok || got.n != 7 {
t.Fatalf("inner-a should have received payload{7}, got %#v", innerA.last())
}
}
func TestFocusJumpsIntoNestedSubtreeAndUpdatesOuterFocus(t *testing.T) {
outer, _, _, innerB := buildNested(t)
// Outer's own focus starts on "sidebar" (first leaf, depth-first).
handled, _ := outer.Focus("inner-b")
if !handled {
t.Fatal("Focus(\"inner-b\") should have been handled")
}
if outer.state.id != "inner-root" {
t.Fatalf("outer focusedID = %q, want %q (the leaf hosting the nested tree)", outer.state.id, "inner-root")
}
// At-least-once, not exactly-once (see Focus's doc comment): the outer
// leaf's own re-notification can duplicate the nested tree's own
// dispatch, so only assert inner-b actually got notified, not a count.
if innerB.focusN < 1 {
t.Fatalf("inner-b.focusN = %d, want at least 1", innerB.focusN)
}
}
func TestRequestFocusAuthorizedThroughNestedChain(t *testing.T) {
outer, _, innerA, _ := buildNested(t)
// Move outer focus onto the nested subtree, and its own internal focus
// onto inner-a, so inner-a is genuinely the focused leaf end-to-end.
outer.Focus("inner-a")
if innerA.focusN != 1 {
t.Fatalf("inner-a.focusN = %d, want 1 before the request", innerA.focusN)
}
updated, _ := outer.Update(RequestFocusMsg{Source: "inner-a", Target: "sidebar"})
outer = updated.(Model)
if outer.state.id != "sidebar" {
t.Fatalf("focusedID = %q, want %q: inner-a is genuinely focused, its request should be honored", outer.state.id, "sidebar")
}
}
func TestRequestFocusFromNonFocusedNestedLeafIsIgnored(t *testing.T) {
outer, _, _, innerB := buildNested(t)
// Outer focus is on "sidebar"; the nested tree isn't even the focused
// branch, so nothing inside it - including inner-b - is authorized.
_ = innerB
updated, _ := outer.Update(RequestFocusMsg{Source: "inner-b", Target: "sidebar"})
outer = updated.(Model)
if outer.state.id != "sidebar" {
t.Fatalf("focusedID = %q, want unchanged %q", outer.state.id, "sidebar")
}
}
func TestMoveFocusDelegatesToNestedBeforeGeometry(t *testing.T) {
outer, _, _, innerB := buildNested(t)
outer.Focus("inner-a")
if ok := outer.MoveFocus(FocusRight); !ok {
t.Fatal("MoveFocus(FocusRight) should have been handled by the nested tree (inner-a -> inner-b)")
}
if innerB.focusN != 1 {
t.Fatalf("inner-b.focusN = %d, want 1 (moved within the nested tree)", innerB.focusN)
}
if outer.state.id != "inner-root" {
t.Fatalf("outer focusedID changed to %q, should have stayed on the nested leaf", outer.state.id)
}
// Now at inner-b, the nested tree's own rightmost leaf: pressing right
// again must bubble up and move the OUTER focus instead.
if ok := outer.MoveFocus(FocusRight); ok {
// buildNested's outer split is only sidebar|inner-root left-to-right,
// so there's nothing further right at the outer level either -
// MoveFocus should report false all the way up.
t.Fatalf("expected no further neighbor to the right at either level")
}
}
func TestHelpBindingsDelegatesThroughNesting(t *testing.T) {
outer, _, innerA, _ := buildNested(t)
outer.Focus("inner-a")
want := key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "do x"))
innerA.help = []key.Binding{want}
got := outer.HelpBindings()
if len(got) != 1 || got[0].Help().Key != "x" {
t.Fatalf("HelpBindings() = %#v, want the focused inner leaf's bindings", got)
}
}