mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 20:15:49 +02:00
init layout, notifications, tabs & more
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user