Signed-off-by: Hadi <hadi@example.fr>
This commit is contained in:
Hadi
2026-08-18 16:15:03 +02:00
parent 5f99fa7521
commit aa6496901f
48 changed files with 2001 additions and 3626 deletions
+4 -3
View File
@@ -8,7 +8,7 @@ The idea is simple: instead of every TUI app managing its own colors, they all s
- `github.com/anotherhadi/ilovetui/style` — the theme itself: colors, pre-built panel styles, config loading.
- `github.com/anotherhadi/ilovetui/bubbles` — themed constructors for official `bubbles/v2` components (`help`, `textarea`, `textinput`, `list`, `table`, `filepicker`, `spinner`, `progress`, `paginator`, `viewport`).
- `github.com/anotherhadi/ilovetui/tabs`, `.../layout`, `.../modal`, `.../notification` — custom components not found in the official `bubbles` library, styled from the same theme. See each package's own README: [`tabs`](tabs/README.md), [`layout`](layout/README.md), [`modal`](modal/README.md), [`notification`](notification/README.md).
- `github.com/anotherhadi/ilovetui/tabs`, `.../helpbar`, `.../modal`, `.../drawer`, `.../notification` — custom components not found in the official `bubbles` library, styled from the same theme.
## How it works
@@ -127,8 +127,9 @@ t := tabs.New([]tabs.Item{{Title: "First", Model: firstPane}, {Title: "Second",
`tabs` renders a horizontal tab bar styled from `style.S`. The host application renders the content
below it; see [`tabs/README.md`](tabs/README.md) and `examples/tabs` for a full example.
For the other custom components, see their own README: [`layout`](layout/README.md) (BSP pane
layout with spatial focus navigation), [`modal`](modal/README.md) (centered popup dialogs) and
For the other custom components, see their own README: [`helpbar`](helpbar/README.md) (responsive
help bar that reflows into as many columns as fit), [`modal`](modal/README.md) (centered popup
dialogs), [`drawer`](drawer/README.md) (left/right sidebar panels, mirroring `modal`) and
[`notification`](notification/README.md) (toast notifications).
## Projects using ilovetui
+140
View File
@@ -0,0 +1,140 @@
# drawer
A full-height panel flush against the left or right edge of an already-rendered background, on top
of it dimmed - the sidebar/drawer equivalent of [`modal`](../modal/README.md), which this package
otherwise mirrors closely: same stack of panels triggered from anywhere via an exported `tea.Msg`
(`ShowMsg`/`Show`) rather than a direct reference to the `Model` that ends up rendering it, same
composite-over-an-already-rendered-string `Render`, no assumption about how the host builds that
string.
## Quick start
```go
import (
"github.com/anotherhadi/ilovetui/drawer"
)
type model struct {
d drawer.Model
width, height int
}
func newModel() model {
return model{d: drawer.New()}
}
func (m model) Init() tea.Cmd { return m.d.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "l" {
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
}
if msg.String() == "esc" && m.d.Open() {
return m, drawer.Close()
}
}
var cmd tea.Cmd
m.d, cmd = m.d.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
background := renderYourUI(m.width, m.height)
view := tea.NewView(m.d.Render(background))
view.AltScreen = true
return view
}
```
Any component in the same bubbletea program can trigger a drawer via `drawer.Show`, without
holding a reference to the `drawer.Model` that will actually render it - that `Model` just needs
to see every `tea.Msg` the program produces (i.e. get its `Update` called from the top-level
`Update`), same as any other child model.
## Content is a model
A drawer's body is a `tea.Model`, not a string. While a drawer is on top of the stack it gets
every message the `drawer.Model` receives, its `Init` runs when it opens, and its commands come
back out - so it can hold a file list, a filter form, or a picker that reports its choice with a
`tea.Msg` of its own, which the component that opened it listens for:
```go
type pickedMsg struct{ file string }
// somewhere else
return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
```
Only the topmost drawer is updated: everything beneath it is dimmed and frozen until the drawers
above it close.
For a drawer with nothing to interact with, `drawer.Text` wraps a plain string:
```go
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
```
The box shrinks to fit whatever the content draws (unless `WithWidth` fixes it), so a content
that wants a specific size sets it on itself - the drawer only ever sees the rendered result.
## Side and width
```go
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"),
drawer.WithSide(drawer.Right), drawer.WithWidth(24))
```
`WithSide` anchors the drawer to `drawer.Left` (the default) or `drawer.Right`. `WithWidth` fixes
the drawer's total width instead of shrinking to fit its content, still capped by whatever
actually fits the background - same unit as the `Model`-level `WithMaxWidth`. Either way, the
drawer always spans the background's full height, flush top to bottom.
## Showing and closing
```go
return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
return m, drawer.Close() // close the topmost drawer
```
The stack is a plain LIFO, with no identity: a drawer is closed by being on top, never by being
named. There is nothing to tag a drawer with, and nothing that can target one in the middle of the
stack - the topmost is both the only one that receives messages and the only one `Close` can
reach, so the two rules never disagree.
- `drawer.Open()` reports whether at least one drawer is currently shown - handy for a host that
wants to route key presses to the drawer instead of its normal UI while one is open. Note that a
host doing this also makes it impossible for a key to open a second drawer, which is what keeps
the stack shallow without any bookkeeping.
- A content model closes its own drawer by returning `drawer.Close()`, since it only ever runs
while it is the topmost one.
## Stacking
Drawers stack: showing a second one while the first is still open pushes it on top, dimming both
the background and the first drawer to the same flat color, same as `modal`. Opening a left drawer
and a right drawer at once still stacks - if you want both visible at full color simultaneously,
render them as two ordinary panels via `layout` instead; this package is for transient
sidebars, not permanent chrome.
## Styling
```go
d := drawer.New(drawer.WithMaxWidth(30), drawer.WithStyles(myStyles))
return m, drawer.Show("Nav", drawer.Text("content"), drawer.WithDrawerStyle(oneOffStyles))
```
`WithMaxWidth` caps how wide a drawer can grow (border and padding included) before wrapping; a
drawer narrower than the cap shrinks to fit its content instead of padding out to it, unless shown
with `WithWidth`. A drawer can also never overflow past the edge of whatever background it's
rendered on. `WithStyles` sets the default look for every drawer shown by this `Model`;
`WithDrawerStyle` (a `Show` option) overrides it for one drawer alone. `DefaultStyles()` builds
from `style.S`, same palette roles as `modal.DefaultStyles()`.
## Examples
- `examples/drawer` - a left nav drawer and a right inspector drawer, either one at a time.
+109
View File
@@ -0,0 +1,109 @@
package drawer
import tea "charm.land/bubbletea/v2"
// Side is which edge of the background a Drawer is anchored to.
type Side int
const (
// Left anchors the drawer to the left edge (the zero value, so a Drawer
// built without WithSide opens on the left).
Left Side = iota
// Right anchors the drawer to the right edge.
Right
)
// Drawer is one sidebar panel on the stack.
type Drawer struct {
Title string
// Content is the drawer's body: a full model, updated and rendered by
// the drawer.Model while it's on top of the stack. Wrap a plain string
// with Text for a drawer with nothing to interact with.
Content tea.Model
Side Side
// Width, if non-zero, fixes the drawer's total width (border and
// padding included, same unit as the Model's WithMaxWidth) instead of
// shrinking to fit what Content draws, and Title. Still capped by whatever actually
// fits the background.
Width int
// Style, if non-nil, overrides the Model's default Styles for this
// drawer alone.
Style *Styles
}
// DrawerOption configures a Drawer built by Show.
type DrawerOption func(*Drawer)
// WithSide anchors the drawer to the given Side (default Left).
func WithSide(s Side) DrawerOption {
return func(d *Drawer) { d.Side = s }
}
// WithWidth fixes the drawer's total width instead of shrinking to fit its
// content, still capped by whatever actually fits the background.
func WithWidth(w int) DrawerOption {
return func(d *Drawer) { d.Width = w }
}
// WithDrawerStyle overrides the Model's default Styles for this drawer
// alone, for a one-off custom look instead of the shared theme.
func WithDrawerStyle(s Styles) DrawerOption {
return func(d *Drawer) { d.Style = &s }
}
func newDrawer(title string, content tea.Model, opts ...DrawerOption) Drawer {
d := Drawer{Title: title, Content: content}
for _, opt := range opts {
opt(&d)
}
return d
}
// ShowMsg tells a drawer.Model to display Drawer, pushing it on top of the
// stack. Any component in the same bubbletea program can trigger one via
// Show, without holding a reference to the drawer.Model that will actually
// render it - that Model just needs to see every tea.Msg the program
// produces, same as any other child model.
type ShowMsg struct{ Drawer Drawer }
// Show returns a tea.Cmd that opens a new drawer on top of the stack,
// anchored to the left edge by default. content is a model, so a drawer can
// hold anything a pane can - a file list, a filter form, a picker that
// reports its choice with its own tea.Msg:
//
// return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
// return m, drawer.Show("Nav", drawer.Text("Home\nProjects"))
//
// The content's Init runs when the drawer opens, and it receives every
// message while it's the topmost drawer (see Model.Update).
func Show(title string, content tea.Model, opts ...DrawerOption) tea.Cmd {
d := newDrawer(title, content, opts...)
return func() tea.Msg { return ShowMsg{Drawer: d} }
}
// DismissMsg closes the topmost drawer. The stack is a plain LIFO: a drawer
// is closed by being on top, never by being named.
type DismissMsg struct{}
// Close returns a tea.Cmd that closes the topmost drawer - which is also the
// only one that can act (see Model.Update), so a content model closes itself
// by returning it:
//
// return c, drawer.Close()
func Close() tea.Cmd {
return func() tea.Msg { return DismissMsg{} }
}
// text is a model wrapping a fixed string: a drawer body with nothing to
// update.
type text string
func (t text) Init() tea.Cmd { return nil }
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
func (t text) View() tea.View { return tea.NewView(string(t)) }
// Text wraps a plain string as drawer content, for the common drawer that has
// nothing to interact with:
//
// drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
func Text(s string) tea.Model { return text(s) }
+114
View File
@@ -0,0 +1,114 @@
// Package drawer renders a full-height panel flush against the left or
// right edge of an already-rendered background, on top of it dimmed -
// the sidebar/drawer equivalent of github.com/anotherhadi/ilovetui/modal,
// which this package otherwise mirrors closely: same stack of panels
// triggered from anywhere via an exported tea.Msg (see ShowMsg/Show)
// rather than a direct reference to the Model that ends up rendering it,
// same composite-over-an-already-rendered-string Render, same absence of
// any assumption about how the host builds that string.
//
// A drawer's content is a model, not a string: it is updated while it's on
// top of the stack, so it can hold anything a pane can - a file list, a
// filter form, a picker reporting its choice back with its own tea.Msg. See
// Show and Text.
package drawer
import tea "charm.land/bubbletea/v2"
// Model holds the currently open drawers (a stack: the most recently shown
// is drawn on top, everything beneath it - the background and any earlier
// drawer - dimmed, see Render) and the rendering config (max width, styles)
// they share. Build one with New.
type Model struct {
drawers []Drawer
nextID int
maxWidth int
styles Styles
}
// Option configures a Model at construction. See WithMaxWidth, WithStyles.
type Option func(*Model)
// WithMaxWidth caps a drawer's total width (border and padding included). A
// drawer shown without WithWidth shrinks to fit its content instead of
// padding out to the cap; a drawer shown with WithWidth uses that width
// instead, still capped by this. 0 means only the background's own width
// caps it.
func WithMaxWidth(w int) Option {
return func(m *Model) { m.maxWidth = w }
}
// WithStyles overrides the default styles (see DefaultStyles).
func WithStyles(s Styles) Option {
return func(m *Model) { m.styles = s }
}
// New builds a Model. Defaults: a 30-column max width, DefaultStyles.
func New(opts ...Option) Model {
m := Model{
maxWidth: 30,
styles: DefaultStyles(),
}
for _, opt := range opts {
opt(&m)
}
return m
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case ShowMsg:
return m.show(msg.Drawer)
case DismissMsg:
return m.pop(), nil
}
return m.updateTop(msg)
}
// updateTop forwards msg to the topmost drawer's content - the only one the
// user can interact with, everything beneath it being dimmed (see Render). A
// drawer deeper in the stack is frozen until the ones above it close.
//
// This is what lets drawer content be a real model: it gets the key presses,
// the ticks and the results of its own commands, and can report back to the
// rest of the program with a tea.Msg of its own.
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
i := len(m.drawers) - 1
if i < 0 || m.drawers[i].Content == nil {
return m, nil
}
var cmd tea.Cmd
m.drawers[i].Content, cmd = m.drawers[i].Content.Update(msg)
return m, cmd
}
// Open reports whether at least one drawer is currently shown - handy for a
// host that wants to route key presses to the drawer (e.g. esc to dismiss)
// instead of its normal UI while one is open.
func (m Model) Open() bool { return len(m.drawers) > 0 }
// show pushes a drawer on top of the stack and returns its content's Init - a
// drawer's body starts the same way any other model does.
func (m Model) show(d Drawer) (Model, tea.Cmd) {
m.drawers = append(m.drawers, d)
return m, initContent(d)
}
// initContent is d's content's Init, or nil for a drawer without content.
func initContent(d Drawer) tea.Cmd {
if d.Content == nil {
return nil
}
return d.Content.Init()
}
// pop closes the topmost drawer (see Close).
func (m Model) pop() Model {
if len(m.drawers) == 0 {
return m
}
m.drawers = m.drawers[:len(m.drawers)-1]
return m
}
+155
View File
@@ -0,0 +1,155 @@
package drawer
import (
"image/color"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/anotherhadi/ilovetui/style"
)
// Render draws every open drawer (see Model.Update/Show) on top of
// background (already rendered, e.g. layout.Model.View() or any other
// component's View()) and returns the result. Each drawer in the stack
// first flattens whatever came before it - background plus any earlier
// drawer - to a single flat DimColor (see dim), then draws its own
// full-height box flush against its Side on top, so nesting a second
// drawer on top of a first dims the first one too. background is returned
// unchanged whenever there's nothing to draw.
func (m Model) Render(background string) string {
if len(m.drawers) == 0 {
return background
}
result := background
for _, d := range m.drawers {
w, h := lipgloss.Width(result), lipgloss.Height(result)
if w <= 0 || h <= 0 {
return result
}
result = m.renderOne(d, result, w, h)
}
return result
}
// View is a convenience for a pane whose sole purpose is showing drawers
// (e.g. a dedicated layout.Leaf): it draws the stack over a blank
// width x height area instead of an existing background.
func (m Model) View(width, height int) string {
return m.Render(blank(width, height))
}
// renderOne dims background flat and draws d's box, spanning its full
// height, flush against d.Side.
func (m Model) renderOne(d Drawer, background string, w, h int) string {
s := m.styles
if d.Style != nil {
s = *d.Style
}
box := m.renderBox(d, s, w, h)
bw := lipgloss.Width(box)
x := 0
if d.Side == Right {
x = max(w-bw, 0)
}
compositor := lipgloss.NewCompositor(
lipgloss.NewLayer(dim(background, s.DimColor)),
lipgloss.NewLayer(box).X(x).Y(0).Z(1),
)
return compositor.Render()
}
// dim flattens s to a single flat color: every existing style (colors,
// bold, underline...) is stripped, then every character - including
// whitespace, so highlighted/selected backgrounds vanish too - is
// repainted in c. Applying a Foreground style to a multi-line string styles
// each line independently (see lipgloss.Style.Render), so this keeps s's
// line structure intact.
func dim(s string, c color.Color) string {
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
}
// renderBox draws d as a bordered, title-embedded box (style.RenderWithTitle)
// spanning the background's full height, capped by the Model's configured
// max width and by whatever actually fits inside a bgW-wide background.
func (m Model) renderBox(d Drawer, s Styles, bgW, bgH int) string {
widthCap := m.maxWidth
if d.Width > 0 {
widthCap = d.Width
}
maxW := effectiveMax(widthCap, bgW)
body := contentView(d)
inner := contentWidth(d, body, maxW)
content := s.Content.Width(inner).Render(body)
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
return style.RenderWithTitle(s.Border, s.Title.Render(d.Title), content, boxWidth, bgH)
}
// contentView is the drawer body's rendered string, or "" for a drawer
// without content. The box shrinks to fit whatever the content model draws,
// so a content that wants a specific size sets it on itself - the drawer only
// ever sees the result.
func contentView(d Drawer) string {
if d.Content == nil {
return ""
}
return d.Content.View().Content
}
// contentWidth is the drawer's inner (border/padding excluded) width, given
// the resolved total-width cap maxWidth (see renderBox): maxWidth-4 if
// d.Width is set (a fixed total width), otherwise its natural size (long
// enough for the widest line of title/content), capped at maxWidth-4.
func contentWidth(d Drawer, body string, maxWidth int) int {
capped := max(maxWidth-4, 1)
if d.Width > 0 {
return capped
}
natural := max(naturalWidth(body), lipgloss.Width(d.Title), 1)
return min(natural, capped)
}
// naturalWidth is the width of content's widest line.
func naturalWidth(content string) int {
w := 0
for _, line := range strings.Split(content, "\n") {
if lw := lipgloss.Width(line); lw > w {
w = lw
}
}
return w
}
// effectiveMax resolves the cap actually used along the width axis:
// configured (0 = unlimited) narrowed down to fits, whatever actually fits
// the background - a drawer can never overflow past the edge of the
// background, or the terminal, when the background is a full-screen View(),
// regardless of how WithMaxWidth/WithWidth was set.
func effectiveMax(configured, fits int) int {
if fits < 1 {
fits = 1
}
if configured > 0 && configured < fits {
return configured
}
return fits
}
func blank(width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
line := strings.Repeat(" ", width)
lines := make([]string, height)
for i := range lines {
lines[i] = line
}
return strings.Join(lines, "\n")
}
+68
View File
@@ -0,0 +1,68 @@
package drawer
import (
"strings"
"testing"
"charm.land/lipgloss/v2"
)
func background(w, h int) string {
return blank(w, h)
}
func TestRenderLeftFlushToLeftEdge(t *testing.T) {
m := New(WithMaxWidth(10))
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"), WithSide(Left))})
out := m.Render(background(40, 10))
lines := strings.Split(out, "\n")
if len(lines) != 10 {
t.Fatalf("expected 10 lines, got %d", len(lines))
}
if lipgloss.Width(out) == 0 {
t.Fatalf("expected non-empty render")
}
// The border's top-left corner glyph should be the very first rune.
if len([]rune(lines[0])) == 0 {
t.Fatalf("expected a rendered top border line")
}
}
func TestRenderRightFlushToRightEdge(t *testing.T) {
m := New(WithMaxWidth(10))
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Inspector", Text("hi"), WithSide(Right))})
out := m.Render(background(40, 10))
if lipgloss.Width(out) != 40 {
t.Fatalf("expected full background width 40, got %d", lipgloss.Width(out))
}
}
func TestSpansFullBackgroundHeight(t *testing.T) {
m := New()
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"))})
out := m.Render(background(40, 12))
if lipgloss.Height(out) != 12 {
t.Fatalf("expected full height 12, got %d", lipgloss.Height(out))
}
}
func TestFixedWidthHonored(t *testing.T) {
m := New(WithMaxWidth(50))
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("x"), WithWidth(20))})
box := m.renderBox(m.drawers[0], m.styles, 80, 10)
if got := lipgloss.Width(box); got != 20 { // WithWidth is the total box width
t.Fatalf("expected fixed box width 20, got %d", got)
}
}
func TestNoDrawersReturnsBackgroundUnchanged(t *testing.T) {
m := New()
bg := background(10, 5)
if got := m.Render(bg); got != bg {
t.Fatalf("expected background unchanged when no drawer is open")
}
}
+37
View File
@@ -0,0 +1,37 @@
package drawer
import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// Styles is the set of lipgloss styles, plus the dim color, used to render a
// drawer and the background behind it. Border carries the box's border
// (shape + color, no size), Title and Content color the two pieces of text
// drawn inside it, DimColor is the single flat color every character of the
// background gets overwritten with while the drawer is open.
type Styles struct {
Border lipgloss.Style
Title lipgloss.Style
Content lipgloss.Style
DimColor color.Color
}
// DefaultStyles builds a Styles from style.S: the box borrows
// PanelFocused's border (the drawer is what has focus while it's open).
// DimColor reuses Subtle - the base16 "comments/invisibles" role, already
// used across this repo for de-emphasized text (borders, placeholders,
// separators, see bubbles/*.go and modal.DefaultStyles) - darker than
// Muted, which reads too bright once it's covering an entire screen instead
// of a single blurred field.
func DefaultStyles() Styles {
return Styles{
Border: style.S.PanelFocused.Padding(0, 1),
Title: lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary),
Content: lipgloss.NewStyle().Foreground(style.S.Text),
DimColor: style.S.Subtle,
}
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/style"
)
type model struct {
d drawer.Model
width, height int
}
func newModel() model {
return model{d: drawer.New()}
}
func (m model) Init() tea.Cmd { return m.d.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "l":
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"),
drawer.WithSide(drawer.Left), drawer.WithWidth(20))
case "r":
return m, drawer.Show("Inspector", drawer.Text("id: 42\nstatus: ok"),
drawer.WithSide(drawer.Right), drawer.WithWidth(20))
case "esc":
if m.d.Open() {
return m, drawer.Close()
}
}
}
var cmd tea.Cmd
m.d, cmd = m.d.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App")
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(
"Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the drawer.")
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"l: open left drawer r: open right drawer esc: close the top one q: quit")
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", body, "", help))
view := tea.NewView(m.d.Render(background))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+231
View File
@@ -0,0 +1,231 @@
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/examples/fullapp/metrics"
"github.com/anotherhadi/ilovetui/examples/fullapp/overview"
"github.com/anotherhadi/ilovetui/examples/fullapp/settings"
"github.com/anotherhadi/ilovetui/examples/fullapp/sidebar"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/ilovetui/style"
)
var pages = []struct {
item sidebar.NavItem
new func() tea.Model
}{
{sidebar.NavItem{Icon: "󰋜", Name: "Overview"}, func() tea.Model { return overview.New() }},
{sidebar.NavItem{Icon: "󰄨", Name: "Metrics"}, func() tea.Model { return metrics.New() }},
{sidebar.NavItem{Icon: "󰒓", Name: "Settings"}, func() tea.Model { return settings.New() }},
}
type HelpProvider interface {
HelpBindings() []key.Binding
}
func navItems() []sidebar.NavItem {
items := make([]sidebar.NavItem, len(pages))
for i, p := range pages {
items[i] = p.item
}
return items
}
type keyMap struct {
FocusSidebar key.Binding
Close key.Binding
Help key.Binding
Quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
FocusSidebar: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "focus sidebar")),
Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q / ctrl+c", "quit")),
}
}
type Model struct {
sidebar sidebar.Model
page tea.Model
help helpbar.Model
notif notification.Model
modal modal.Model
drawer drawer.Model
keys keyMap
sidebarWidth int
hideNav bool
contentFocused bool
width, height int
}
func NewModel() Model {
keys := defaultKeyMap()
return Model{
sidebar: sidebar.New(navItems()...),
sidebarWidth: 24,
page: pages[0].new(),
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.FocusSidebar, keys.Quit)),
notif: notification.New(),
modal: modal.New(),
drawer: drawer.New(),
keys: keys,
hideNav: true,
}
}
func (m Model) Init() tea.Cmd {
return m.page.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m.resize()
case sidebar.SelectMsg:
m.page = pages[msg.Index].new()
var cmd tea.Cmd
m, cmd = m.resize()
return m, tea.Batch(m.page.Init(), cmd)
case sidebar.BlurMsg:
m.contentFocused = true
return m.resize()
case tea.KeyPressMsg:
if m.modal.Open() {
if key.Matches(msg, m.keys.Close) {
return m, modal.Close()
}
var cmd tea.Cmd
m.modal, cmd = m.modal.Update(msg)
return m, cmd
}
if m.drawer.Open() {
if key.Matches(msg, m.keys.Close) {
return m, drawer.Close()
}
var cmd tea.Cmd
m.drawer, cmd = m.drawer.Update(msg)
return m, cmd
}
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.help, _ = m.help.Update(msg)
return m.resize()
case key.Matches(msg, m.keys.FocusSidebar):
m.contentFocused = !m.contentFocused
return m.resize()
}
if m.contentFocused {
var cmd tea.Cmd
m.page, cmd = m.page.Update(msg)
return m, cmd
}
var cmd tea.Cmd
m.sidebar, cmd = m.sidebar.Update(msg)
return m, cmd
}
var notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd tea.Cmd
m.notif, notifCmd = m.notif.Update(msg)
m.modal, modalCmd = m.modal.Update(msg)
m.drawer, drawerCmd = m.drawer.Update(msg)
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
m.page, pageCmd = m.page.Update(msg)
return m, tea.Batch(notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd)
}
func (m Model) navWidth() int {
if m.hideNav && m.contentFocused {
return 0
}
return m.sidebarWidth
}
func (m Model) resize() (Model, tea.Cmd) {
if m.width <= 0 || m.height <= 0 {
return m, nil
}
m.help.SetWidth(m.width)
inner := style.ContentHeight(m.height - m.help.Height(m.focusedHelp()...))
navW := m.navWidth()
m.sidebar.SetSize(max(navW-2, 0), inner)
var cmd tea.Cmd
m.page, cmd = m.page.Update(tea.WindowSizeMsg{
Width: max(m.width-navW-2, 0),
Height: inner,
})
return m, cmd
}
func (m Model) focusedHelp() []key.Binding {
if !m.contentFocused {
return m.sidebar.HelpBindings()
}
if hp, ok := m.page.(HelpProvider); ok {
return hp.HelpBindings()
}
return nil
}
func (m Model) View() tea.View {
if m.width <= 0 || m.height <= 0 {
return tea.NewView("")
}
helpBar := m.help.View(m.focusedHelp()...)
bodyH := max(m.height-lipgloss.Height(helpBar), 0)
navW := m.navWidth()
body := style.RenderWithTitle(
panel(m.contentFocused), m.sidebar.Selected().Name, m.page.View().Content, m.width-navW, bodyH)
if navW > 0 {
left := style.RenderWithTitle(
panel(!m.contentFocused), "Menu", m.sidebar.View(), navW, bodyH)
body = lipgloss.JoinHorizontal(lipgloss.Top, left, body)
}
appView := lipgloss.JoinVertical(lipgloss.Left,
body,
helpBar,
)
view := tea.NewView(m.notif.Render(m.modal.Render(m.drawer.Render(appView))))
view.AltScreen = true
return view
}
func panel(focused bool) lipgloss.Style {
if focused {
return style.S.PanelFocused
}
return style.S.Panel
}
func main() {
if _, err := tea.NewProgram(NewModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+81
View File
@@ -0,0 +1,81 @@
// Package metrics is the fullapp example's second page: a spinner and a few
// gauges. It has no keys either, but unlike overview it runs a command, so
// it's what proves the shell keeps feeding ticks to a pane that doesn't have
// focus.
package metrics
import (
"fmt"
"charm.land/bubbles/v2/progress"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/style"
)
// gaugeWidth is how wide a bar renders, before the label in front of it.
const gaugeWidth = 30
type gauge struct {
name string
percent float64
}
// Model is the page. Values are hardcoded - this is a layout test, not a
// monitoring tool.
type Model struct {
spinner spinner.Model
bar progress.Model
gauges []gauge
width, height int
}
func New() Model {
bar := bubbles.NewProgress()
bar.SetWidth(gaugeWidth)
return Model{
spinner: bubbles.NewSpinner(spinner.WithSpinner(spinner.MiniDot)),
bar: bar,
gauges: []gauge{
{"cpu", 0.42},
{"memory", 0.71},
{"disk", 0.13},
},
}
}
// Init starts the spinner ticking. The shell returns it from its own Init,
// or the spinner never starts.
func (m Model) Init() tea.Cmd { return m.spinner.Tick }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = size.Width, size.Height
return m, nil
}
// Every other message goes to the spinner, whose own tick keeps it
// turning - including while another pane has focus.
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m Model) View() tea.View {
rows := []string{style.S.Bold.Render(m.spinner.View() + " collecting")}
for _, g := range m.gauges {
// ViewAs renders a given percentage without animating toward it,
// which is all a fixed value needs.
rows = append(rows, fmt.Sprintf("%-8s %s", g.name, m.bar.ViewAs(g.percent)))
}
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
}
+45
View File
@@ -0,0 +1,45 @@
// Package overview is the fullapp example's first page: the simplest pane
// there is - no keys, no commands, just text sized to whatever room the
// shell gives it.
package overview
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// Model is the page. It's an ordinary tea.Model: nothing about it knows it
// lives in a shell, and it never implements HelpBindings because it has no
// keys of its own to advertise.
type Model struct {
width, height int
}
func New() Model { return Model{} }
func (m Model) Init() tea.Cmd { return nil }
// Update only tracks the size the shell hands down as a tea.WindowSizeMsg,
// same as if the page were the whole program.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = size.Width, size.Height
}
return m, nil
}
func (m Model) View() tea.View {
body := lipgloss.JoinVertical(lipgloss.Center,
style.S.Bold.Render("Overview"),
"",
style.S.Faint.Render("tab focuses this pane, but there's"),
style.S.Faint.Render("nothing here to focus on."),
)
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(body))
}
+102
View File
@@ -0,0 +1,102 @@
// Package settings is the fullapp example's third page: a short list of
// toggles. It's the one page with keys of its own, so it's what proves the
// shell routes presses to the focused pane and lists that pane's bindings in
// the help bar.
package settings
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
type toggle struct {
name string
on bool
}
type keyMap struct {
Up key.Binding
Down key.Binding
Toggle key.Binding
}
// Model is the page. The cursor is a plain int: three lines don't need a
// list.Model behind them.
type Model struct {
keys keyMap
toggles []toggle
cursor int
width, height int
}
func New() Model {
return Model{
keys: keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Toggle: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")),
},
toggles: []toggle{
{name: "Nerd fonts", on: style.S.NerdFonts},
{name: "Notifications", on: true},
{name: "Telemetry", on: false},
},
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
// The shell only sends these while this pane has focus, so there's
// no focused check to make here.
switch {
case key.Matches(msg, m.keys.Up):
m.cursor = max(m.cursor-1, 0)
case key.Matches(msg, m.keys.Down):
m.cursor = min(m.cursor+1, len(m.toggles)-1)
case key.Matches(msg, m.keys.Toggle):
m.toggles[m.cursor].on = !m.toggles[m.cursor].on
}
}
return m, nil
}
func (m Model) View() tea.View {
rows := make([]string, len(m.toggles))
for i, t := range m.toggles {
cursor, box := " ", "[ ]"
if i == m.cursor {
cursor = "> "
}
if t.on {
box = "[x]"
}
line := cursor + box + " " + t.name
if i == m.cursor {
line = lipgloss.NewStyle().Foreground(style.S.Primary).Render(line)
}
rows[i] = line
}
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
}
// HelpBindings makes the page's keys show up in the shell's help bar while
// it has focus. It's the optional half of the contract: overview and metrics
// have no keys and don't implement it.
func (m Model) HelpBindings() []key.Binding {
return []key.Binding{m.keys.Up, m.keys.Down, m.keys.Toggle}
}
+134
View File
@@ -0,0 +1,134 @@
package sidebar
import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
)
type NavItem struct {
Icon string
Name string
}
func (n NavItem) Title() string {
if n.Icon == "" {
return n.Name
}
return n.Icon + " " + n.Name
}
func (n NavItem) Description() string { return "" }
func (n NavItem) FilterValue() string { return n.Name }
type SelectMsg struct {
Index int
Item NavItem
}
// BlurMsg is the sidebar asking to be given up: it goes out with the
// SelectMsg, on the grounds that picking an entry means you're done with the
// menu. Where focus lands instead is the host's call - the sidebar has no
// idea what else is on screen.
type BlurMsg struct{}
// blur is BlurMsg's command form.
func blur() tea.Msg { return BlurMsg{} }
type KeyMap struct {
Select key.Binding
}
func DefaultKeyMap() KeyMap {
return KeyMap{
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
}
}
type Model struct {
KeyMap KeyMap
list list.Model
items []NavItem
selected int
}
func New(items ...NavItem) Model {
d := bubbles.NewDefaultDelegate()
d.ShowDescription = false
d.SetSpacing(0)
l := bubbles.NewList(listItems(items), 0, 0)
l.SetDelegate(d)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetShowPagination(false)
l.SetFilteringEnabled(false)
l.DisableQuitKeybindings()
return Model{
KeyMap: DefaultKeyMap(),
list: l,
items: items,
}
}
func listItems(items []NavItem) []list.Item {
out := make([]list.Item, len(items))
for i, item := range items {
out[i] = item
}
return out
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if press, ok := msg.(tea.KeyPressMsg); ok && key.Matches(press, m.KeyMap.Select) {
// Only the interactive path asks for focus to move on. Select on its
// own doesn't, because the host also calls it to set the starting
// entry - which must not steal focus from anything.
return m, tea.Batch(m.Select(m.list.Index()), blur)
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m *Model) Select(index int) tea.Cmd {
if index < 0 || index >= len(m.items) {
return nil
}
m.selected = index
m.list.Select(index)
item := m.items[index]
return func() tea.Msg { return SelectMsg{Index: index, Item: item} }
}
func (m *Model) SetSize(width, height int) { m.list.SetSize(width, height) }
func (m Model) Selected() NavItem {
if m.selected < 0 || m.selected >= len(m.items) {
return NavItem{}
}
return m.items[m.selected]
}
func (m Model) SelectedIndex() int { return m.selected }
func (m Model) Cursor() int { return m.list.Index() }
func (m Model) View() string { return m.list.View() }
func (m Model) HelpBindings() []key.Binding {
return []key.Binding{
m.list.KeyMap.CursorUp,
m.list.KeyMap.CursorDown,
m.KeyMap.Select,
}
}
-103
View File
@@ -1,103 +0,0 @@
// Command basic demonstrates the minimum layout needs: a 2x2 grid of plain
// panes, no custom border, ctrl+hjkl moving focus between them out of the
// box.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
// textPane is the simplest possible Pane: it just reports its own id, size
// and focus state. It still renders to exactly the width/height it was
// told (via lipgloss's own Width/Height), which is the one rule every Pane
// has to follow - layout composes View() output side by side and never
// pads it itself.
type textPane struct {
id string
w, h int
focused bool
}
func newTextPane(id string) *textPane { return &textPane{id: id} }
func (p *textPane) Init() tea.Cmd { return nil }
func (p *textPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *textPane) View() string {
state := "blurred"
if p.focused {
state = "focused"
}
content := fmt.Sprintf("%s\n(%s, %dx%d)", p.id, state, p.w, p.h)
return lipgloss.NewStyle().
Width(p.w).
Height(p.h).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(content)
}
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
}
func (m model) Init() tea.Cmd { return m.layout.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, cmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(m.layout.View())
view.AltScreen = true
return view
}
func main() {
root := layout.HSplit(0.5,
layout.VSplit(0.5,
layout.Leaf("top-left", newTextPane("top-left")),
layout.Leaf("bottom-left", newTextPane("bottom-left")),
),
layout.VSplit(0.5,
layout.Leaf("top-right", newTextPane("top-right")),
layout.Leaf("bottom-right", newTextPane("bottom-right")),
),
)
m := model{layout: layout.New(root, layout.AsRoot())}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-94
View File
@@ -1,94 +0,0 @@
// Command bordered demonstrates that layout draws no chrome of its own:
// each pane here owns its border and picks its color from FocusMsg/BlurMsg,
// via the optional layout.Bordered helper.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
type borderedPane struct {
id string
w, h int
focused bool
}
func newBorderedPane(id string) *borderedPane { return &borderedPane{id: id} }
func (p *borderedPane) Init() tea.Cmd { return nil }
func (p *borderedPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *borderedPane) View() string {
inner := lipgloss.NewStyle().
Width(p.w - 2).
Height(p.h - 2).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(p.id)
// layout.Bordered already draws the border at exactly p.w x p.h, so the
// content it wraps must already be sized to w-2 x h-2 (border eats one
// cell on each side) - same rule as any other Pane, just one layer in.
return layout.Bordered(p.focused, p.w, p.h, inner)
}
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
}
func (m model) Init() tea.Cmd { return m.layout.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, cmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(m.layout.View())
view.AltScreen = true
return view
}
func main() {
root := layout.HSplit(0.3,
layout.Leaf("sidebar", newBorderedPane("sidebar")),
layout.VSplit(0.6,
layout.Leaf("main", newBorderedPane("main")),
layout.Leaf("log", newBorderedPane("log")),
),
)
m := model{layout: layout.New(root, layout.AsRoot())}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-116
View File
@@ -1,116 +0,0 @@
// Command fullapp demonstrates a more realistic app shell: the sidebar
// leaf isn't a plain placeholder pane, it's a full layout.Model of its own
// (sidebar.New(), a themed list, see sidebar/sidebar.go) embedded exactly
// like the "router" one below - layout.Model implements Pane, so any
// package can hand back one to be wrapped in a Leaf, no adapter needed.
package first
import (
"fmt"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
type pane struct {
id string
w, h int
focused bool
}
func newPane(id string) *pane { return &pane{id: id} }
func (p *pane) Init() tea.Cmd { return nil }
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *pane) View() string {
content := lipgloss.NewStyle().
Width(p.w - 2).Height(p.h - 2).
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
Render(p.id)
return layout.Bordered(p.focused, p.w, p.h, content)
}
// HelpBindings implements layout.HelpProvider, so every pane - at the top
// level or nested three levels deep, doesn't matter - shows up correctly
// in the single, outer help bar.
func (p *pane) HelpBindings() []key.Binding {
return []key.Binding{key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "use "+p.id))}
}
// editorPane keeps a counter (press +) that's otherwise irrelevant to the
// app but proves the point of router.Model owning pages permanently:
// switch to "Second" and back, and it's still there. A plain pane (like
// terminal below) would just get reconstructed from scratch every time if
// something recreated it on each switch instead of keeping it alive.
type editorPane struct {
w, h int
focused bool
count int
}
func newEditorPane() *editorPane { return &editorPane{} }
func (p *editorPane) Init() tea.Cmd { return nil }
func (p *editorPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
case tea.KeyPressMsg:
if msg.String() == "+" {
p.count++
}
}
return p, nil
}
func (p *editorPane) View() string {
content := lipgloss.NewStyle().
Width(p.w - 2).Height(p.h - 2).
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
Render(fmt.Sprintf("editor\n\npress + to increment: %d", p.count))
return layout.Bordered(p.focused, p.w, p.h, content)
}
// HelpBindings implements layout.HelpProvider.
func (p *editorPane) HelpBindings() []key.Binding {
return []key.Binding{key.NewBinding(key.WithKeys("+"), key.WithHelp("+", "increment"))}
}
// NewWorkspace builds the "First" page's own nested layout.Model: note the
// lack of layout.AsRoot() here - only the outermost Model (see main) should
// render a help bar, or focused help would show up twice. second.
// NewWorkspace and sidebar.New() below are built the same way, same reason.
//
// This Model is built exactly once, by router.New(), and kept alive for
// the whole app's lifetime - router.Model just changes which page is
// rendered/routed to, it never reconstructs one. That's what lets
// editorPane's counter above survive switching to "Second" and back; see
// package router's own doc comment for why that ownership lives there
// and not in the sidebar.
func NewWorkspace() layout.Model {
root := layout.VSplit(0.7,
layout.Leaf("editor", newEditorPane()),
layout.Leaf("terminal", newPane("terminal")),
)
return layout.New(root)
}
-88
View File
@@ -1,88 +0,0 @@
// Command fullapp demonstrates a more realistic app shell: the sidebar
// leaf isn't a plain placeholder pane, it's a full layout.Model of its own
// (sidebar.New(), a themed list, see sidebar/sidebar.go) embedded exactly
// like the "router" one below - layout.Model implements Pane, so any
// package can hand back one to be wrapped in a Leaf, no adapter needed.
//
// It also owns the app's single notification.Model (see second/second.go,
// which triggers toasts via notification.Show without ever holding a
// reference to it) and composites its toasts over the whole rendered
// layout, top-right - notification has no dependency on layout, so this is
// the same Render(background string) pattern as examples/notification, just
// with layout.Model.View() as the background instead of a plain string.
//
// Same story for the app's single modal.Model (see third/third.go), except
// modal is composited last, on top of notif's own output: a modal is meant
// to command the whole screen's attention, so it should flatten any toast
// already showing to the same dim gray as everything else, not leave it
// floating on top in full color.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/router"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/sidebar"
"github.com/anotherhadi/ilovetui/layout"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
)
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
notif notification.Model
modal modal.Model
}
func (m model) Init() tea.Cmd {
return tea.Batch(m.layout.Init(), m.notif.Init(), m.modal.Init())
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, layoutCmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
var notifCmd, modalCmd tea.Cmd
m.notif, notifCmd = m.notif.Update(msg)
m.modal, modalCmd = m.modal.Update(msg)
return m, tea.Batch(layoutCmd, notifCmd, modalCmd)
}
func (m model) View() tea.View {
view := tea.NewView(m.modal.Render(m.notif.Render(m.layout.View())))
view.AltScreen = true
return view
}
func main() {
root := layout.HSplit(0.25,
layout.Leaf("sidebar", sidebar.New()),
layout.Leaf("router", router.New()),
).WithMaximum(20)
m := model{
layout: layout.New(root, layout.AsRoot()),
notif: notification.New(notification.WithPosition(notification.TopRight)),
modal: modal.New(),
}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-109
View File
@@ -1,109 +0,0 @@
// Package router owns every page's own layout.Model for the lifetime of
// the whole app, so switching pages never discards their state - only
// which one is currently active/rendered changes. It slots into the root
// tree's "router" leaf exactly like a single nested layout.Model would
// (see examples/layout/nested), because Model implements layout.Navigable
// itself on top of layout.Pane, delegating everything to whichever page is
// active: ctrl+hjkl, the help bar, and SendMsg/RequestFocusMsg addressed to
// ids inside that page all keep working transparently.
package router
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/first"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/second"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/third"
"github.com/anotherhadi/ilovetui/layout"
)
// SelectMsg asks Model to switch its active page, by id ("first", "second"
// or "third"). Sent by the sidebar via layout.SendMsg{Target: "router"} -
// it never holds a reference to Model either.
type SelectMsg struct{ Page string }
type Model struct {
active string
pages map[string]layout.Model
}
func New() *Model {
return &Model{
active: "first",
pages: map[string]layout.Model{
"first": first.NewWorkspace(),
"second": second.NewWorkspace(),
"third": third.NewWorkspace(),
},
}
}
func (m *Model) current() layout.Model { return m.pages[m.active] }
func (m *Model) Init() tea.Cmd {
var cmds []tea.Cmd
for _, p := range m.pages {
if cmd := p.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
func (m *Model) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
if sel, ok := msg.(SelectMsg); ok {
if _, exists := m.pages[sel.Page]; exists {
m.active = sel.Page
}
return m, nil
}
if size, ok := msg.(layout.SizeMsg); ok {
// Every page needs to stay correctly sized even while hidden - the
// outer tree only re-sends SizeMsg when the "router" leaf's own
// Rect changes, never on a plain page switch, so a page that was
// inactive during a resize must still learn about it here, or it
// renders at a stale size whenever it becomes active again.
var cmds []tea.Cmd
for id, p := range m.pages {
updated, cmd := p.Update(size)
m.pages[id] = updated.(layout.Model)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
updated, cmd := m.current().Update(msg)
m.pages[m.active] = updated.(layout.Model)
return m, cmd
}
func (m *Model) View() string {
return m.current().View()
}
// Leaves, MoveFocus, Route, Focus and FocusedHelp implement
// layout.Navigable, delegating to whichever page is currently active.
func (m *Model) Leaves() []layout.LeafRect {
return m.current().Leaves()
}
func (m *Model) MoveFocus(dir layout.FocusDirection) bool {
return m.current().MoveFocus(dir)
}
func (m *Model) Route(target string, msg tea.Msg) (bool, tea.Cmd) {
return m.current().Route(target, msg)
}
func (m *Model) Focus(id string) (bool, tea.Cmd) {
return m.current().Focus(id)
}
func (m *Model) FocusedHelp() []key.Binding {
return m.current().FocusedHelp()
}
-99
View File
@@ -1,99 +0,0 @@
// Package second is the "Second" page's own workspace: deliberately a
// different shape from first's (a single pane holding a themed list, not an
// editor/terminal split), so swapping between the two via sidebar.Model's
// "enter" case is visibly a real change of sub-app, not just a relabeled
// copy of first. It also demonstrates notification.Show being called from
// deep inside a nested layout.Model, with nothing wiring it to the
// notification.Model that actually renders it - see main.go's model.View.
package second
import (
"charm.land/bubbles/v2/key"
bubbleslist "charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/layout"
"github.com/anotherhadi/ilovetui/notification"
)
// kind is a list entry: a notification.Kind plus the title/message Show
// gets called with when it's selected.
type kind struct {
title string
message string
kind notification.Kind
}
func (k kind) Title() string { return k.title }
func (k kind) Description() string { return "" }
func (k kind) FilterValue() string { return k.title }
type pane struct {
list bubbleslist.Model
w, h int
focused bool
}
func newPane() *pane {
items := []bubbleslist.Item{
kind{title: "Info", message: "Just so you know.", kind: notification.Info},
kind{title: "Success", message: "Config written to disk.", kind: notification.Success},
kind{title: "Warning", message: "Disk space getting low on /dev/sda1.", kind: notification.Warning},
kind{title: "Error", message: "Failed to reach the remote host.", kind: notification.Error},
}
list := bubbles.NewList(items, 0, 0)
list.Title = "Notify"
// Same reasoning as sidebar.Model: the built-in help footer is
// redundant with layout's own centralized help bar (see HelpBindings
// below).
list.SetShowHelp(false)
return &pane{list: list}
}
func (p *pane) Init() tea.Cmd { return nil }
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
p.list.SetSize(p.w-2, p.h-2)
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
if !p.focused {
return p, nil
}
if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "enter" {
if selected, ok := p.list.SelectedItem().(kind); ok {
return p, notification.Show(selected.title, selected.message, selected.kind)
}
}
var cmd tea.Cmd
p.list, cmd = p.list.Update(msg)
return p, cmd
}
func (p *pane) View() string {
return layout.Bordered(p.focused, p.w, p.h, p.list.View())
}
// HelpBindings implements layout.HelpProvider.
func (p *pane) HelpBindings() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "show notification")),
key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "go down")),
key.NewBinding(key.WithKeys("k"), key.WithHelp("k", "go up")),
}
}
// NewWorkspace builds the "Second" page's own nested layout.Model. No
// layout.AsRoot() - see first.NewWorkspace's doc comment.
func NewWorkspace() layout.Model {
return layout.New(layout.Leaf("content", newPane()))
}
-101
View File
@@ -1,101 +0,0 @@
package sidebar
import (
"charm.land/bubbles/v2/key"
bubbleslist "charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/router"
"github.com/anotherhadi/ilovetui/layout"
)
// page is a sidebar entry: just an id (matching one of router.Model's
// own page keys) and a label. Model has no idea what a page actually is or
// how it's built - that's router's business entirely, this only needs
// to name it.
type page struct {
id string
title string
}
func (p page) Title() string { return p.title }
func (p page) Description() string { return "" }
func (p page) FilterValue() string { return p.title }
type Model struct {
id string // learned from SizeMsg.ID, needed as RequestFocusMsg.Source
list bubbleslist.Model
w, h int
focused bool
}
func New() *Model {
items := []bubbleslist.Item{
page{id: "first", title: "First"},
page{id: "second", title: "Second"},
page{id: "third", title: "Third"},
}
list := bubbles.NewList(items, 0, 0)
list.Title = "Sidebar"
// list's own built-in help footer is redundant - Model already feeds
// layout's single centralized help bar via HelpBindings below - and at
// a narrow sidebar width it word-wraps onto multiple lines, which
// list.SetSize doesn't account for: list.View() ends up taller than
// the height it was given, breaking layout's "render exactly h" rule
// and pushing the border past the bottom of the pane.
list.SetShowHelp(false)
return &Model{list: list}
}
func (m *Model) Init() tea.Cmd { return nil }
func (m *Model) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
m.id, m.w, m.h = msg.ID, msg.Width, msg.Height
m.list.SetSize(m.w-2, m.h-2)
case layout.FocusMsg:
m.focused = true
case layout.BlurMsg:
m.focused = false
}
if !m.focused {
return m, nil
}
if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "enter" {
if selected, ok := m.list.SelectedItem().(page); ok {
// tea.Sequence, not tea.Batch: the page switch must land
// before the focus jump, or RequestFocusMsg could cascade
// into router while it's still showing the previous page
// (Batch runs both concurrently with no ordering guarantee).
return m, tea.Sequence(
func() tea.Msg {
return layout.SendMsg{Target: "router", Msg: router.SelectMsg{Page: selected.id}}
},
func() tea.Msg {
return layout.RequestFocusMsg{Source: m.id, Target: "router"}
},
)
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m *Model) View() string {
return layout.Bordered(m.focused, m.w, m.h, m.list.View())
}
func (m *Model) HelpBindings() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open page")),
key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "go down")),
key.NewBinding(key.WithKeys("k"), key.WithHelp("k", "go up")),
}
}
-111
View File
@@ -1,111 +0,0 @@
// Package third is the "Third" page's own workspace: a single pane showing
// modal.Show being called from deep inside a nested layout.Model, exactly
// like second/second.go does for notification.Show - modal has no
// dependency on layout either, so nothing here needs a reference to the
// modal.Model that actually renders it (see main.go's model.View).
package third
import (
"charm.land/bubbles/v2/key"
bubbleslist "charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/layout"
"github.com/anotherhadi/ilovetui/modal"
)
// action is a list entry: the title/content Show gets called with when it's
// selected, and whether closing it needs an explicit "y" (a destructive
// confirm) or any key at all (a plain info popup).
type action struct {
title, content string
confirm bool
}
func (a action) Title() string { return a.title }
func (a action) Description() string { return "" }
func (a action) FilterValue() string { return a.title }
type pane struct {
list bubbleslist.Model
w, h int
focused bool
// open/confirm track the modal this pane itself opened, so it knows to
// swallow keys instead of forwarding them to the list underneath while
// it's up - modal.Model has no notion of "focus" of its own, the pane
// that triggered it is responsible for gating input while it's open.
open bool
confirm bool
}
func newPane() *pane {
items := []bubbleslist.Item{
action{title: "Delete file", content: "This can't be undone.\n\ny: confirm esc: cancel", confirm: true},
action{title: "About", content: "modal renders a centered popup over a\nflat-dimmed background.\n\nesc: close"},
}
list := bubbles.NewList(items, 0, 0)
list.Title = "Modal"
// Same reasoning as sidebar/second: redundant with layout's own
// centralized help bar (see HelpBindings below).
list.SetShowHelp(false)
return &pane{list: list}
}
func (p *pane) Init() tea.Cmd { return nil }
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
p.list.SetSize(p.w-2, p.h-2)
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
if !p.focused {
return p, nil
}
if key, ok := msg.(tea.KeyPressMsg); ok {
if p.open {
if !p.confirm || key.String() == "y" || key.String() == "esc" {
p.open = false
return p, modal.Close()
}
return p, nil
}
if key.String() == "enter" {
if selected, ok := p.list.SelectedItem().(action); ok {
p.open, p.confirm = true, selected.confirm
return p, modal.Show(selected.title+"?", selected.content)
}
}
}
var cmd tea.Cmd
p.list, cmd = p.list.Update(msg)
return p, cmd
}
func (p *pane) View() string {
return layout.Bordered(p.focused, p.w, p.h, p.list.View())
}
// HelpBindings implements layout.HelpProvider.
func (p *pane) HelpBindings() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open modal")),
key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "go down")),
key.NewBinding(key.WithKeys("k"), key.WithHelp("k", "go up")),
}
}
// NewWorkspace builds the "Third" page's own nested layout.Model. No
// layout.AsRoot() - see first.NewWorkspace's doc comment.
func NewWorkspace() layout.Model {
return layout.New(layout.Leaf("content", newPane()))
}
-136
View File
@@ -1,136 +0,0 @@
// Command help demonstrates the dynamic help bar: each pane implements
// layout.HelpProvider (or doesn't) and the bottom bar always reflects
// whichever one is currently focused, with no wiring beyond that.
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
// helpfulPane implements layout.HelpProvider with a binding or two of its
// own, on top of the usual Size/Focus/Blur bookkeeping.
type helpfulPane struct {
id string
w, h int
focused bool
bindings []key.Binding
}
func newHelpfulPane(id string, bindings []key.Binding) *helpfulPane {
return &helpfulPane{id: id, bindings: bindings}
}
func (p *helpfulPane) Init() tea.Cmd { return nil }
func (p *helpfulPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *helpfulPane) View() string {
content := lipgloss.NewStyle().
Width(p.w - 2).Height(p.h - 2).
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
Render(p.id)
return layout.Bordered(p.focused, p.w, p.h, content)
}
// HelpBindings implements layout.HelpProvider.
func (p *helpfulPane) HelpBindings() []key.Binding { return p.bindings }
// silentPane deliberately does NOT implement layout.HelpProvider, to show
// that the help bar just falls back to layout's own controls (ctrl+hjkl,
// ?) instead of disappearing or erroring when it's focused.
type silentPane struct {
w, h int
focused bool
}
func (p *silentPane) Init() tea.Cmd { return nil }
func (p *silentPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *silentPane) View() string {
content := lipgloss.NewStyle().
Width(p.w - 2).Height(p.h - 2).
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
Render("no help bindings\n(watch the bar below)")
return layout.Bordered(p.focused, p.w, p.h, content)
}
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
}
func (m model) Init() tea.Cmd { return m.layout.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, cmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(m.layout.View())
view.AltScreen = true
return view
}
func main() {
writer := newHelpfulPane("writer", []key.Binding{
key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")),
key.NewBinding(key.WithKeys("ctrl+z"), key.WithHelp("ctrl+z", "undo")),
})
browser := newHelpfulPane("browser", []key.Binding{
key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
})
root := layout.HSplit(0.34,
layout.Leaf("writer", writer),
layout.HSplit(0.5,
layout.Leaf("browser", browser),
layout.Leaf("scratch", &silentPane{}),
),
)
m := model{layout: layout.New(root, layout.AsRoot())}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-132
View File
@@ -1,132 +0,0 @@
// Command messaging demonstrates the two ways panes talk without holding a
// reference to each other: "control" sends arbitrary commands to "editor"
// by id via SendMsg (1/2/3 keys), and asks layout to move focus there via
// RequestFocusMsg (enter key) - the same pattern a real sidebar would use
// to both drive and jump to a content pane it selected.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
// commandMsg is what "control" sends to "editor" - an app-defined message,
// entirely opaque to layout itself (see SendMsg).
type commandMsg struct{ text string }
type controlPane struct {
id string // learned from SizeMsg.ID, needed as RequestFocusMsg.Source
w, h int
focused bool
}
func (p *controlPane) Init() tea.Cmd { return nil }
func (p *controlPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.id, p.w, p.h = msg.ID, msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
case tea.KeyPressMsg:
switch msg.String() {
case "1", "2", "3":
text := "command " + msg.String()
return p, func() tea.Msg {
return layout.SendMsg{Target: "editor", Msg: commandMsg{text: text}}
}
case "enter":
return p, func() tea.Msg {
return layout.RequestFocusMsg{Source: p.id, Target: "editor"}
}
}
}
return p, nil
}
func (p *controlPane) View() string {
content := "control\n\n1/2/3: send a command\nenter: focus editor"
inner := lipgloss.NewStyle().Width(p.w - 2).Height(p.h - 2).Render(content)
return layout.Bordered(p.focused, p.w, p.h, inner)
}
type editorPane struct {
w, h int
focused bool
last string
}
func (p *editorPane) Init() tea.Cmd { return nil }
func (p *editorPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
case commandMsg:
p.last = msg.text
}
return p, nil
}
func (p *editorPane) View() string {
last := p.last
if last == "" {
last = "(nothing yet)"
}
content := fmt.Sprintf("editor\n\nlast command received:\n%s", last)
inner := lipgloss.NewStyle().Width(p.w - 2).Height(p.h - 2).Render(content)
return layout.Bordered(p.focused, p.w, p.h, inner)
}
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
}
func (m model) Init() tea.Cmd { return m.layout.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, cmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(m.layout.View())
view.AltScreen = true
return view
}
func main() {
root := layout.HSplit(0.35,
layout.Leaf("control", &controlPane{}),
layout.Leaf("editor", &editorPane{}),
)
m := model{layout: layout.New(root, layout.AsRoot())}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-107
View File
@@ -1,107 +0,0 @@
// Command nested demonstrates composability: "workspace" is itself a full
// layout.Model (its own two-pane split) embedded as an ordinary Leaf inside
// the outer tree. ctrl+hjkl bubbles in and out of it transparently, and
// only the outer Model shows a help bar - the inner one is built without
// layout.AsRoot(), see newWorkspace.
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/layout"
)
type pane struct {
id string
w, h int
focused bool
}
func newPane(id string) *pane { return &pane{id: id} }
func (p *pane) Init() tea.Cmd { return nil }
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
switch msg := msg.(type) {
case layout.SizeMsg:
p.w, p.h = msg.Width, msg.Height
case layout.FocusMsg:
p.focused = true
case layout.BlurMsg:
p.focused = false
}
return p, nil
}
func (p *pane) View() string {
content := lipgloss.NewStyle().
Width(p.w - 2).Height(p.h - 2).
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
Render(p.id)
return layout.Bordered(p.focused, p.w, p.h, content)
}
// HelpBindings implements layout.HelpProvider, so every pane - at the top
// level or nested three levels deep, doesn't matter - shows up correctly
// in the single, outer help bar.
func (p *pane) HelpBindings() []key.Binding {
return []key.Binding{key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "use "+p.id))}
}
// newWorkspace builds the nested layout.Model: note the lack of
// layout.AsRoot() here - only the outermost Model (see main) should render
// a help bar, or focused help would show up twice.
func newWorkspace() layout.Model {
root := layout.VSplit(0.7,
layout.Leaf("editor", newPane("editor")),
layout.Leaf("terminal", newPane("terminal")),
)
return layout.New(root)
}
// model is the actual top-level tea.Model: layout itself reserves no quit
// key (that's an app policy, not layout's to make), so the host wraps it
// and handles ctrl+c/q itself, same as any other custom component in this
// repo (see examples/tabs).
type model struct {
layout layout.Model
}
func (m model) Init() tea.Cmd { return m.layout.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
updated, cmd := m.layout.Update(msg)
m.layout = updated.(layout.Model)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(m.layout.View())
view.AltScreen = true
return view
}
func main() {
root := layout.HSplit(0.25,
layout.Leaf("sidebar", newPane("sidebar")).WithMinimum(20),
layout.Leaf("workspace", newWorkspace()),
)
m := model{layout: layout.New(root, layout.AsRoot())}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+38 -11
View File
@@ -11,10 +11,36 @@ import (
"github.com/anotherhadi/ilovetui/style"
)
const confirmID = "confirm"
// confirmedMsg is what the confirmation modal reports back with. The app
// listens for it like any other message - it never holds a reference to the
// modal, and the modal never knows what confirming means.
type confirmedMsg struct{}
// confirm is the modal's content: a model, so it owns its own keys. The host
// no longer has to ask which modal is on top to know where "y" should go.
type confirm struct{}
func (c confirm) Init() tea.Cmd { return nil }
func (c confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "y" {
// Report back and close itself: Close() is a package-level command,
// so the content needs no reference to the modal.Model either.
return c, tea.Batch(
func() tea.Msg { return confirmedMsg{} },
modal.Close(),
)
}
return c, nil
}
func (c confirm) View() tea.View {
return tea.NewView("This can't be undone.\n\ny: confirm esc: cancel")
}
type model struct {
m modal.Model
deleted bool
width, height int
}
@@ -30,29 +56,27 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width, m.height = msg.Width, msg.Height
return m, nil
case confirmedMsg:
m.deleted = true
return m, nil
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "m":
return m, modal.Show("Delete file?", "This can't be undone.\n\ny: confirm esc: cancel",
modal.WithID(confirmID))
return m, modal.Show("Delete file?", confirm{})
case "n":
if m.m.Open() {
return m, modal.Show("Really sure?", "There's no undo for this one either.")
return m, modal.Show("Really sure?", modal.Text("There's no undo for this one either."))
}
case "esc":
if m.m.Open() {
return m, modal.Close()
}
case "y":
if m.m.TopID() == confirmID {
return m, modal.Dismiss(confirmID)
}
}
}
@@ -63,8 +87,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m model) View() tea.View {
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App")
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(
"Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the modal.")
text := "Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the modal."
if m.deleted {
text = "File deleted - the modal's content reported back\nwith its own message, and closed itself."
}
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(text)
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"m: open modal n: open nested modal y: confirm esc: cancel q: quit")
+235
View File
@@ -0,0 +1,235 @@
// Command sidebar is the whole app-shell pattern in one file: a sidebar on
// the left, an ordinary tea.Model on the right, a global help bar at the
// bottom, tab to move focus between the two. No layout package involved -
// lipgloss.JoinHorizontal/JoinVertical and style.RenderWithTitle already do
// all of it, and the panes stay plain tea.Models.
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/style"
)
// sidebarWidth is the sidebar's total width, border included.
const sidebarWidth = 24
// HelpProvider is the only contract in this pattern, and it's optional: a
// pane that implements it gets its own bindings listed in the global help
// bar while it's focused. A pane that doesn't just contributes nothing.
type HelpProvider interface {
HelpBindings() []key.Binding
}
// ---------------------------------------------------------------- shell keys
type keyMap struct {
Focus key.Binding
Help key.Binding
Quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
Focus: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch pane")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q", "quit")),
}
}
// ---------------------------------------------------------------- the shell
type model struct {
sidebar list.Model
content tea.Model
help helpbar.Model
keys keyMap
contentFocused bool
w, h int
}
func newModel() model {
items := []list.Item{page("Overview"), page("Metrics"), page("Settings")}
sidebar := bubbles.NewList(items, sidebarWidth-2, 0)
sidebar.SetShowTitle(false)
sidebar.SetShowStatusBar(false)
sidebar.SetShowHelp(false)
keys := defaultKeyMap()
return model{
sidebar: sidebar,
content: newCounter(),
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.Focus, keys.Quit)),
keys: keys,
}
}
func (m model) Init() tea.Cmd { return m.content.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height
return m.resize()
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
// Expanding the bar takes rows away from the panes.
m.help, _ = m.help.Update(msg)
return m.resize()
case key.Matches(msg, m.keys.Focus):
m.contentFocused = !m.contentFocused
return m, nil
}
// Only the focused pane sees key presses.
if m.contentFocused {
var cmd tea.Cmd
m.content, cmd = m.content.Update(msg)
return m, cmd
}
var cmd tea.Cmd
m.sidebar, cmd = m.sidebar.Update(msg)
return m, cmd
}
// Everything else (ticks, HTTP responses...) goes to both, so a blurred
// pane keeps working.
var sidebarCmd, contentCmd tea.Cmd
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
m.content, contentCmd = m.content.Update(msg)
return m, tea.Batch(sidebarCmd, contentCmd)
}
// bodyHeight is the height left for the two panes once the help bar has
// taken its share. resize and View both go through it so they can't disagree.
func (m model) bodyHeight() int {
return max(m.h-m.help.Height(m.focusedHelp()...), 0)
}
func (m model) resize() (model, tea.Cmd) {
if m.w <= 0 || m.h <= 0 {
return m, nil
}
m.help.SetWidth(m.w)
inner := style.ContentHeight(m.bodyHeight())
m.sidebar.SetSize(sidebarWidth-2, inner)
var cmd tea.Cmd
m.content, cmd = m.content.Update(tea.WindowSizeMsg{
Width: max(m.w-sidebarWidth-2, 0), Height: inner,
})
return m, cmd
}
// focusedHelp is the focused pane's own bindings, if it offers any.
func (m model) focusedHelp() []key.Binding {
if m.contentFocused {
if hp, ok := m.content.(HelpProvider); ok {
return hp.HelpBindings()
}
return nil
}
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
}
func (m model) View() tea.View {
if m.w <= 0 || m.h <= 0 {
return tea.NewView("")
}
helpBar := m.help.View(m.focusedHelp()...)
bodyH := m.bodyHeight()
left := style.RenderWithTitle(
panel(!m.contentFocused), "Menu", m.sidebar.View(), sidebarWidth, bodyH)
right := style.RenderWithTitle(
panel(m.contentFocused), "Content", m.content.View().Content, m.w-sidebarWidth, bodyH)
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
lipgloss.JoinHorizontal(lipgloss.Top, left, right),
helpBar,
))
view.AltScreen = true
return view
}
// panel picks the bordered panel style matching a pane's focus state.
func panel(focused bool) lipgloss.Style {
if focused {
return style.S.PanelFocused
}
return style.S.Panel
}
// ------------------------------------------------------- the right-hand pane
// counter is an ordinary tea.Model - nothing about it knows it's living in a
// shell. It implements HelpProvider purely to appear in the help bar.
type counter struct {
n int
w, h int
keys struct{ Inc, Dec key.Binding }
}
func newCounter() *counter {
c := &counter{}
c.keys.Inc = key.NewBinding(key.WithKeys("+", "k"), key.WithHelp("+/k", "increment"))
c.keys.Dec = key.NewBinding(key.WithKeys("-", "j"), key.WithHelp("-/j", "decrement"))
return c
}
func (c *counter) Init() tea.Cmd { return nil }
func (c *counter) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.w, c.h = msg.Width, msg.Height
case tea.KeyPressMsg:
switch {
case key.Matches(msg, c.keys.Inc):
c.n++
case key.Matches(msg, c.keys.Dec):
c.n--
}
}
return c, nil
}
func (c *counter) View() tea.View {
return tea.NewView(lipgloss.NewStyle().
Width(c.w).Height(c.h).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(fmt.Sprintf("count: %d", c.n)))
}
func (c *counter) HelpBindings() []key.Binding {
return []key.Binding{c.keys.Inc, c.keys.Dec}
}
// ------------------------------------------------------------- sidebar items
type page string
func (p page) Title() string { return string(p) }
func (p page) Description() string { return "" }
func (p page) FilterValue() string { return string(p) }
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+91
View File
@@ -0,0 +1,91 @@
# Helpbar
A responsive help bar: one line of key bindings that expands, on demand, into a multi-column view reflowed to use as many columns as the available width allows.
## Quick start
```go
import "github.com/anotherhadi/ilovetui/helpbar"
type model struct {
help helpbar.Model
keys keyMap
h int
}
func newModel() model {
keys := defaultKeyMap()
return model{
help: helpbar.New(
helpbar.WithToggle(keys.Help), // '?' expands/collapses
helpbar.WithGlobal(keys.Focus, keys.Quit), // always shown
),
keys: keys,
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.h = msg.Height
m.help.SetWidth(msg.Width)
case tea.KeyPressMsg:
m.help, _ = m.help.Update(msg) // flips ShowAll on the toggle binding
}
return m, nil
}
func (m model) View() tea.View {
bar := m.help.View(m.focused().HelpBindings()...)
body := renderBody(m.h - lipgloss.Height(bar))
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, bar))
}
```
## Global vs. contextual bindings
Bindings come from two places, and they render in this order:
1. The `WithToggle` binding, so the way to expand the bar always leads.
2. `WithGlobal` bindings - what your app reserves for itself (quit, switch pane...), set once.
3. Contextual bindings, passed to `View` at render time.
Contextual bindings are meant to change from render to render, so the bar can track whatever component currently has focus. Nothing in the package knows what "focus" means for your app - you just hand it a different slice:
```go
func (m model) focusedHelp() []key.Binding {
if m.contentFocused {
return m.content.HelpBindings()
}
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
}
```
Disabled bindings (`key.Binding.SetEnabled(false)`) are dropped before layout, so the reflow never
budgets width for something that won't be drawn.
## Reserving room for the bar
The bar's height depends on its content and on whether it's expanded, so ask it rather than assuming
a fixed number of rows:
```go
body := m.height - m.help.Height(contextual...)
```
`Height` is exactly `lipgloss.Height` of what `View` returns for the same arguments, so the two can
never disagree about where the bar begins. An empty bar (no bindings, or no width set) renders `""`
and takes zero rows.
## Styling
Defaults come from the shared `style` theme. Override with `WithStyles`:
```go
helpbar.New(helpbar.WithStyles(myStyles)) // help.Styles from charm.land/bubbles/v2/help
```
## Examples
- `examples/sidebar` uses it as an app-wide bar tracking the focused pane.
- `examples/app` full app with a single help bar
+185
View File
@@ -0,0 +1,185 @@
// Package helpbar is a responsive help bar: a single line of key bindings
// that expands, on demand, into a multi-column view reflowed to use as many
// columns as the available width allows.
//
// It has no dependency on any particular layout or container - it's just a
// component that takes a width and some bindings and returns a string, so it
// works as well under a plain lipgloss.JoinVertical as anywhere else.
//
// Bindings come from two places. Global ones (quit, toggle help, whatever
// your app reserves for itself) are set once via WithGlobal and always shown
// first. Contextual ones are passed to View at render time, so the bar can
// track whatever component currently has focus:
//
// bar := m.help.View(m.focused().HelpBindings()...)
// body := m.height - lipgloss.Height(bar)
package helpbar
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"
)
// Model is a help bar. The zero value isn't usable - build one with New.
type Model struct {
// ShowAll switches between the one-line short view and the full
// multi-column view. Set it directly, or let WithToggle bind a key to
// it and have Update flip it for you.
ShowAll bool
help help.Model
global []key.Binding
toggle key.Binding
width int
}
// Option configures a Model at construction.
type Option func(*Model)
// WithGlobal sets the bindings shown before the contextual ones on every
// render - the keys your app reserves for itself regardless of what's
// focused.
func WithGlobal(bindings ...key.Binding) Option {
return func(m *Model) { m.global = bindings }
}
// WithToggle makes Update flip ShowAll when b matches, and lists b ahead of
// every other binding (including WithGlobal's) so the way to expand the bar
// is always the first thing shown. Without it, Update ignores key presses
// and toggling ShowAll is entirely up to the caller.
func WithToggle(b key.Binding) Option {
return func(m *Model) { m.toggle = b }
}
// WithStyles overrides the themed default styles.
func WithStyles(s help.Styles) Option {
return func(m *Model) { m.help.Styles = s }
}
// New builds a help bar themed from the shared style package.
func New(opts ...Option) Model {
m := Model{help: bubbles.NewHelp()}
for _, opt := range opts {
opt(&m)
}
return m
}
// SetWidth sets the width the bar renders within. Nothing is shown until
// this is called with a positive value - typically from your
// tea.WindowSizeMsg handler.
func (m *Model) SetWidth(w int) {
m.width = w
m.help.SetWidth(w)
}
// Width returns the width last given to SetWidth.
func (m Model) Width() int { return m.width }
// Update flips ShowAll when a key press matches WithToggle's binding. It's
// optional: a Model built without WithToggle ignores every message, and you
// can always set ShowAll yourself instead.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyPressMsg); ok && key.Matches(keyMsg, m.toggle) {
m.ShowAll = !m.ShowAll
}
return m, nil
}
// View renders the bar: one line while ShowAll is false, otherwise the full
// multi-column view. contextual bindings follow the toggle and global ones,
// and are meant to change from render to render as focus moves.
//
// Returns "" when no width has been set or nothing is left to show, in which
// case the bar occupies no rows at all.
func (m Model) View(contextual ...key.Binding) string {
bindings := m.bindings(contextual)
if len(bindings) == 0 || m.width <= 0 {
return ""
}
if !m.ShowAll {
return m.help.ShortHelpView(bindings)
}
return m.help.FullHelpView(m.columns(bindings))
}
// Height is the number of rows View would take for the same bindings. Use it
// to work out how much room is left for the rest of your UI. View is the
// source of truth: this is exactly lipgloss.Height of its output, so the two
// can't disagree about where the bar begins.
func (m Model) Height(contextual ...key.Binding) int {
view := m.View(contextual...)
if view == "" {
return 0
}
return lipgloss.Height(view)
}
// bindings is the full ordered list: the toggle first (so the way to expand
// the bar always leads), then global, then contextual. Disabled bindings are
// dropped here so column reflow never budgets width for something
// help.FullHelpView will skip anyway.
func (m Model) bindings(contextual []key.Binding) []key.Binding {
all := make([]key.Binding, 0, 1+len(m.global)+len(contextual))
if m.toggle.Enabled() {
all = append(all, m.toggle)
}
for _, b := range append(append([]key.Binding{}, m.global...), contextual...) {
if b.Enabled() {
all = append(all, b)
}
}
return all
}
// columns reflows bindings into as many columns as fit within the bar's
// width, which is the same as using as few rows as possible. It walks row
// counts upward and returns the first arrangement that fits, so the result is
// the widest (fewest-rows) layout the width allows.
//
// help.FullHelpView fills each column top to bottom, so a group is a column,
// not a row.
func (m Model) columns(bindings []key.Binding) [][]key.Binding {
if m.width <= 0 {
return [][]key.Binding{bindings}
}
for rows := 1; rows < len(bindings); rows++ {
groups := chunkColumns(bindings, rows)
if m.renderedWidth(groups) <= m.width {
return groups
}
}
// Everything in one column: the narrowest arrangement possible. It may
// still overflow, in which case help.FullHelpView truncates as usual.
return chunkColumns(bindings, len(bindings))
}
// renderedWidth measures what FullHelpView would actually produce for
// groups, by asking it - rather than reimplementing its column and separator
// arithmetic here, which would silently drift the moment upstream changes a
// style or a separator.
//
// The width is zeroed first because that's what disables FullHelpView's own
// truncation (see its shouldAddItem): at width 0 it lays every column out in
// full, which is the untruncated width this needs to measure.
func (m Model) renderedWidth(groups [][]key.Binding) int {
unbounded := m.help
unbounded.SetWidth(0)
return lipgloss.Width(unbounded.FullHelpView(groups))
}
// chunkColumns slices bindings into consecutive groups of at most rows each.
func chunkColumns(bindings []key.Binding, rows int) [][]key.Binding {
if rows < 1 {
rows = 1
}
groups := make([][]key.Binding, 0, (len(bindings)+rows-1)/rows)
for i := 0; i < len(bindings); i += rows {
groups = append(groups, bindings[i:min(i+rows, len(bindings))])
}
return groups
}
-309
View File
@@ -1,309 +0,0 @@
# 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
@@ -1,64 +0,0 @@
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
@@ -1,89 +0,0 @@
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
@@ -1,130 +0,0 @@
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
@@ -1,49 +0,0 @@
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
@@ -1,77 +0,0 @@
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
@@ -1,542 +0,0 @@
// 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
@@ -1,277 +0,0 @@
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
@@ -1,80 +0,0 @@
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
@@ -1,151 +0,0 @@
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
@@ -1,75 +0,0 @@
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
@@ -1,210 +0,0 @@
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
@@ -1,112 +0,0 @@
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
@@ -1,150 +0,0 @@
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)
}
}
+54 -18
View File
@@ -4,9 +4,8 @@ A centered popup box on top of a dimmed background, triggered from anywhere in a
program via an exported `tea.Msg` (`ShowMsg`/`Show`) rather than a direct reference to the
`Model` that ends up rendering it - standard Elm architecture, no IPC between processes.
It composites over an already-rendered string, so it has no dependency on
`github.com/anotherhadi/ilovetui/layout`: the same `Model` works whether the host uses `layout`
for its main content or not.
It composites over an already-rendered string, so it makes no assumption about how the host
builds that string: the same `Model` works whatever the host uses to lay out its main content.
## Quick start
@@ -30,7 +29,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "d" {
return m, modal.Show("Delete file?", "This can't be undone.\n\ny: confirm esc: cancel")
return m, modal.Show("Delete file?", modal.Text("This can't be undone.\n\ny: confirm esc: cancel"))
}
if msg.String() == "esc" && m.m.Open() {
return m, modal.Close()
@@ -55,37 +54,74 @@ a reference to the `modal.Model` that will actually render it - that `Model` jus
every `tea.Msg` the program produces (i.e. get its `Update` called from the top-level `Update`),
same as any other child model.
## Showing and dismissing
## Content is a model
A modal's body is a `tea.Model`, not a string. While a modal is on top of the stack it gets every
message the `modal.Model` receives, its `Init` runs when it opens, and its commands come back out
- so it can hold a form, a list, or a confirmation that reports its answer with a `tea.Msg` of
its own, which the component that opened it listens for:
```go
return m, modal.Show("Delete file?", "This can't be undone.", modal.WithID("confirm"))
type confirmedMsg struct{ path string }
return m, modal.Dismiss("confirm") // close a specific modal by id
return m, modal.Close() // close whichever modal is on top, whatever its id
func (c confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "y" {
path := c.path
return c, func() tea.Msg { return confirmedMsg{path} }
}
return c, nil
}
// somewhere else
return m, modal.Show("Delete file?", confirm{path: p})
```
Only the topmost modal is updated: everything beneath it is dimmed and frozen until the modals
above it close.
For a modal with nothing to interact with, `modal.Text` wraps a plain string:
```go
return m, modal.Show("About", modal.Text("v1.0\n\nesc to close"))
```
The box shrinks to fit whatever the content draws, so a content that wants a specific size sets
it on itself - the modal only ever sees the rendered result.
## Showing and closing
```go
return m, modal.Show("Delete file?", confirm{})
return m, modal.Close() // close the topmost modal
```
The stack is a plain LIFO, with no identity: a modal is closed by being on top, never by being
named. There is nothing to tag a modal with, and nothing that can target one in the middle of the
stack - the topmost is both the only one that receives messages and the only one `Close` can
reach, so the two rules never disagree.
- `modal.Open()` reports whether at least one modal is currently shown - handy for a host that
wants to route key presses to the modal (e.g. `esc` to dismiss, `enter` to confirm) instead of
its normal UI while one is open.
- `modal.TopID()` returns the id of the topmost (currently interactive) modal, or `""` if none is
open - useful to tell which modal a generic key like `enter` should act on.
- A modal shown without `WithID` gets an auto-generated id that's never returned to the caller -
only a modal shown with `WithID` can be targeted by `Dismiss` later. Showing again with the same
id replaces it in place instead of stacking a duplicate.
wants to route key presses to the modal instead of its normal UI while one is open. Note that a
host doing this also makes it impossible for a key to open a second modal, which is what keeps
the stack shallow without any bookkeeping.
- A content model closes its own modal by returning `modal.Close()`, since it only ever runs while
it is the topmost one.
## Stacking
Modals stack: showing a second one while the first is still open pushes it on top, dimming both
the background and the first modal to the same flat color. Dismissing the top one reveals the one
the background and the first modal to the same flat color. Closing the top one reveals the one
beneath, still in full color. This is what lets a "delete?" confirmation open a nested "really
sure?" modal without any special-casing.
sure?" modal without any special-casing - the nested one is pushed by the content on top, the only
one able to act.
## Styling
```go
m := modal.New(modal.WithMaxWidth(60), modal.WithMaxHeight(20), modal.WithStyles(myStyles))
return m, modal.Show("Title", "Message", modal.WithModalStyle(oneOffStyles))
return m, modal.Show("Title", modal.Text("Message"), modal.WithModalStyle(oneOffStyles))
```
`WithMaxWidth`/`WithMaxHeight` cap how large a modal box can grow before wrapping/truncating; a
+37 -27
View File
@@ -2,12 +2,13 @@ package modal
import tea "charm.land/bubbletea/v2"
// Modal is one popup. Build it via Show's opts rather than a literal: ID
// gets a default (see WithID) that a bare literal would silently skip.
// Modal is one popup on the stack.
type Modal struct {
ID string
Title string
Content string
// Content is the modal's body: a full model, updated and rendered by
// the modal.Model while it's on top of the stack. Wrap a plain string
// with Text for a modal with nothing to interact with.
Content tea.Model
// Style, if non-nil, overrides the Model's default Styles for this
// modal alone.
Style *Styles
@@ -16,20 +17,13 @@ type Modal struct {
// ModalOption configures a Modal built by Show.
type ModalOption func(*Modal)
// WithID gives the modal a stable id, so a later Show reusing the same id
// replaces it in place instead of pushing a duplicate on the stack, and so
// it can be targeted by Dismiss.
func WithID(id string) ModalOption {
return func(mo *Modal) { mo.ID = id }
}
// WithModalStyle overrides the Model's default Styles for this modal alone,
// for a one-off custom look instead of the shared theme.
func WithModalStyle(s Styles) ModalOption {
return func(mo *Modal) { mo.Style = &s }
}
func newModal(title, content string, opts ...ModalOption) Modal {
func newModal(title string, content tea.Model, opts ...ModalOption) Modal {
mo := Modal{Title: title, Content: content}
for _, opt := range opts {
opt(&mo)
@@ -44,27 +38,43 @@ func newModal(title, content string, opts ...ModalOption) Modal {
// produces, same as any other child model.
type ShowMsg struct{ Modal Modal }
// Show returns a tea.Cmd that opens a new modal on top of the stack:
// Show returns a tea.Cmd that opens a new modal on top of the stack. content
// is a model, so a modal can hold anything a pane can - a form, a list, a
// confirmation that reports back with its own tea.Msg:
//
// return m, modal.Show("Delete file?", "This can't be undone.")
func Show(title, content string, opts ...ModalOption) tea.Cmd {
// return m, modal.Show("Delete file?", modal.Text("This can't be undone."))
// return m, modal.Show("Rename", newRenameForm(path))
//
// The content's Init runs when the modal opens, and it receives every message
// while it's the topmost modal (see Model.Update).
func Show(title string, content tea.Model, opts ...ModalOption) tea.Cmd {
mo := newModal(title, content, opts...)
return func() tea.Msg { return ShowMsg{Modal: mo} }
}
// DismissMsg closes the modal identified by ID, or the topmost modal if ID
// is empty.
type DismissMsg struct{ ID string }
// DismissMsg closes the topmost modal. The stack is a plain LIFO: a modal is
// closed by being on top, never by being named.
type DismissMsg struct{}
// Dismiss returns a tea.Cmd that closes the modal identified by id. Only
// useful for modals shown with WithID.
func Dismiss(id string) tea.Cmd {
return func() tea.Msg { return DismissMsg{ID: id} }
}
// Close returns a tea.Cmd that closes the topmost modal, whatever its id -
// the common case of a host handling esc/"cancel" without needing to know
// which modal is currently on top.
// Close returns a tea.Cmd that closes the topmost modal - which is also the
// only one that can act (see Model.Update), so a content model closes itself
// by returning it:
//
// return c, modal.Close()
func Close() tea.Cmd {
return func() tea.Msg { return DismissMsg{} }
}
// text is a model wrapping a fixed string: a modal body with nothing to
// update.
type text string
func (t text) Init() tea.Cmd { return nil }
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
func (t text) View() tea.View { return tea.NewView(string(t)) }
// Text wraps a plain string as modal content, for the common modal that has
// nothing to interact with:
//
// modal.Show("About", modal.Text("v1.0\n\nesc to close"))
func Text(s string) tea.Model { return text(s) }
+41 -44
View File
@@ -4,16 +4,17 @@
// up rendering it.
//
// It composites over an already-rendered string (see Model.Render), so it
// has no dependency on github.com/anotherhadi/ilovetui/layout: the same
// Model works whether the host uses layout for its main content or not (see
// makes no assumption at all about how the host builds that string: the same
// Model works whatever the host uses to lay out its main content (see
// Model.Render and Model.View).
//
// A modal's content is a model, not a string: it is updated while it's on
// top of the stack, so it can hold anything a pane can - a form, a list, a
// confirmation reporting its answer back with its own tea.Msg. See Show and
// Text.
package modal
import (
"fmt"
tea "charm.land/bubbletea/v2"
)
import tea "charm.land/bubbletea/v2"
// Model holds the currently open modals (a stack: the most recently shown
// is drawn on top, everything beneath it - the background and any earlier
@@ -21,7 +22,6 @@ import (
// they share. Build one with New.
type Model struct {
modals []Modal
nextID int
maxWidth int
maxHeight int
styles Styles
@@ -68,58 +68,55 @@ func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case ShowMsg:
return m.show(msg.Modal), nil
return m.show(msg.Modal)
case DismissMsg:
return m.remove(msg.ID), nil
return m.pop(), nil
}
return m.updateTop(msg)
}
// updateTop forwards msg to the topmost modal's content - the only one the
// user can interact with, everything beneath it being dimmed (see Render). A
// modal deeper in the stack is frozen until the ones above it close.
//
// This is what lets modal content be a real model: it gets the key presses,
// the ticks and the results of its own commands, and can report back to the
// rest of the program with a tea.Msg of its own.
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
i := len(m.modals) - 1
if i < 0 || m.modals[i].Content == nil {
return m, nil
}
var cmd tea.Cmd
m.modals[i].Content, cmd = m.modals[i].Content.Update(msg)
return m, cmd
}
// Open reports whether at least one modal is currently shown - handy for a
// host that wants to route key presses to the modal (e.g. esc to dismiss,
// enter to confirm) instead of its normal UI while one is open.
func (m Model) Open() bool { return len(m.modals) > 0 }
// TopID returns the ID of the topmost (currently interactive) modal, or ""
// if none is open.
func (m Model) TopID() string {
if len(m.modals) == 0 {
return ""
}
return m.modals[len(m.modals)-1].ID
}
// show pushes or replaces (see WithID) a modal on top of the stack.
func (m Model) show(mo Modal) Model {
if mo.ID == "" {
mo.ID = fmt.Sprintf("modal-%d", m.nextID)
m.nextID++
}
for i, existing := range m.modals {
if existing.ID == mo.ID {
m.modals[i] = mo
return m
}
}
// show pushes a modal on top of the stack and returns its content's Init - a
// modal's body starts the same way any other model does.
func (m Model) show(mo Modal) (Model, tea.Cmd) {
m.modals = append(m.modals, mo)
return m
return m, initContent(mo)
}
// remove closes the modal identified by id, or the topmost one if id is
// empty (see Close).
func (m Model) remove(id string) Model {
// initContent is mo's content's Init, or nil for a modal without content.
func initContent(mo Modal) tea.Cmd {
if mo.Content == nil {
return nil
}
return mo.Content.Init()
}
// pop closes the topmost modal (see Close).
func (m Model) pop() Model {
if len(m.modals) == 0 {
return m
}
if id == "" {
m.modals = m.modals[:len(m.modals)-1]
return m
}
for i, mo := range m.modals {
if mo.ID == id {
m.modals = append(m.modals[:i], m.modals[i+1:]...)
break
}
}
return m
}
+16 -4
View File
@@ -79,8 +79,9 @@ func (m Model) renderBox(mo Modal, s Styles, bgW, bgH int) string {
maxW := effectiveMax(m.maxWidth, bgW-2*margin)
maxH := effectiveMax(m.maxHeight, bgH-2*margin)
inner := contentWidth(mo, maxW)
content := s.Content.Width(inner).Render(mo.Content)
body := contentView(mo)
inner := contentWidth(body, mo.Title, maxW)
content := s.Content.Width(inner).Render(body)
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
boxHeight := min(lipgloss.Height(content)+2, maxH)
@@ -88,11 +89,22 @@ func (m Model) renderBox(mo Modal, s Styles, bgW, bgH int) string {
return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight)
}
// contentView is the modal body's rendered string, or "" for a modal without
// content. The box shrinks to fit whatever the content model draws, so a
// content that wants a specific size sets it on itself - the modal only ever
// sees the result.
func contentView(mo Modal) string {
if mo.Content == nil {
return ""
}
return mo.Content.View().Content
}
// contentWidth is the modal's inner (border/padding excluded) width: its
// natural size (long enough for the widest line of title/content), capped
// at maxWidth.
func contentWidth(mo Modal, maxWidth int) int {
natural := max(naturalWidth(mo.Content), lipgloss.Width(mo.Title), 1)
func contentWidth(body, title string, maxWidth int) int {
natural := max(naturalWidth(body), lipgloss.Width(title), 1)
capped := max(maxWidth-4, 1)
return min(natural, capped)
}
+3 -3
View File
@@ -4,14 +4,14 @@ Toast-style notifications, triggered from anywhere in a bubbletea program via an
`tea.Msg` (`ShowMsg`/`Show`) rather than a direct reference to the `Model` that ends up rendering
them - standard Elm architecture, no IPC between processes.
It composites over an already-rendered string, so it has no dependency on
`github.com/anotherhadi/ilovetui/layout`: the same `Model` works whether the host uses `layout`
for its main content or not.
It composites over an already-rendered string, so it makes no assumption about how the host
builds that string: the same `Model` works whatever the host uses to lay out its main content.
## Quick start
```go
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/notification"
)
+2 -2
View File
@@ -3,8 +3,8 @@
// rather than a direct reference to the Model that ends up rendering them.
//
// It composites over an already-rendered string (see Model.Render), so it
// has no dependency on github.com/anotherhadi/ilovetui/layout: the same
// Model works whether the host uses layout for its main content or not (see
// makes no assumption at all about how the host builds that string: the same
// Model works whatever the host uses to lay out its main content (see
// Model.Render and Model.View).
package notification