init layout, notifications, tabs & more

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-08-17 22:09:32 +02:00
parent f3a1877832
commit 5f99fa7521
68 changed files with 6375 additions and 330 deletions
+58 -15
View File
@@ -1,22 +1,28 @@
# Ilovetui
A minimal Go library that provides a shared [Base16](https://github.com/tinted-theming/home) color theme for terminal UIs built with [bubbletea](https://github.com/charmbracelet/bubbletea) and [lipgloss](https://github.com/charmbracelet/lipgloss).
A minimal Go library that provides a shared [Base16](https://github.com/tinted-theming/home) color theme for terminal UIs built with [bubbletea](https://github.com/charmbracelet/bubbletea) and [lipgloss](https://github.com/charmbracelet/lipgloss), plus a small collection of Bubble Tea v2 components on top of it.
The idea is simple: instead of every TUI app managing its own colors, they all share one theme file so the user customizes once and every app looks consistent.
## Packages
- `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).
## How it works
On import, `ilovetui` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (respecting `$XDG_CONFIG_HOME`).
On import, `style` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (respecting `$XDG_CONFIG_HOME`).
If no config exists, it falls back to the embedded default. The active theme is exposed as the package-level variable `S`.
```go
import "github.com/anotherhadi/ilovetui"
import "github.com/anotherhadi/ilovetui/style"
// Use colors directly
style := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
s := lipgloss.NewStyle().Foreground(style.S.Primary)
// Use pre-built panel styles
box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Title", content, w, h)
box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h)
```
No setup required — just import and use.
@@ -44,17 +50,17 @@ The theme follows the [Base16](https://github.com/tinted-theming/home) standard
| `Warning` | Base09 | Integers / Constants / Booleans |
| `Error` | Base08 | Variables / Errors / Diff Deleted |
The default theme is `./default.yaml`. Copy it and edit to customize:
The default theme is `style/default.yaml`. Copy it and edit to customize:
```sh
mkdir -p ~/.config/ilovetui
cp $(go env GOPATH)/pkg/mod/github.com/anotherhadi/ilovetui*/default.yaml ~/.config/ilovetui/config.yaml
cp $(go env GOPATH)/pkg/mod/github.com/anotherhadi/ilovetui*/style/default.yaml ~/.config/ilovetui/config.yaml
```
Or let your app write it on first run:
```go
ilovetui.WriteDefaultConfig(ilovetui.DefaultConfigPath())
style.WriteDefaultConfig(style.DefaultConfigPath())
```
## Pre-built styles
@@ -72,22 +78,59 @@ ilovetui.WriteDefaultConfig(ilovetui.DefaultConfigPath())
```go
// Inner usable height of a bordered panel with outer height h
inner := ilovetui.ContentHeight(h)
inner := style.ContentHeight(h)
// Render a box with a title embedded in the top border
box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Header", content, w, h)
box := style.RenderWithTitle(style.S.PanelFocused, "Header", content, w, h)
```
## API
```go
ilovetui.Init() // Reload from default config path
ilovetui.InitFrom(path string) // Reload from a custom path
ilovetui.InitFromBytes(data []byte) // Parse raw YAML
ilovetui.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml
ilovetui.WriteDefaultConfig(path) // Write default config if missing
style.Init() // Reload from default config path
style.InitFrom(path string) // Reload from a custom path
style.InitFromBytes(data []byte) // Parse raw YAML
style.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml
style.WriteDefaultConfig(path) // Write default config if missing
```
## Themed official components
```go
import "github.com/anotherhadi/ilovetui/bubbles"
h := bubbles.NewHelp()
ta := bubbles.NewTextarea(false)
ti := bubbles.NewTextInput()
l := bubbles.NewList(items, width, height)
t := bubbles.NewTable()
fp := bubbles.NewFilePicker()
sp := bubbles.NewSpinner()
pr := bubbles.NewProgress()
pg := bubbles.NewPaginator()
vp := bubbles.NewViewport()
```
Each constructor mirrors the official component's own `New`, then applies `style.S` on top. Where the
official `New` takes options (`spinner`, `table`, `progress`), they're forwarded before the theme is
applied, so you can still customize behavior; anything you pass that also sets colors will be overridden
by the theme afterward.
## Custom components
```go
import "github.com/anotherhadi/ilovetui/tabs"
t := tabs.New([]tabs.Item{{Title: "First", Model: firstPane}, {Title: "Second", Model: secondPane}})
```
`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
[`notification`](notification/README.md) (toast notifications).
## Projects using ilovetui
- [anotherhadi/spilltea](https://github.com/anotherhadi/spilltea): A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players. Think Burp Suite or Caido, but entirely in your terminal.
-44
View File
@@ -1,44 +0,0 @@
package ilovetui
import (
"strings"
"charm.land/lipgloss/v2"
)
// ContentHeight returns the usable inner height for a bordered panel of totalH rows.
func ContentHeight(totalH int) int {
h := totalH - 2
if h < 0 {
return 0
}
return h
}
// RenderWithTitle renders a bordered box with a title embedded in the top border.
// title may contain ANSI color codes. width and height are the total outer dimensions.
//
// Example:
//
// box := ilovetui.RenderWithTitle(theme.Styles.PanelFocused, "Header", content, w, h)
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
boxH := height - 1
if contentH := boxH - 1; contentH > 0 {
lines := strings.Split(content, "\n")
if len(lines) > contentH {
content = strings.Join(lines[:contentH], "\n")
}
}
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
titleW := lipgloss.Width(title)
fillW := boxWidth - titleW - 4 // 4 = "╭ " + " " + "╮"
if fillW < 0 {
fillW = 0
}
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
topLine := bc.Render("╭ ") + bc.Render(title) + bc.Render(" "+strings.Repeat("─", fillW)+"╮")
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
}
+6
View File
@@ -0,0 +1,6 @@
// Package bubbles provides themed constructors for official bubbles/v2
// components (help, textarea, textinput, list, table, filepicker, spinner,
// progress, paginator, viewport). Each constructor mirrors the official
// component's own New function, then applies the shared ilovetui/style
// theme on top.
package bubbles
+26
View File
@@ -0,0 +1,26 @@
package bubbles
import (
"charm.land/bubbles/v2/filepicker"
"github.com/anotherhadi/ilovetui/style"
)
// NewFilePicker returns a filepicker.Model styled with the active theme.
func NewFilePicker() filepicker.Model {
f := filepicker.New()
s := filepicker.DefaultStyles()
s.Cursor = s.Cursor.Foreground(style.S.Primary)
s.DisabledCursor = s.DisabledCursor.Foreground(style.S.Subtle)
s.Symlink = s.Symlink.Foreground(style.S.Warning)
s.Directory = s.Directory.Foreground(style.S.Primary)
s.File = s.File.Foreground(style.S.Text)
s.DisabledFile = s.DisabledFile.Foreground(style.S.Subtle)
s.Permission = s.Permission.Foreground(style.S.Muted)
s.Selected = s.Selected.Foreground(style.S.Primary)
s.DisabledSelected = s.DisabledSelected.Foreground(style.S.Subtle)
s.FileSize = s.FileSize.Foreground(style.S.Muted)
s.EmptyDirectory = s.EmptyDirectory.Foreground(style.S.Subtle)
f.Styles = s
return f
}
+21
View File
@@ -0,0 +1,21 @@
package bubbles
import (
"charm.land/bubbles/v2/help"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// NewHelp returns a help.Model styled with the active theme.
func NewHelp() help.Model {
h := help.New()
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(style.S.Primary)
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
h.Styles.FullKey = lipgloss.NewStyle().Foreground(style.S.Primary)
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(style.S.Subtle)
return h
}
+13
View File
@@ -0,0 +1,13 @@
package bubbles
import "strings"
// SplitH splits totalHeight into top and bottom sections, accounting for the
// height of statusBar (measured by newline count).
func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
statusH := strings.Count(statusBar, "\n") + 1
available := totalHeight - statusH
top = int(float64(available) * ratio)
bottom = available - top
return
}
+54
View File
@@ -0,0 +1,54 @@
package bubbles
import (
"charm.land/bubbles/v2/list"
"github.com/anotherhadi/ilovetui/style"
)
// NewList returns a list.Model styled with the active theme, using
// NewDefaultDelegate for item rendering.
func NewList(items []list.Item, width, height int) list.Model {
m := list.New(items, NewDefaultDelegate(), width, height)
m.Styles = themedListStyles()
return m
}
// themedListStyles builds list.Styles from the active theme.
func themedListStyles() list.Styles {
// isDark only affects a couple of fallback colors below, all of which
// are overridden regardless.
s := list.DefaultStyles(true)
s.Title = s.Title.Background(style.S.Primary).Foreground(style.S.Background)
s.Spinner = s.Spinner.Foreground(style.S.Primary)
s.Filter = themedTextInputStyles()
s.DefaultFilterCharacterMatch = s.DefaultFilterCharacterMatch.Foreground(style.S.Primary)
s.StatusBar = s.StatusBar.Foreground(style.S.Muted)
s.StatusEmpty = s.StatusEmpty.Foreground(style.S.Subtle)
s.StatusBarActiveFilter = s.StatusBarActiveFilter.Foreground(style.S.Text)
s.StatusBarFilterCount = s.StatusBarFilterCount.Foreground(style.S.Subtle)
s.NoItems = s.NoItems.Foreground(style.S.Subtle)
s.ArabicPagination = s.ArabicPagination.Foreground(style.S.Subtle)
s.ActivePaginationDot = s.ActivePaginationDot.Foreground(style.S.Primary)
s.InactivePaginationDot = s.InactivePaginationDot.Foreground(style.S.Subtle)
s.DividerDot = s.DividerDot.Foreground(style.S.Subtle)
return s
}
// NewDefaultDelegate returns a list.DefaultDelegate styled with the active
// theme, for use with NewList or a custom list.Model.
func NewDefaultDelegate() list.DefaultDelegate {
d := list.NewDefaultDelegate()
d.Styles.NormalTitle = d.Styles.NormalTitle.Foreground(style.S.Text)
d.Styles.NormalDesc = d.Styles.NormalDesc.Foreground(style.S.Muted)
d.Styles.SelectedTitle = d.Styles.SelectedTitle.
BorderForeground(style.S.Primary).
Foreground(style.S.Primary)
d.Styles.SelectedDesc = d.Styles.SelectedDesc.
BorderForeground(style.S.Primary).
Foreground(style.S.Primary)
d.Styles.DimmedTitle = d.Styles.DimmedTitle.Foreground(style.S.Subtle)
d.Styles.DimmedDesc = d.Styles.DimmedDesc.Foreground(style.S.SubtleBg)
d.Styles.FilterMatch = d.Styles.FilterMatch.Foreground(style.S.Primary)
return d
}
+17
View File
@@ -0,0 +1,17 @@
package bubbles
import (
"charm.land/bubbles/v2/paginator"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// NewPaginator returns a dot-style paginator.Model styled with the active theme.
func NewPaginator() paginator.Model {
p := paginator.New()
p.Type = paginator.Dots
p.ActiveDot = lipgloss.NewStyle().Foreground(style.S.Primary).Render("•")
p.InactiveDot = lipgloss.NewStyle().Foreground(style.S.Subtle).Render("•")
return p
}
+19
View File
@@ -0,0 +1,19 @@
package bubbles
import (
"charm.land/bubbles/v2/progress"
"github.com/anotherhadi/ilovetui/style"
)
// NewProgress returns a progress.Model with a themed fill, blending from
// style.S.Subtle to style.S.Primary. Any opts are forwarded to progress.New;
// pass progress.WithColors to override the default blend.
func NewProgress(opts ...progress.Option) progress.Model {
allOpts := append([]progress.Option{
progress.WithColors(style.S.Subtle, style.S.Primary),
}, opts...)
p := progress.New(allOpts...)
p.EmptyColor = style.S.SubtleBg
return p
}
+16
View File
@@ -0,0 +1,16 @@
package bubbles
import (
"charm.land/bubbles/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// NewSpinner returns a spinner.Model styled with the active theme. Any opts
// are forwarded to spinner.New before the theme is applied.
func NewSpinner(opts ...spinner.Option) spinner.Model {
s := spinner.New(opts...)
s.Style = lipgloss.NewStyle().Foreground(style.S.Primary)
return s
}
+19
View File
@@ -0,0 +1,19 @@
package bubbles
import (
"charm.land/bubbles/v2/table"
"github.com/anotherhadi/ilovetui/style"
)
// NewTable returns a table.Model styled with the active theme. Any opts are
// forwarded to table.New before the theme is applied.
func NewTable(opts ...table.Option) table.Model {
t := table.New(opts...)
s := table.DefaultStyles()
s.Header = s.Header.Foreground(style.S.Primary)
s.Cell = s.Cell.Foreground(style.S.Text)
s.Selected = s.Selected.Foreground(style.S.Primary)
t.SetStyles(s)
return t
}
+33
View File
@@ -0,0 +1,33 @@
package bubbles
import (
"charm.land/bubbles/v2/textarea"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// NewTextarea returns a textarea.Model styled with the active theme.
// Set showLineNumbers to true to display line numbers in the gutter.
func NewTextarea(showLineNumbers bool) textarea.Model {
ta := textarea.New()
ta.Prompt = ""
ta.ShowLineNumbers = showLineNumbers
ta.CharLimit = 0
ta.EndOfBufferCharacter = '~'
ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.Text = lipgloss.NewStyle().Foreground(style.S.Text)
ts.Focused.CursorLine = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Text)
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Primary).Bold(true)
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(style.S.Subtle)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(style.S.Muted)
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
ta.SetStyles(ts)
return ta
}
+32
View File
@@ -0,0 +1,32 @@
package bubbles
import (
"charm.land/bubbles/v2/textinput"
"github.com/anotherhadi/ilovetui/style"
)
// NewTextInput returns a textinput.Model styled with the active theme.
func NewTextInput() textinput.Model {
t := textinput.New()
t.SetStyles(themedTextInputStyles())
return t
}
// themedTextInputStyles builds textinput.Styles from the active theme.
// Shared with NewList, which themes its filter input the same way.
func themedTextInputStyles() textinput.Styles {
// isDark only affects textinput.DefaultStyles' Blurred.Text color, which
// we override below regardless.
s := textinput.DefaultStyles(true)
s.Focused.Text = s.Focused.Text.Foreground(style.S.Text)
s.Focused.Placeholder = s.Focused.Placeholder.Foreground(style.S.Subtle)
s.Focused.Suggestion = s.Focused.Suggestion.Foreground(style.S.Subtle)
s.Focused.Prompt = s.Focused.Prompt.Foreground(style.S.Primary)
s.Blurred.Text = s.Blurred.Text.Foreground(style.S.Muted)
s.Blurred.Placeholder = s.Blurred.Placeholder.Foreground(style.S.Subtle)
s.Blurred.Suggestion = s.Blurred.Suggestion.Foreground(style.S.Subtle)
s.Blurred.Prompt = s.Blurred.Prompt.Foreground(style.S.Subtle)
s.Cursor.Color = style.S.Primary
return s
}
+38
View File
@@ -0,0 +1,38 @@
package bubbles
import (
"strings"
"charm.land/bubbles/v2/viewport"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// NewViewport returns a viewport.Model with mouse wheel disabled.
func NewViewport() viewport.Model {
vp := viewport.New()
vp.MouseWheelEnabled = false
return vp
}
// ViewportView renders the viewport and appends a subtle scroll indicator
// on the last visible line when the user has not reached the bottom.
func ViewportView(vp *viewport.Model) string {
v := vp.View()
if vp.AtBottom() {
return v
}
lines := strings.Split(v, "\n")
if len(lines) == 0 {
return v
}
arrow := lipgloss.NewStyle().Foreground(style.S.Subtle).Render("↓")
arrowW := lipgloss.Width(arrow)
inner := vp.Width() - 2*arrowW
if inner < 0 {
inner = 0
}
lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow
return strings.Join(lines, "\n")
}
-96
View File
@@ -1,96 +0,0 @@
package ilovetui
import (
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
"charm.land/lipgloss/v2"
)
// NewHelp returns a help.Model styled with the active theme.
func NewHelp() help.Model {
h := help.New()
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(S.Primary)
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(S.Muted)
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
h.Styles.FullKey = lipgloss.NewStyle().Foreground(S.Primary)
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(S.Muted)
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(S.Subtle)
return h
}
// NewTextarea returns a textarea.Model styled with the active theme.
// Set showLineNumbers to true to display line numbers in the gutter.
func NewTextarea(showLineNumbers bool) textarea.Model {
ta := textarea.New()
ta.Prompt = ""
ta.ShowLineNumbers = showLineNumbers
ta.CharLimit = 0
ta.EndOfBufferCharacter = '~'
ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.Text = lipgloss.NewStyle().Foreground(S.Text)
ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text)
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Primary).Bold(true)
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.Muted)
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ta.SetStyles(ts)
return ta
}
// NewPaginator returns a dot-style paginator.Model styled with the active theme.
func NewPaginator() paginator.Model {
p := paginator.New()
p.Type = paginator.Dots
p.ActiveDot = S.PagerDotActive
p.InactiveDot = S.PagerDotInactive
return p
}
// SplitH splits totalHeight into top and bottom sections, accounting for the
// height of statusBar (measured by newline count).
func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
statusH := strings.Count(statusBar, "\n") + 1
available := totalHeight - statusH
top = int(float64(available) * ratio)
bottom = available - top
return
}
// NewViewport returns a viewport.Model with mouse wheel disabled.
func NewViewport() viewport.Model {
vp := viewport.New()
vp.MouseWheelEnabled = false
return vp
}
// ViewportView renders the viewport and appends a subtle scroll indicator
// on the last visible line when the user has not reached the bottom.
func ViewportView(vp *viewport.Model) string {
v := vp.View()
if vp.AtBottom() {
return v
}
lines := strings.Split(v, "\n")
if len(lines) == 0 {
return v
}
arrow := lipgloss.NewStyle().Foreground(S.Subtle).Render("↓")
arrowW := lipgloss.Width(arrow)
inner := vp.Width() - 2*arrowW
if inner < 0 {
inner = 0
}
lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow
return strings.Join(lines, "\n")
}
-51
View File
@@ -1,51 +0,0 @@
package ilovetui
type colorsYAML struct {
Base00 string `yaml:"base00"`
Base01 string `yaml:"base01"`
Base02 string `yaml:"base02"`
Base03 string `yaml:"base03"`
Base04 string `yaml:"base04"`
Base05 string `yaml:"base05"`
Base06 string `yaml:"base06"`
Base07 string `yaml:"base07"`
Base08 string `yaml:"base08"`
Base09 string `yaml:"base09"`
Base0A string `yaml:"base0a"`
Base0B string `yaml:"base0b"`
Base0C string `yaml:"base0c"`
Base0D string `yaml:"base0d"`
Base0E string `yaml:"base0e"`
Base0F string `yaml:"base0f"`
}
type configYAML struct {
Colors colorsYAML `yaml:"colors"`
}
func mergeColors(base, user colorsYAML) colorsYAML {
pick := func(b, u string) string {
if u != "" {
return u
}
return b
}
return colorsYAML{
Base00: pick(base.Base00, user.Base00),
Base01: pick(base.Base01, user.Base01),
Base02: pick(base.Base02, user.Base02),
Base03: pick(base.Base03, user.Base03),
Base04: pick(base.Base04, user.Base04),
Base05: pick(base.Base05, user.Base05),
Base06: pick(base.Base06, user.Base06),
Base07: pick(base.Base07, user.Base07),
Base08: pick(base.Base08, user.Base08),
Base09: pick(base.Base09, user.Base09),
Base0A: pick(base.Base0A, user.Base0A),
Base0B: pick(base.Base0B, user.Base0B),
Base0C: pick(base.Base0C, user.Base0C),
Base0D: pick(base.Base0D, user.Base0D),
Base0E: pick(base.Base0E, user.Base0E),
Base0F: pick(base.Base0F, user.Base0F),
}
}
+103
View File
@@ -0,0 +1,103 @@
// 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
@@ -0,0 +1,94 @@
// 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
@@ -0,0 +1,116 @@
// 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
@@ -0,0 +1,88 @@
// 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
@@ -0,0 +1,109 @@
// 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
@@ -0,0 +1,99 @@
// 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
@@ -0,0 +1,101 @@
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
@@ -0,0 +1,111 @@
// 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
@@ -0,0 +1,136 @@
// 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
@@ -0,0 +1,132 @@
// 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
@@ -0,0 +1,107 @@
// 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)
}
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/style"
)
const confirmID = "confirm"
type model struct {
m modal.Model
width, height int
}
func newModel() model {
return model{m: modal.New()}
}
func (m model) Init() tea.Cmd { return m.m.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 "m":
return m, modal.Show("Delete file?", "This can't be undone.\n\ny: confirm esc: cancel",
modal.WithID(confirmID))
case "n":
if m.m.Open() {
return m, modal.Show("Really sure?", "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)
}
}
}
var cmd tea.Cmd
m.m, cmd = m.m.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 modal.")
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"m: open modal n: open nested modal y: confirm esc: cancel q: quit")
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", body, "", help))
view := tea.NewView(m.m.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)
}
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/ilovetui/style"
)
var positions = []struct {
name string
pos notification.Position
}{
{"top", notification.Top},
{"top-left", notification.TopLeft},
{"top-right", notification.TopRight},
{"bottom", notification.Bottom},
{"bottom-left", notification.BottomLeft},
{"bottom-right", notification.BottomRight},
}
const stickyID = "sticky-demo"
type model struct {
notif notification.Model
posIdx int
width, height int
}
func newModel() model {
return model{notif: notification.New(notification.WithPosition(positions[0].pos))}
}
func (m model) Init() tea.Cmd {
return m.notif.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 "1":
return m, notification.Show("Info", "Just so you know.", notification.Info)
case "2":
return m, notification.Show("Success", "Config written to disk.", notification.Success)
case "3":
return m, notification.Show("Warning", "Disk space getting low on /dev/sda1.", notification.Warning)
case "4":
return m, notification.Show("Error", "Failed to reach the remote host.", notification.Error)
case "s":
return m, notification.Show("Sticky", "Stays until you press d.",
notification.Info, notification.WithID(stickyID), notification.WithDuration(0))
case "d":
return m, notification.Dismiss(stickyID)
case "p":
m.posIdx = (m.posIdx + 1) % len(positions)
m.notif = notification.New(notification.WithPosition(positions[m.posIdx].pos))
return m, m.notif.Init()
}
}
var cmd tea.Cmd
m.notif, cmd = m.notif.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"1-4: info/success/warning/error s: sticky d: dismiss sticky p: position (" +
positions[m.posIdx].name + ") q: quit")
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, help)
view := tea.NewView(m.notif.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)
}
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/tabs"
)
// pane is a minimal tabs.Tab implementation. It keeps its own counter to
// show that each tab's model has independent state that persists across
// switches, and its own width/height to show how a host propagates size
// down to a wrapped Tab (see model.Update's tea.WindowSizeMsg case).
type pane struct {
name string
count int
width, height int
}
func newPane(name string) pane {
return pane{name: name}
}
func (p pane) Init() tea.Cmd {
return nil
}
func (p pane) Update(msg tea.Msg) (tabs.Tab, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.width, p.height = msg.Width, msg.Height
case tea.KeyPressMsg:
if msg.String() == "+" {
p.count++
}
}
return p, nil
}
func (p pane) View() string {
return fmt.Sprintf("%s\n\npress + to increment: %d\n(content area: %dx%d)", p.name, p.count, p.width, p.height)
}
var docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2)
type model struct {
tabs tabs.Model
}
func newModel() model {
items := []tabs.Item{
{Title: "Lip Gloss", Model: newPane("Lip Gloss")},
{Title: "Blush", Model: newPane("Blush")},
{Title: "Eye Shadow", Model: newPane("Eye Shadow")},
{Title: "Mascara", Model: newPane("Mascara")},
{Title: "Foundation", Model: newPane("Foundation")},
}
return model{tabs: tabs.New(items)}
}
func (m model) Init() tea.Cmd {
return m.tabs.Init()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" || msg.String() == "q" {
return m, tea.Quit
}
case tea.WindowSizeMsg:
// Size the tabs component to fill the terminal, net of docStyle's own
// frame. The tab bar itself keeps its natural width; Content stretches.
m.tabs.SetSize(
msg.Width-docStyle.GetHorizontalFrameSize(),
msg.Height-docStyle.GetVerticalFrameSize(),
)
// tabs has no generic way to size an arbitrary Tab itself, so forward
// the actual usable content area as a WindowSizeMsg: tabs.Update
// already routes non-key messages to the active item's Update, so
// this reaches pane.Update's own tea.WindowSizeMsg case above.
var cmd tea.Cmd
m.tabs, cmd = m.tabs.Update(tea.WindowSizeMsg{
Width: m.tabs.ContentWidth(),
Height: m.tabs.ContentHeight(),
})
return m, cmd
}
var cmd tea.Cmd
m.tabs, cmd = m.tabs.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(docStyle.Render(m.tabs.View()))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+5 -2
View File
@@ -4,19 +4,20 @@ go 1.25.8
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.2
charm.land/glamour/v2 v2.0.0
charm.land/lipgloss/v2 v2.0.3
github.com/charmbracelet/x/ansi v0.11.7
gopkg.in/yaml.v3 v3.0.1
)
require (
charm.land/bubbletea/v2 v2.0.2 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -24,12 +25,14 @@ require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
+8
View File
@@ -22,6 +22,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
@@ -42,10 +44,14 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
@@ -56,6 +62,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-115
View File
@@ -1,116 +1 @@
// Package ilovetui provides a shared Base16 color theme for bubbletea/lipgloss
// applications. The theme is loaded automatically on import from
// ~/.config/ilovetui/config.yaml (falling back to the embedded
// default config). Access colors and styles via the package-level variable S.
//
// import "github.com/anotherhadi/ilovetui"
//
// style := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
// box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Title", content, w, h)
package ilovetui
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
//go:embed default.yaml
var DefaultConfig []byte
// S is the active theme. It is populated automatically at import time and can
// be reloaded at any point by calling Init, InitFrom, or InitFromBytes.
var S Styles
func init() {
path := DefaultConfigPath()
if data, err := os.ReadFile(path); err == nil {
if s, err := stylesFromBytes(data); err == nil {
S = s
return
}
}
// Silent fallback: embedded default always works.
s, _ := stylesFromBytes(DefaultConfig)
S = s
}
// Init reloads S from the user config file, falling back to the embedded
// default if the file is missing. Returns an error only on parse failures.
func Init() error {
path := DefaultConfigPath()
data, err := os.ReadFile(path)
if err != nil {
s, e := stylesFromBytes(DefaultConfig)
if e != nil {
return e
}
S = s
return nil
}
return InitFromBytes(data)
}
// InitFrom reloads S from an explicit file path.
func InitFrom(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("ilovetui: read config: %w", err)
}
return InitFromBytes(data)
}
// InitFromBytes reloads S from raw YAML. Accepts hex strings with or without
// the leading '#'.
func InitFromBytes(data []byte) error {
s, err := stylesFromBytes(data)
if err != nil {
return err
}
S = s
return nil
}
// WriteDefaultConfig writes the embedded default config to path, creating
// parent directories as needed. No-op if the file already exists.
func WriteDefaultConfig(path string) error {
if _, err := os.Stat(path); err == nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("ilovetui: create config dir: %w", err)
}
if err := os.WriteFile(path, DefaultConfig, 0o600); err != nil {
return fmt.Errorf("ilovetui: write config: %w", err)
}
return nil
}
// DefaultConfigPath returns the canonical user config path,
// respecting $XDG_CONFIG_HOME.
func DefaultConfigPath() string {
return filepath.Join(configDir(), "ilovetui", "config.yaml")
}
func stylesFromBytes(data []byte) (Styles, error) {
var base configYAML
if err := yaml.Unmarshal(DefaultConfig, &base); err != nil {
return Styles{}, fmt.Errorf("ilovetui: parse default config: %w", err)
}
var user configYAML
if err := yaml.Unmarshal(data, &user); err != nil {
return Styles{}, fmt.Errorf("ilovetui: parse config: %w", err)
}
return newStyles(mergeColors(base.Colors, user.Colors)), nil
}
func configDir() string {
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
return dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config")
}
+309
View File
@@ -0,0 +1,309 @@
# layout
Arrange panes in a binary split tree (BSP, tmux/i3-style), navigate between them with
spatial `ctrl+hjkl`, and get a help bar that always reflects whatever's focused - no
matter how deep the tree is, or how many of those splits are themselves other `layout`
trees nested inside a pane.
`layout` only owns geometry, focus and routing. It draws no border and imposes no
style: every pane decides how to render itself for the size and focus state it's given.
## Concepts
Three things:
- **`Pane`** is your content: `Init() tea.Cmd`, `Update(tea.Msg) (Pane, tea.Cmd)`,
`View() string` - the same shape used by every other custom component in this repo.
- **`Node`** is the shape of the tree. `Leaf(id, pane)` is a slot holding one `Pane`,
addressed everywhere else (`SendMsg`, `RequestFocusMsg`, `SplitLeaf`, `CloseLeaf`,
`Resize`) by that `id`. `Split`/`HSplit`/`VSplit` divide space between two child
`Node`s.
- **`Model`** is the running layout: a `Node` tree plus focus, sizing and the help bar.
Build one with `layout.New(root, layout.AsRoot())`.
## Quick start
```go
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/layout"
)
func main() {
root := layout.HSplit(0.3,
layout.Leaf("sidebar", newSidebarPane()),
layout.Leaf("content", newContentPane()),
)
m := layout.New(root, layout.AsRoot())
if err := layout.Run(m); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
```
That's a working two-pane app: `ctrl+h`/`ctrl+l` move focus between "sidebar" and
"content", `?` toggles the help bar, everything resizes with the terminal.
`layout.Run(m, opts ...tea.ProgramOption)` wraps `m` for `tea.NewProgram` and runs it,
alt-screen included. `layout` itself reserves no quit key - deciding how (and whether)
the app quits is the host's call, same as any other app-level policy; see the examples
for the usual `ctrl+c`/`q` pattern.
## Writing a pane
```go
type Pane interface {
Init() tea.Cmd
Update(tea.Msg) (Pane, tea.Cmd)
View() string
}
```
`layout` tells a pane its size and focus state entirely through messages - never assume
either any other way:
- **`SizeMsg{ID, Width, Height}`** whenever the pane's allocated space changes (first
layout, terminal resize, a sibling split/close/resize...). `ID` is the pane's own id,
learned here so it can later fill `RequestFocusMsg.Source` (see below).
- **`FocusMsg{}`** / **`BlurMsg{}`** whenever the pane gains or loses keyboard focus.
Named distinctly from bubbletea's own `tea.FocusMsg`/`tea.BlurMsg`, which are about
terminal focus, not pane focus.
Two rules to actually get right:
- **`View()` must render exactly the last width/height you were told.** `layout`
composes panes side by side with `lipgloss.JoinHorizontal`/`JoinVertical` - if a pane
renders the wrong size, the whole layout visibly misaligns.
- **You own your own chrome.** `layout` never draws a border. `layout.Bordered(focused,
w, h, content)` covers the common case (border color follows focus, via
`style.S.Primary`/`style.S.Subtle`) as an optional helper - draw nothing, or draw
something else entirely, if you want.
## Building the tree
```go
root := layout.HSplit(0.3,
layout.Leaf("sidebar", newSidebarPane()),
layout.Leaf("content", newContentPane()),
)
```
- `layout.Leaf(id, pane)` - one pane, addressed by `id` everywhere else in the API.
- `layout.HSplit(ratio, first, second)` / `layout.VSplit(ratio, first, second)` - divide
space horizontally (side by side) or vertically (stacked), giving `ratio` (0 to 1) to
`first` and the rest to `second`. `layout.Split(id, dir, ratio, first, second)` is the
general form if the split itself needs an `id` (see "Resizing at runtime" below).
Nest freely:
```go
root := layout.HSplit(0.25,
sidebar,
layout.VSplit(0.7,
layout.HSplit(0.5, topLeft, topRight),
bottom,
),
)
```
### Sizing
```go
layout.HSplit(0.3, sidebar, content) // sidebar gets 30%, content the rest
layout.HSplit(0.3, sidebar, content).WithMinimum(20) // 30%, but never below 20 cells
layout.HSplit(0.3, sidebar, content).WithMaximum(40) // 30%, but never above 40 cells
layout.HSplit(0.3, sidebar, content).WithMinimum(20).WithMaximum(20) // fixed at 20 cells
```
`WithMinimum`/`WithMaximum` clamp the resolved size of the split's *first* child (in
cells: columns for a horizontal split, rows for a vertical one). Setting both to the
same value pins it regardless of ratio - the way to get an exact-width sidebar.
## Navigation and the help bar
`ctrl+h`/`ctrl+l`/`ctrl+j`/`ctrl+k` move focus spatially - whichever pane is actually
adjacent in that direction (tmux's `select-pane -L/-D/-U/-R`), not "next in the tree".
`?` toggles the help bar between its short and full form. Both work out of the box.
To customize the keys:
```go
km := layout.DefaultKeyMap()
km.FocusLeft = key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "focus left"))
m := layout.New(root, layout.AsRoot(), layout.WithKeyMap(km))
```
### Making a pane show up in the help bar
Implement `HelpProvider`:
```go
type HelpProvider interface {
HelpBindings() []key.Binding
}
```
`layout` reads it fresh every render from whichever pane is currently focused, at
whatever depth (see "Composing bigger apps" below) - nothing to push or keep in sync. A
pane that doesn't implement it just contributes nothing; the bar still shows `layout`'s
own controls (`?`, `ctrl+hjkl`), it never disappears.
**Only the outermost `Model` should render a help bar.** Pass `layout.AsRoot()` only to
the one actually handed to `Run`/`tea.NewProgram` - an embedded `Model` (see "Composing
bigger apps") built without it contributes its focused pane's bindings to the outer bar
instead of drawing a second one of its own.
## Talking between panes
A pane never holds a reference to another - it returns a `tea.Cmd` and lets `layout`
deliver it, by id, wherever that id lives in the tree (including inside a nested
`layout.Model` - see "Composing bigger apps"):
```go
// deliver an arbitrary message to another pane's Update, regardless of focus
return p, func() tea.Msg {
return layout.SendMsg{Target: "content", Msg: pageChangedMsg{page: selected}}
}
```
An unknown `Target` is silently ignored.
### Asking layout to move focus
```go
return p, func() tea.Msg {
return layout.RequestFocusMsg{Source: p.id, Target: "content"}
}
```
`p.id` is whatever the pane last learned from `SizeMsg.ID`. **Only honored when
`Source` is the pane that currently, genuinely holds focus** - a blurred pane (say,
reacting to a `SendMsg` while in the background) can't redirect focus this way, for
itself or anyone else; only the pane actually focused right now can hand focus off to
another. An unauthorized `Source`, or an unknown `Target`, is silently ignored.
A concrete pattern: a sidebar list drives *and* jumps to a content pane, purely through
messages:
```go
func (p sidebarPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
prevIndex := p.list.Index()
var cmd tea.Cmd
p.list, cmd = p.list.Update(msg)
if p.list.Index() != prevIndex {
selected := p.list.SelectedItem().(page)
cmd = tea.Batch(cmd,
func() tea.Msg { return layout.SendMsg{Target: "content", Msg: pageChangedMsg{selected}} },
func() tea.Msg { return layout.RequestFocusMsg{Source: p.id, Target: "content"} },
)
}
return p, cmd
}
```
## Reshaping the tree at runtime
```go
m, cmd := m.SplitLeaf("editor", layout.Vertical, "terminal", newTerminalPane())
m, cmd = m.CloseLeaf("terminal")
m, cmd = m.Resize("main-split", 0.6)
m, cmd = m.SetPane("workspace", newSecondPagePane())
```
- **`SplitLeaf(id, dir, newID, newModel, opts ...SplitOption)`** splits the leaf `id`
into two: `id` keeps its original pane on one side, `Leaf(newID, newModel)` takes the
other, joined by a 50/50 split by default. Override with `WithSplitID`,
`WithSplitRatio`, `WithSplitMinimum`, `WithSplitMaximum`.
- **`CloseLeaf(id)`** removes a leaf; its sibling takes the place of their parent split.
Focus moves elsewhere automatically if `id` was focused. The tree's own last
remaining leaf can't be closed this way.
- **`Resize(splitID, ratio)`** changes a split's ratio. Only reachable if the split was
given an id, via `(*Node).WithID` (or `WithSplitID` when it was created by
`SplitLeaf`) - `HSplit`/`VSplit` leave it unaddressable (`""`) by default.
- **`SetPane(id, newPane)`** swaps what's rendered at an existing leaf without touching
the tree's shape - the way an app switches its content area between entirely
different pages/sub-apps, each potentially its own package, as opposed to a pane
updating its own internal state in response to a message. The new pane is `Init`'d and
immediately told its size; it's told `FocusMsg` too if `id` currently holds focus,
since whatever it's replacing never will be again.
A pane never holds a reference to the `Model` it lives in, so from inside a pane's own
`Update`, use the message forms instead - `SplitLeafMsg`, `CloseLeafMsg`, `ResizeMsg`,
`SetPaneMsg`:
```go
return p, func() tea.Msg {
return layout.SplitLeafMsg{ID: "editor", Dir: layout.Vertical, NewID: "terminal", NewModel: newTerminalPane()}
}
```
## Composing bigger apps
A `layout.Model` is itself a `Pane` (and a `Navigable`, see below) - embed one inside
another directly, no wrapper needed:
```go
func newWorkspace() layout.Model {
root := layout.VSplit(0.7,
layout.Leaf("editor", newEditorPane()),
layout.Leaf("terminal", newTerminalPane()),
)
return layout.New(root) // no AsRoot(): the outer Model already renders one help bar
}
root := layout.HSplit(0.25,
layout.Leaf("sidebar", newSidebarPane()),
layout.Leaf("workspace", newWorkspace()),
)
m := layout.New(root, layout.AsRoot())
```
Once embedded this way, everything works transparently:
- `ctrl+hjkl` tries moving focus *inside* whatever's currently focused first; only once
that reports being at its own edge does the level above move between its own direct
children instead.
- The help bar keeps showing exactly one bar, reflecting whatever's focused anywhere in
the nesting.
- `SendMsg`/`RequestFocusMsg` reach an id inside a nested tree automatically, without
the outer tree needing to know it's there.
- The nested tree's own shape stays its own business - nothing from outside reaches
into it structurally, only messages cross that boundary.
This all works because `layout.Model` implements `Navigable`:
```go
type Navigable interface {
Pane
Leaves() []LeafRect
MoveFocus(dir FocusDirection) bool
Route(target string, msg tea.Msg) (handled bool, cmd tea.Cmd)
Focus(id string) (handled bool, cmd tea.Cmd)
FocusedHelp() []key.Binding
}
```
A hand-rolled `Pane` never needs to implement this itself - it's what lets one
`layout.Model` recognize *another* `layout.Model` sitting in one of its leaves and
delegate to it, at arbitrary nesting depth. You'll only reach for it directly if you're
building something that itself wants to compose with `layout` the same way `layout`
composes with itself.
## Examples
- `examples/layout/basic` - a 2x2 grid, no custom border, the minimum to get started.
- `examples/layout/bordered` - each pane draws its own border via `layout.Bordered`,
following focus.
- `examples/layout/nested` - a whole `layout.Model` embedded as a pane, ctrl+hjkl and
the help bar both working transparently across the boundary.
- `examples/layout/messaging` - a "control" pane driving and focusing an "editor" pane
by id via `SendMsg`/`RequestFocusMsg`.
- `examples/layout/help` - the help bar changing to match whatever's focused, including
a pane that implements no bindings at all.
+64
View File
@@ -0,0 +1,64 @@
package layout
import "math"
// Rect is an axis-aligned screen region in terminal cells, origin top-left.
type Rect struct {
X, Y, W, H int
}
// LeafRect pairs a Leaf's id with the Rect it was allocated by the most
// recent layout pass.
type LeafRect struct {
ID string
Rect Rect
}
// computeLayout descends the tree rooted at n, allocating r between its
// leaves according to each Split's ratio/min/max, and returns a flat
// registry of every leaf's resolved Rect. Order is deterministic (a
// depth-first walk, first child before second), which is what makes it safe
// to use directly as a stable iteration order elsewhere (Init, routing).
func computeLayout(n *Node, r Rect) []LeafRect {
if n == nil {
return nil
}
if n.leaf {
return []LeafRect{{ID: n.id, Rect: r}}
}
var firstRect, secondRect Rect
if n.dir == Horizontal {
w1 := resolveSize(n, r.W)
firstRect = Rect{X: r.X, Y: r.Y, W: w1, H: r.H}
secondRect = Rect{X: r.X + w1, Y: r.Y, W: r.W - w1, H: r.H}
} else {
h1 := resolveSize(n, r.H)
firstRect = Rect{X: r.X, Y: r.Y, W: r.W, H: h1}
secondRect = Rect{X: r.X, Y: r.Y + h1, W: r.W, H: r.H - h1}
}
leaves := computeLayout(n.first, firstRect)
return append(leaves, computeLayout(n.second, secondRect)...)
}
// resolveSize returns the cell size a Split's first child gets out of total,
// starting from n.ratio and then clamped to [n.min, n.max] (0 on either side
// means that bound is unset). n.min == n.max fixes the size outright,
// regardless of ratio.
func resolveSize(n *Node, total int) int {
size := int(math.Round(n.ratio * float64(total)))
if n.min > 0 && size < n.min {
size = n.min
}
if n.max > 0 && size > n.max {
size = n.max
}
if size < 0 {
size = 0
}
if size > total {
size = total
}
return size
}
+89
View File
@@ -0,0 +1,89 @@
package layout
import "testing"
func TestComputeLayoutEvenSplit(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
leaves := computeLayout(root, Rect{W: 100, H: 40})
want := map[string]Rect{
"a": {X: 0, Y: 0, W: 50, H: 40},
"b": {X: 50, Y: 0, W: 50, H: 40},
}
assertLeafRects(t, leaves, want)
}
func TestComputeLayoutVerticalRatio(t *testing.T) {
root := VSplit(0.25, Leaf("top", newStub()), Leaf("bottom", newStub()))
leaves := computeLayout(root, Rect{W: 80, H: 40})
want := map[string]Rect{
"top": {X: 0, Y: 0, W: 80, H: 10},
"bottom": {X: 0, Y: 10, W: 80, H: 30},
}
assertLeafRects(t, leaves, want)
}
func TestComputeLayoutMinimum(t *testing.T) {
root := HSplit(0.1, Leaf("a", newStub()), Leaf("b", newStub())).WithMinimum(20)
leaves := computeLayout(root, Rect{W: 100, H: 10})
want := map[string]Rect{
"a": {X: 0, Y: 0, W: 20, H: 10},
"b": {X: 20, Y: 0, W: 80, H: 10},
}
assertLeafRects(t, leaves, want)
}
func TestComputeLayoutMaximum(t *testing.T) {
root := HSplit(0.9, Leaf("a", newStub()), Leaf("b", newStub())).WithMaximum(20)
leaves := computeLayout(root, Rect{W: 100, H: 10})
want := map[string]Rect{
"a": {X: 0, Y: 0, W: 20, H: 10},
"b": {X: 20, Y: 0, W: 80, H: 10},
}
assertLeafRects(t, leaves, want)
}
func TestComputeLayoutFixedWhenMinEqualsMax(t *testing.T) {
root := HSplit(0.9, Leaf("a", newStub()), Leaf("b", newStub())).WithMinimum(20).WithMaximum(20)
leaves := computeLayout(root, Rect{W: 100, H: 10})
want := map[string]Rect{
"a": {X: 0, Y: 0, W: 20, H: 10},
"b": {X: 20, Y: 0, W: 80, H: 10},
}
assertLeafRects(t, leaves, want)
}
func TestComputeLayoutNested(t *testing.T) {
root := HSplit(0.3,
Leaf("sidebar", newStub()),
VSplit(0.5, Leaf("top", newStub()), Leaf("bottom", newStub())),
)
leaves := computeLayout(root, Rect{W: 100, H: 20})
want := map[string]Rect{
"sidebar": {X: 0, Y: 0, W: 30, H: 20},
"top": {X: 30, Y: 0, W: 70, H: 10},
"bottom": {X: 30, Y: 10, W: 70, H: 10},
}
assertLeafRects(t, leaves, want)
}
func assertLeafRects(t *testing.T, leaves []LeafRect, want map[string]Rect) {
t.Helper()
if len(leaves) != len(want) {
t.Fatalf("got %d leaves, want %d (%v)", len(leaves), len(want), leaves)
}
for _, lr := range leaves {
wr, ok := want[lr.ID]
if !ok {
t.Fatalf("unexpected leaf %q", lr.ID)
}
if lr.Rect != wr {
t.Errorf("leaf %q: got %+v, want %+v", lr.ID, lr.Rect, wr)
}
}
}
+130
View File
@@ -0,0 +1,130 @@
package layout
import (
"charm.land/bubbles/v2/key"
"charm.land/lipgloss/v2"
)
// HelpProvider is how a pane opts into the help bar: implement it and
// return whatever bindings you want shown while you're focused. A pane
// that doesn't implement it just contributes nothing - the help bar still
// shows layout's own controls (ctrl+hjkl, ?), it never disappears entirely.
type HelpProvider interface {
HelpBindings() []key.Binding
}
// HelpBindings implements HelpProvider by delegating to FocusedHelp, so a
// Model embedded as a HelpProvider behaves identically to one consulted as
// a Navigable.
func (m Model) HelpBindings() []key.Binding {
return m.FocusedHelp()
}
// FocusedHelp implements Navigable: the bindings for whatever pane
// currently has focus, drilling into a nested Navigable automatically until
// it reaches the real pane at the bottom. Returns nil if the focused pane
// (at any depth) implements neither Navigable nor HelpProvider.
func (m Model) FocusedHelp() []key.Binding {
focused, ok := findNode(m.root, m.state.id)
if !ok {
return nil
}
if nav, ok := focused.model.(Navigable); ok {
return nav.FocusedHelp()
}
if hp, ok := focused.model.(HelpProvider); ok {
return hp.HelpBindings()
}
return nil
}
// helpKeyMap adapts a focused pane's flat HelpProvider bindings plus
// layout's own KeyMap into the shape bubbles/help.KeyMap expects.
type helpKeyMap struct {
pane []key.Binding
own KeyMap
width int
}
// ShortHelp implements help.KeyMap. own leads (its ToggleHelp is always
// first, see KeyMap.ShortHelp), the focused pane's own bindings follow.
func (h helpKeyMap) ShortHelp() []key.Binding {
return append(append([]key.Binding{}, h.own.ShortHelp()...), h.pane...)
}
// FullHelp implements help.KeyMap. Rather than the fixed grouping ShortHelp
// mirrors, it flattens every binding into one ordered list - own first, so
// ToggleHelp lands in the first column's first row, then the pane's - and
// re-flows it into as many columns as fit within width, maximizing columns
// to minimize the number of rows the full help view takes.
func (h helpKeyMap) FullHelp() [][]key.Binding {
all := append(append([]key.Binding{}, flattenGroups(h.own.FullHelp())...), h.pane...)
return flowColumns(all, h.width)
}
func flattenGroups(groups [][]key.Binding) []key.Binding {
var flat []key.Binding
for _, g := range groups {
flat = append(flat, g...)
}
return flat
}
// flowColumns arranges bindings into as many columns as fit within width
// without overflowing. It fills each column top-to-bottom before moving to
// the next, which is what bubbles/help.FullHelpView expects: one inner
// slice per column, rendered as a vertical stack.
func flowColumns(bindings []key.Binding, width int) [][]key.Binding {
enabled := make([]key.Binding, 0, len(bindings))
for _, kb := range bindings {
if kb.Enabled() {
enabled = append(enabled, kb)
}
}
if len(enabled) == 0 {
return nil
}
if width <= 0 {
return [][]key.Binding{enabled}
}
for rows := 1; rows <= len(enabled); rows++ {
groups := chunkRows(enabled, rows)
if columnsWidth(groups) <= width {
return groups
}
}
return [][]key.Binding{enabled}
}
// chunkRows splits bindings into groups of at most rows items each, filling
// each group before moving to the next - the column-major order help's
// FullHelpView renders (first group is the leftmost column).
func chunkRows(bindings []key.Binding, rows int) [][]key.Binding {
var groups [][]key.Binding
for i := 0; i < len(bindings); i += rows {
end := min(i+rows, len(bindings))
groups = append(groups, bindings[i:end])
}
return groups
}
// columnsWidth mirrors bubbles/help.FullHelpView's own width accounting:
// each column is as wide as its longest key plus a space plus its longest
// description, columns separated by FullSeparator's width (4 cells, " ").
func columnsWidth(groups [][]key.Binding) int {
const separatorWidth = 4
total := 0
for i, group := range groups {
if i > 0 {
total += separatorWidth
}
var keyWidth, descWidth int
for _, kb := range group {
keyWidth = max(keyWidth, lipgloss.Width(kb.Help().Key))
descWidth = max(descWidth, lipgloss.Width(kb.Help().Desc))
}
total += keyWidth + 1 + descWidth
}
return total
}
+49
View File
@@ -0,0 +1,49 @@
package layout
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
)
// stubPane is a minimal Pane used across the test suite: it records every
// message it receives (and counts Focus/Blur specifically) and can be told
// to return a fixed cmd on its next Update, so tests can assert on both
// sides of the layout <-> pane contract without a real component.
type stubPane struct {
focusN, blurN int
w, h int
msgs []tea.Msg
nextCmd tea.Cmd
help []key.Binding
}
func newStub() *stubPane { return &stubPane{} }
func (p *stubPane) Init() tea.Cmd { return nil }
func (p *stubPane) Update(msg tea.Msg) (Pane, tea.Cmd) {
p.msgs = append(p.msgs, msg)
switch m := msg.(type) {
case FocusMsg:
p.focusN++
case BlurMsg:
p.blurN++
case SizeMsg:
p.w, p.h = m.Width, m.Height
}
cmd := p.nextCmd
p.nextCmd = nil
return p, cmd
}
func (p *stubPane) View() string { return "" }
// HelpBindings implements HelpProvider.
func (p *stubPane) HelpBindings() []key.Binding { return p.help }
func (p *stubPane) last() tea.Msg {
if len(p.msgs) == 0 {
return nil
}
return p.msgs[len(p.msgs)-1]
}
+77
View File
@@ -0,0 +1,77 @@
package layout
import "charm.land/bubbles/v2/key"
// KeyMap holds the bindings Model itself reacts to: directional focus
// movement and toggling the help bar. A pane's own bindings are separate
// (see HelpProvider) - these are only the ones layout intercepts before a
// key ever reaches the focused pane.
type KeyMap struct {
FocusLeft key.Binding
FocusRight key.Binding
FocusUp key.Binding
FocusDown key.Binding
ToggleHelp key.Binding
// ShowFocusInShortHelp also lists ctrl+hjkl on the short help line, not
// just the full one. Off by default (see DefaultKeyMap): the four
// bindings crowd a single line for little gain, since they're always
// one '?' away in the full view regardless. Set it on a KeyMap passed
// to WithKeyMap to opt back in.
ShowFocusInShortHelp bool
}
// DefaultKeyMap returns the standard tmux/vim-style bindings: ctrl+h/j/k/l
// to move focus, ? to toggle the help bar.
func DefaultKeyMap() KeyMap {
return KeyMap{
FocusLeft: key.NewBinding(
key.WithKeys("ctrl+h"),
key.WithHelp("ctrl+h", "focus left"),
),
FocusRight: key.NewBinding(
key.WithKeys("ctrl+l"),
key.WithHelp("ctrl+l", "focus right"),
),
FocusUp: key.NewBinding(
key.WithKeys("ctrl+k"),
key.WithHelp("ctrl+k", "focus up"),
),
FocusDown: key.NewBinding(
key.WithKeys("ctrl+j"),
key.WithHelp("ctrl+j", "focus down"),
),
ToggleHelp: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
}
}
// focusBindings returns the four directional bindings in reading order.
func (k KeyMap) focusBindings() []key.Binding {
return []key.Binding{k.FocusLeft, k.FocusDown, k.FocusUp, k.FocusRight}
}
// ShortHelp implements help.KeyMap so KeyMap can be fed to bubbles/help
// directly for layout's own controls. ToggleHelp always leads - see
// helpKeyMap.ShortHelp, which relies on that to put '?' first in the
// composed bar too.
func (k KeyMap) ShortHelp() []key.Binding {
bindings := []key.Binding{k.ToggleHelp}
if k.ShowFocusInShortHelp {
bindings = append(bindings, k.focusBindings()...)
}
return bindings
}
// FullHelp implements help.KeyMap. ToggleHelp always leads, same reasoning
// as ShortHelp; the focus bindings show here unconditionally, regardless of
// ShowFocusInShortHelp.
func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.ToggleHelp},
{k.FocusLeft, k.FocusRight},
{k.FocusUp, k.FocusDown},
}
}
+542
View File
@@ -0,0 +1,542 @@
// Package layout arranges Pane content in a binary split tree (BSP,
// tmux/i3-style), with spatial ctrl+hjkl focus navigation, message routing
// between panes by id, and a help bar that always reflects whatever pane is
// currently focused, however deep it's nested. It owns geometry, focus and
// routing only - it draws no border and imposes no style: each pane decides
// how to render itself for the size and focus state it's given (see SizeMsg,
// FocusMsg, BlurMsg).
package layout
import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/style"
)
// Pane is a leaf's content. It's the same Init/Update/View shape used by
// every other custom component in this repo (see tabs.Tab): distinct from
// the real tea.Model, whose View returns tea.View rather than string -
// that's the top level's job (see Run), not a nested pane's.
type Pane interface {
Init() tea.Cmd
Update(tea.Msg) (Pane, tea.Cmd)
View() string
}
// Navigable is what makes a Model composable: embed one layout.Model inside
// another (Leaf(id, innerModel)) and it works transparently, because
// layout.Model itself implements Navigable. ctrl+hjkl first tries
// MoveFocus on whatever's currently focused; SendMsg/RequestFocusMsg reach
// into nested trees via Route/Focus; the help bar drills in via
// FocusedHelp. A pane that isn't itself a layout.Model just doesn't
// implement this, and is treated as an ordinary leaf everywhere.
type Navigable interface {
Pane
Leaves() []LeafRect
MoveFocus(dir FocusDirection) bool
Route(target string, msg tea.Msg) (handled bool, cmd tea.Cmd)
Focus(id string) (handled bool, cmd tea.Cmd)
FocusedHelp() []key.Binding
}
// focusState holds the pieces of Model's state that Navigable's MoveFocus
// and Focus must be able to mutate despite having value receivers - a
// requirement of Model being usable by value as a Leaf's tea.Model and
// still satisfying Navigable when type-asserted back out of that interface.
// Boxed behind a pointer so the mutation persists across every copy of
// Model that shares it.
type focusState struct {
id string
// pendingCmd queues cmds produced by BlurMsg/FocusMsg dispatch that
// happened inside MoveFocus, which - being bool-only, per Navigable -
// has no return path for them. Drained by the nearest Update that
// actually returns a tea.Cmd; delivery lags by at most one Update
// cycle, never user-visible in practice.
pendingCmd tea.Cmd
}
// Model is a running layout: a Node tree, focus, sizing, and (if AsRoot)
// the help bar. Build one with New.
type Model struct {
root *Node
state *focusState
leaves []LeafRect
width, height int
keyMap KeyMap
help help.Model
showHelp bool
asRoot bool
}
// Option configures a Model at construction. See AsRoot, WithKeyMap.
type Option func(*Model)
// AsRoot marks this Model as the outermost one: only a root Model renders
// its own help bar in View. Off by default, so an embedded Model (see
// Navigable) never shows a duplicate bar - only pass this to the Model
// actually handed to Run/tea.NewProgram.
func AsRoot() Option {
return func(m *Model) { m.asRoot = true }
}
// WithKeyMap overrides the default ctrl+hjkl/? bindings.
func WithKeyMap(k KeyMap) Option {
return func(m *Model) { m.keyMap = k }
}
// New builds a Model from root. The first leaf (depth-first, first child
// before second) starts focused.
func New(root *Node, opts ...Option) Model {
m := Model{
root: root,
state: &focusState{id: firstLeafID(root)},
keyMap: DefaultKeyMap(),
help: bubbles.NewHelp(),
}
for _, opt := range opts {
opt(&m)
}
return m
}
// program adapts a Model (a Pane, like any other layout leaf content) into
// a real tea.Model for tea.NewProgram: the only place a Model's View needs
// to become a tea.View instead of a string (see Pane's doc comment).
type program struct{ m Model }
func (p program) Init() tea.Cmd { return p.m.Init() }
func (p program) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
updated, cmd := p.m.Update(msg)
p.m = updated.(Model)
return p, cmd
}
func (p program) View() tea.View {
view := tea.NewView(p.m.View())
view.AltScreen = true
return view
}
// Run builds and starts a tea.Program for m (which should have been
// constructed with AsRoot). A convenience for the common standalone-binary
// case; an app assembling layout into a bigger tea.Program of its own can
// wrap m the same way program does above instead.
func Run(m Model, opts ...tea.ProgramOption) error {
_, err := tea.NewProgram(program{m: m}, opts...).Run()
return err
}
func firstLeafID(n *Node) string {
for n != nil && !n.leaf {
n = n.first
}
if n == nil {
return ""
}
return n.id
}
// walk visits every leaf in the tree rooted at n, depth-first, first child
// before second - the same order computeLayout produces, so it's safe to
// rely on for anything that should stay in step with the flat registry.
func walk(n *Node, fn func(*Node)) {
if n == nil {
return
}
if n.leaf {
fn(n)
return
}
walk(n.first, fn)
walk(n.second, fn)
}
func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd
walk(m.root, func(n *Node) {
if cmd := n.model.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
})
// Only the actual root originates the initial FocusMsg. An embedded
// Model's own state.id already defaults to its first leaf (see New),
// but it must stay quiet about it until its parent actually focuses the
// leaf hosting it - which happens naturally through the ordinary
// FocusMsg/BlurMsg case in Update, cascading down as deep as needed.
// Without this guard, every nested Model fires its own initial
// FocusMsg independently, so a leaf that isn't even the outer tree's
// initial focus still shows as focused until the first real move.
if m.asRoot {
if n, ok := findNode(m.root, m.state.id); ok {
updated, cmd := n.model.Update(FocusMsg{})
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
}
return tea.Batch(cmds...)
}
func (m Model) Update(msg tea.Msg) (Pane, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m.resize()
case SizeMsg:
// A nested Model receiving its own allocation from a parent
// layout.Model - equivalent to tea.WindowSizeMsg at the root.
m.width, m.height = msg.Width, msg.Height
return m.resize()
case FocusMsg, BlurMsg:
// This whole (sub)tree just gained/lost focus at the parent's
// level: redistribute to whichever of our own leaves is focused,
// not to the tree "globally" (see Navigable doc).
return m, m.deliverToFocused(msg)
case SendMsg:
_, cmd := m.Route(msg.Target, msg.Msg)
return m, cmd
case RequestFocusMsg:
return m, m.handleRequestFocus(msg)
case SetPaneMsg:
return m.SetPane(msg.ID, msg.NewPane)
case SplitLeafMsg:
return m.SplitLeaf(msg.ID, msg.Dir, msg.NewID, msg.NewModel, msg.Opts...)
case CloseLeafMsg:
return m.CloseLeaf(msg.ID)
case ResizeMsg:
return m.Resize(msg.SplitID, msg.Ratio)
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.keyMap.ToggleHelp):
m.showHelp = !m.showHelp
m.help.ShowAll = m.showHelp
return m.resize()
case key.Matches(msg, m.keyMap.FocusLeft):
m.MoveFocus(FocusLeft)
return m, m.drainCmd()
case key.Matches(msg, m.keyMap.FocusRight):
m.MoveFocus(FocusRight)
return m, m.drainCmd()
case key.Matches(msg, m.keyMap.FocusUp):
m.MoveFocus(FocusUp)
return m, m.drainCmd()
case key.Matches(msg, m.keyMap.FocusDown):
m.MoveFocus(FocusDown)
return m, m.drainCmd()
default:
return m, m.deliverToFocused(msg)
}
default:
return m, m.broadcast(msg)
}
}
func (m Model) drainCmd() tea.Cmd {
cmd := m.state.pendingCmd
m.state.pendingCmd = nil
return cmd
}
// deliverToFocused sends msg to the currently focused leaf's model only.
// Used for ordinary key presses (only the focused pane should react to
// keyboard input) and for relaying FocusMsg/BlurMsg into a nested subtree.
func (m Model) deliverToFocused(msg tea.Msg) tea.Cmd {
n, ok := findNode(m.root, m.state.id)
if !ok {
return nil
}
updated, cmd := n.model.Update(msg)
n.model = updated
return cmd
}
// broadcast sends msg to every leaf's model, focused or not - the default
// for anything that isn't one of layout's own reserved message types or a
// key press, so a blurred pane can still receive its own async messages
// (a tick driving a spinner, an HTTP response, ...).
func (m Model) broadcast(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
walk(m.root, func(n *Node) {
updated, cmd := n.model.Update(msg)
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
})
return tea.Batch(cmds...)
}
// setFocus moves focus to id (assumed already validated as an existing
// leaf), dispatching BlurMsg to the old focus and FocusMsg to the new one,
// and returns the resulting batched cmd. A no-op (nil cmd) if id is already
// focused.
func (m Model) setFocus(id string) tea.Cmd {
if id == m.state.id {
return nil
}
var cmds []tea.Cmd
if old, ok := findNode(m.root, m.state.id); ok {
updated, cmd := old.model.Update(BlurMsg{})
old.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
m.state.id = id
if n, ok := findNode(m.root, id); ok {
updated, cmd := n.model.Update(FocusMsg{})
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
// SplitLeaf splits the leaf identified by id into two: id keeps its
// original pane on one side, a new Leaf(newID, newModel) takes the other,
// joined by a 50/50 Split (override via WithSplitRatio, WithSplitID,
// WithSplitMinimum, WithSplitMaximum). A no-op (m unchanged, nil cmd) if id
// doesn't identify an existing leaf.
func (m Model) SplitLeaf(id string, dir Direction, newID string, newModel Pane, opts ...SplitOption) (Model, tea.Cmd) {
newRoot, ok := splitLeaf(m.root, id, dir, newID, newModel, opts...)
if !ok {
return m, nil
}
m.root = newRoot
var cmds []tea.Cmd
if cmd := newModel.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
resized, cmd := m.resize()
if cmd != nil {
cmds = append(cmds, cmd)
}
return resized, tea.Batch(cmds...)
}
// SetPane replaces the Pane at leaf id with newPane, keeping its place and
// shape in the tree unchanged - unlike SplitLeaf/CloseLeaf, which reshape
// the tree, this only swaps what's rendered at an existing slot (the way an
// app switches its content area between entirely different sub-apps/pages,
// each its own package). newPane is Init'd and immediately told its size
// via SizeMsg (using id's current Rect, which by definition hasn't changed);
// it's also told FocusMsg if id currently holds focus, since the pane it's
// replacing never will. A no-op if id doesn't identify an existing leaf.
func (m Model) SetPane(id string, newPane Pane) (Model, tea.Cmd) {
n, ok := findNode(m.root, id)
if !ok || !n.leaf {
return m, nil
}
n.model = newPane
var cmds []tea.Cmd
if cmd := newPane.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
if lr, ok := m.leafRect(id); ok {
updated, cmd := n.model.Update(SizeMsg{ID: id, Width: lr.Rect.W, Height: lr.Rect.H})
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
if m.state.id == id {
updated, cmd := n.model.Update(FocusMsg{})
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
// CloseLeaf removes the leaf identified by id, promoting its sibling to take
// the place of their parent Split. If id currently has focus, focus moves to
// the tree's new first leaf. A no-op if id is the tree's own root (the last
// remaining pane can't be closed this way) or doesn't exist.
func (m Model) CloseLeaf(id string) (Model, tea.Cmd) {
newRoot, ok := closeLeaf(m.root, id)
if !ok {
return m, nil
}
m.root = newRoot
var focusCmd tea.Cmd
if m.state.id == id {
focusCmd = m.setFocus(firstLeafID(m.root))
}
resized, resizeCmd := m.resize()
return resized, tea.Batch(focusCmd, resizeCmd)
}
// Resize sets the ratio of the first child of the Split identified by
// splitID (only reachable if it was given one, via (*Node).WithID or
// WithSplitID). A no-op if splitID isn't found or identifies a Leaf.
func (m Model) Resize(splitID string, ratio float64) (Model, tea.Cmd) {
n, ok := findNode(m.root, splitID)
if !ok || n.leaf {
return m, nil
}
n.ratio = ratio
return m.resize()
}
// resize recomputes the flat leaf registry from the current root/width/
// height and dispatches SizeMsg to every leaf whose Rect actually changed
// (not just the ones directly touched by whatever triggered this - a
// sibling's size can shift too). Shared by every path that can change
// geometry: tea.WindowSizeMsg, SizeMsg (nested), SplitLeaf, CloseLeaf,
// Resize, and toggling the help bar (which changes how much height the tree
// itself gets).
func (m Model) resize() (Model, tea.Cmd) {
if m.width <= 0 || m.height <= 0 {
return m, nil
}
m.help.SetWidth(m.width)
rect := m.treeRect()
newLeaves := computeLayout(m.root, rect)
old := make(map[string]Rect, len(m.leaves))
for _, lr := range m.leaves {
old[lr.ID] = lr.Rect
}
var cmds []tea.Cmd
for _, lr := range newLeaves {
if prev, ok := old[lr.ID]; ok && prev == lr.Rect {
continue
}
if n, ok := findNode(m.root, lr.ID); ok {
updated, cmd := n.model.Update(SizeMsg{ID: lr.ID, Width: lr.Rect.W, Height: lr.Rect.H})
n.model = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
}
m.leaves = newLeaves
return m, tea.Batch(cmds...)
}
// treeRect is the region left for the split tree once the help bar (if
// AsRoot) has taken its share of the height. resize and View both go
// through this so they can never disagree about where the tree ends and
// the help bar begins.
func (m Model) treeRect() Rect {
h := m.height - m.helpHeight()
if h < 0 {
h = 0
}
return Rect{W: m.width, H: h}
}
func (m Model) helpHeight() int {
if !m.asRoot {
return 0
}
if rendered := m.renderHelp(); rendered != "" {
return lipgloss.Height(rendered)
}
return 0
}
func (m Model) renderHelp() string {
return m.help.View(helpKeyMap{pane: m.FocusedHelp(), own: m.keyMap, width: m.width})
}
// Leaves implements Navigable.
func (m Model) Leaves() []LeafRect {
return m.leaves
}
func (m Model) View() string {
if m.width <= 0 || m.height <= 0 {
return ""
}
rect := m.treeRect()
tree := renderNode(m.root, rect.W, rect.H)
if !m.asRoot {
return tree
}
help := m.renderHelp()
if help == "" {
return tree
}
return lipgloss.JoinVertical(lipgloss.Left, tree, help)
}
// renderNode mirrors computeLayout's own allocation (same resolveSize calls
// on the same w/h at each level), so what's rendered here always matches
// the SizeMsg values leaves were already told via resize.
func renderNode(n *Node, w, h int) string {
if n.leaf {
// A misbehaving Pane that renders wider/taller than the SizeMsg it
// was given would otherwise desync every ancestor Join*, so clip it
// here rather than trusting the contract to hold. MaxWidth/MaxHeight
// truncate via ansi.Truncate internally, so this stays escape-code
// safe instead of mangling a Pane's own styling mid-sequence.
return lipgloss.NewStyle().MaxWidth(w).MaxHeight(h).Render(n.model.View())
}
if n.dir == Horizontal {
w1 := resolveSize(n, w)
return lipgloss.JoinHorizontal(lipgloss.Top,
renderNode(n.first, w1, h),
renderNode(n.second, w-w1, h),
)
}
h1 := resolveSize(n, h)
return lipgloss.JoinVertical(lipgloss.Left,
renderNode(n.first, w, h1),
renderNode(n.second, w, h-h1),
)
}
// Bordered is an optional helper for panes that want the common look: a
// border that follows focus (style.S.Primary focused, style.S.Subtle
// blurred) drawn with the configured BorderType. Not required - a pane
// that wants something else, or nothing, just doesn't call this. Renders
// to exactly w by h, border included, as View() must (see FocusMsg/BlurMsg
// and SizeMsg docs).
func Bordered(focused bool, w, h int, content string) string {
color := style.S.Subtle
if focused {
color = style.S.Primary
}
// lipgloss's Width/Height already count the border as part of the box
// (they subtract its size internally before sizing the content), so w
// and h go straight through - no manual -2 here, or the box comes out
// two cells smaller than asked in both dimensions.
return lipgloss.NewStyle().
Border(style.S.BorderType).
BorderForeground(color).
Width(max(w, 0)).
Height(max(h, 0)).
Render(content)
}
+277
View File
@@ -0,0 +1,277 @@
package layout
import (
"testing"
tea "charm.land/bubbletea/v2"
)
func newTestModel(t *testing.T, root *Node) (Model, map[string]*stubPane) {
t.Helper()
panes := map[string]*stubPane{}
walk(root, func(n *Node) {
if s, ok := n.model.(*stubPane); ok {
panes[n.id] = s
}
})
m := New(root, AsRoot())
m.Init()
return m, panes
}
func TestInitFocusesFirstLeaf(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
if m.state.id != "a" {
t.Fatalf("focusedID = %q, want %q", m.state.id, "a")
}
if panes["a"].focusN != 1 {
t.Fatalf("a.focusN = %d, want 1", panes["a"].focusN)
}
if panes["b"].focusN != 0 {
t.Fatalf("b.focusN = %d, want 0", panes["b"].focusN)
}
}
func TestWindowSizeDispatchesSizeMsg(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
// newTestModel builds with AsRoot(), so the tree gets 40 minus the
// 1-row help bar - see treeRect.
if panes["a"].w != 50 || panes["a"].h != 39 {
t.Fatalf("a size = %dx%d, want 50x39", panes["a"].w, panes["a"].h)
}
if panes["b"].w != 50 || panes["b"].h != 39 {
t.Fatalf("b size = %dx%d, want 50x39", panes["b"].w, panes["b"].h)
}
if got := len(m.Leaves()); got != 2 {
t.Fatalf("len(Leaves()) = %d, want 2", got)
}
}
func TestCtrlLMovesFocusAndDispatchesBlurFocus(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
updated, _ = m.Update(tea.KeyPressMsg{Code: 'l', Mod: tea.ModCtrl, Text: ""})
m = updated.(Model)
if m.state.id != "b" {
t.Fatalf("focusedID = %q, want %q", m.state.id, "b")
}
if panes["a"].blurN != 1 {
t.Fatalf("a.blurN = %d, want 1", panes["a"].blurN)
}
if panes["b"].focusN != 1 {
t.Fatalf("b.focusN = %d, want 1", panes["b"].focusN)
}
}
func TestKeyPressOnlyReachesFocusedPane(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
updated, _ = m.Update(tea.KeyPressMsg{Text: "x"})
_ = updated.(Model)
if _, ok := panes["a"].last().(tea.KeyPressMsg); !ok {
t.Fatalf("focused pane a should have received the key press, got %#v", panes["a"].last())
}
if _, ok := panes["b"].last().(tea.KeyPressMsg); ok {
t.Fatalf("blurred pane b should not have received the key press")
}
}
func TestSplitLeafAddsAndSizesNewPane(t *testing.T) {
root := Leaf("a", newStub())
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
newPane := newStub()
m, _ = m.SplitLeaf("a", Horizontal, "b", newPane)
if got := len(m.Leaves()); got != 2 {
t.Fatalf("len(Leaves()) = %d, want 2", got)
}
if newPane.w == 0 || newPane.h == 0 {
t.Fatalf("new pane never received a SizeMsg: w=%d h=%d", newPane.w, newPane.h)
}
if panes["a"].w != 50 {
t.Fatalf("a.w = %d, want 50 after 50/50 split", panes["a"].w)
}
}
func TestSetPaneSwapsContentKeepingShape(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
// "a" is focused (first leaf).
replacement := newStub()
m, _ = m.SetPane("a", replacement)
if got := len(m.Leaves()); got != 2 {
t.Fatalf("len(Leaves()) = %d, want 2 (SetPane must not reshape the tree)", got)
}
if replacement.w != 50 || replacement.h != 39 {
t.Fatalf("replacement size = %dx%d, want 50x39 (a's existing Rect)", replacement.w, replacement.h)
}
if replacement.focusN != 1 {
t.Fatalf("replacement.focusN = %d, want 1: \"a\" currently holds focus", replacement.focusN)
}
if panes["b"].w != 50 {
t.Fatalf("b.w = %d, want unchanged 50", panes["b"].w)
}
}
func TestSetPaneOnBlurredLeafDoesNotFocus(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, _ := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
// "b" is blurred (only "a" is focused initially).
replacement := newStub()
m, _ = m.SetPane("b", replacement)
if replacement.focusN != 0 {
t.Fatalf("replacement.focusN = %d, want 0: \"b\" isn't focused", replacement.focusN)
}
if replacement.w != 50 || replacement.h != 39 {
t.Fatalf("replacement size = %dx%d, want 50x39", replacement.w, replacement.h)
}
}
func TestSetPaneUnknownIDIsNoop(t *testing.T) {
root := Leaf("a", newStub())
m, _ := newTestModel(t, root)
m2, cmd := m.SetPane("nope", newStub())
if cmd != nil {
t.Fatalf("expected nil cmd for an unknown SetPane id, got %v", cmd)
}
if len(m2.Leaves()) != len(m.Leaves()) {
t.Fatalf("tree changed after a no-op SetPane")
}
}
func TestCloseLeafReassignsFocus(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, _ := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
// focus is on "a"; closing it must move focus to what remains.
m, _ = m.CloseLeaf("a")
if got := len(m.Leaves()); got != 1 {
t.Fatalf("len(Leaves()) = %d, want 1", got)
}
if m.state.id != "b" {
t.Fatalf("focusedID = %q, want %q after closing the focused leaf", m.state.id, "b")
}
}
func TestCloseLeafRootIsNoop(t *testing.T) {
root := Leaf("only", newStub())
m, _ := newTestModel(t, root)
m2, cmd := m.CloseLeaf("only")
if cmd != nil {
t.Fatalf("expected nil cmd closing the tree's only leaf, got %v", cmd)
}
if got := len(m2.Leaves()); got != len(m.Leaves()) {
t.Fatalf("tree changed after a no-op close")
}
}
func TestResizeChangesRatio(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub())).WithID("split")
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
m, _ = m.Resize("split", 0.8)
if panes["a"].w != 80 {
t.Fatalf("a.w = %d, want 80 after Resize to 0.8", panes["a"].w)
}
if panes["b"].w != 20 {
t.Fatalf("b.w = %d, want 20 after Resize to 0.8", panes["b"].w)
}
}
func TestSendMsgDeliversToTarget(t *testing.T) {
type payload struct{ n int }
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
// a is focused via Init's initial FocusMsg; b never received anything yet.
updated, _ := m.Update(SendMsg{Target: "b", Msg: payload{n: 42}})
m = updated.(Model)
if got, ok := panes["b"].last().(payload); !ok || got.n != 42 {
t.Fatalf("b should have received payload{42}, got %#v", panes["b"].last())
}
for _, msg := range panes["a"].msgs {
if _, ok := msg.(payload); ok {
t.Fatalf("a should not have received the SendMsg meant for b")
}
}
}
func TestSendMsgUnknownTargetIsIgnored(t *testing.T) {
root := Leaf("a", newStub())
m, _ := newTestModel(t, root)
updated, cmd := m.Update(SendMsg{Target: "nope", Msg: struct{}{}})
_ = updated.(Model)
if cmd != nil {
t.Fatalf("expected nil cmd for an unknown SendMsg target, got %v", cmd)
}
}
func TestRequestFocusFromFocusedPaneSucceeds(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
m, panes := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
updated, _ = m.Update(RequestFocusMsg{Source: "a", Target: "b"})
m = updated.(Model)
if m.state.id != "b" {
t.Fatalf("focusedID = %q, want %q", m.state.id, "b")
}
if panes["b"].focusN != 1 {
t.Fatalf("b.focusN = %d, want 1", panes["b"].focusN)
}
}
func TestRequestFocusFromBlurredPaneIsIgnored(t *testing.T) {
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
third := Leaf("c", newStub())
_ = third
m, _ := newTestModel(t, root)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
m = updated.(Model)
// "a" is focused; "b" (blurred) tries to redirect focus to itself.
updated, _ = m.Update(RequestFocusMsg{Source: "b", Target: "b"})
m = updated.(Model)
if m.state.id != "a" {
t.Fatalf("focusedID = %q, want %q (unauthorized request must be ignored)", m.state.id, "a")
}
}
+80
View File
@@ -0,0 +1,80 @@
package layout
import tea "charm.land/bubbletea/v2"
// SizeMsg tells a pane the exact width/height it's been allocated, and its
// own id (so it can identify itself as the Source of a later
// RequestFocusMsg). Sent to every leaf whose Rect changed, whenever the tree
// is resized, split, closed or reshaped - never assume a pane can deduce its
// size any other way.
type SizeMsg struct {
ID string
Width, Height int
}
// FocusMsg tells a pane it just gained keyboard focus. Named distinctly from
// bubbletea's own tea.FocusMsg (which is about terminal focus, not pane
// focus) to avoid any confusion between the two.
type FocusMsg struct{}
// BlurMsg tells a pane it just lost keyboard focus. See FocusMsg.
type BlurMsg struct{}
// SendMsg delivers Msg to the pane identified by Target, wherever it lives
// in the tree (including inside a nested layout.Model), regardless of which
// pane currently has focus. A pane never holds a reference to another, so
// this is how they talk: return
//
// func() tea.Msg { return layout.SendMsg{Target: "editor", Msg: myMsg{}} }
//
// as a tea.Cmd from Update. An unknown Target is silently ignored.
type SendMsg struct {
Target string
Msg tea.Msg
}
// RequestFocusMsg asks the layout to move keyboard focus to Target. Only
// honored when Source is the id of the pane that currently holds focus (a
// blurred pane can send other panes messages via SendMsg, but can't move
// focus itself or on anyone's behalf) - fill Source from the ID a pane
// learned via SizeMsg:
//
// func() tea.Msg { return layout.RequestFocusMsg{Source: p.id, Target: "content"} }
//
// A mismatched Source, or an unknown Target, is silently ignored.
type RequestFocusMsg struct {
Source string
Target string
}
// SetPaneMsg is the message form of Model.SetPane, for a pane that wants to
// swap another leaf's content without holding a reference to its Model
// (which it never does).
type SetPaneMsg struct {
ID string
NewPane Pane
}
// SplitLeafMsg is the message form of Model.SplitLeaf, for a pane that wants
// to reshape the tree without holding a reference to its Model (which it
// never does). See Model.SplitLeaf for parameters.
type SplitLeafMsg struct {
ID string
Dir Direction
NewID string
NewModel Pane
Opts []SplitOption
}
// CloseLeafMsg is the message form of Model.CloseLeaf.
type CloseLeafMsg struct {
ID string
}
// ResizeMsg is the message form of Model.Resize: sets the ratio of the
// first child of the Split identified by SplitID (a Split only reachable by
// id if it was given one via WithID or WithSplitID).
type ResizeMsg struct {
SplitID string
Ratio float64
}
+151
View File
@@ -0,0 +1,151 @@
package layout
import tea "charm.land/bubbletea/v2"
// FocusDirection is a screen-space direction for moving focus between
// panes: ctrl+h/j/k/l. Distinct from Direction (a Split's axis) because a
// split only ever has two sides, while focus needs to move one of four
// ways.
type FocusDirection int
const (
FocusLeft FocusDirection = iota
FocusRight
FocusUp
FocusDown
)
// FindNeighbor picks the leaf, among leaves, that's geometrically closest to
// from in dir - tmux's select-pane -L/-D/-U/-R, not a tree walk. A
// candidate must be strictly positioned beyond from's edge in dir and share
// some extent with it on the perpendicular axis. Ranked by, in order: edge
// gap (smaller wins), then shared perpendicular extent (larger wins - the
// real tie-breaker between two equally-close neighbors), then
// center-to-center distance on the perpendicular axis (last resort). Pure
// and independent of Model so it's testable on its own.
func FindNeighbor(leaves []LeafRect, from LeafRect, dir FocusDirection) (id string, ok bool) {
type candidate struct {
id string
gap, overlap, cross int
}
var best *candidate
for _, lr := range leaves {
if lr.ID == from.ID {
continue
}
gap, overlap, cross, ok := edgeScore(from.Rect, lr.Rect, dir)
if !ok {
continue
}
c := candidate{id: lr.ID, gap: gap, overlap: overlap, cross: cross}
if best == nil ||
c.gap < best.gap ||
(c.gap == best.gap && c.overlap > best.overlap) ||
(c.gap == best.gap && c.overlap == best.overlap && c.cross < best.cross) {
best = &c
}
}
if best == nil {
return "", false
}
return best.id, true
}
// edgeScore returns the ranking tuple used by FindNeighbor for a single
// (from, other) pair, and ok=false if other isn't a valid candidate in dir
// at all (wrong side, or zero overlap on the perpendicular axis).
func edgeScore(from, other Rect, dir FocusDirection) (gap, overlap, cross int, ok bool) {
switch dir {
case FocusLeft:
if other.X+other.W > from.X {
return 0, 0, 0, false
}
gap = from.X - (other.X + other.W)
overlap = spanOverlap(from.Y, from.Y+from.H, other.Y, other.Y+other.H)
cross = abs((from.Y + from.H/2) - (other.Y + other.H/2))
case FocusRight:
if other.X < from.X+from.W {
return 0, 0, 0, false
}
gap = other.X - (from.X + from.W)
overlap = spanOverlap(from.Y, from.Y+from.H, other.Y, other.Y+other.H)
cross = abs((from.Y + from.H/2) - (other.Y + other.H/2))
case FocusUp:
if other.Y+other.H > from.Y {
return 0, 0, 0, false
}
gap = from.Y - (other.Y + other.H)
overlap = spanOverlap(from.X, from.X+from.W, other.X, other.X+other.W)
cross = abs((from.X + from.W/2) - (other.X + other.W/2))
case FocusDown:
if other.Y < from.Y+from.H {
return 0, 0, 0, false
}
gap = other.Y - (from.Y + from.H)
overlap = spanOverlap(from.X, from.X+from.W, other.X, other.X+other.W)
cross = abs((from.X + from.W/2) - (other.X + other.W/2))
}
if overlap <= 0 {
return 0, 0, 0, false
}
return gap, overlap, cross, true
}
// spanOverlap returns the length shared by [aStart,aEnd) and [bStart,bEnd).
func spanOverlap(aStart, aEnd, bStart, bEnd int) int {
start := max(aStart, bStart)
end := min(aEnd, bEnd)
if end <= start {
return 0
}
return end - start
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// leafRect looks up id's current Rect in this Model's own flat registry.
func (m Model) leafRect(id string) (LeafRect, bool) {
for _, lr := range m.leaves {
if lr.ID == id {
return lr, true
}
}
return LeafRect{}, false
}
// MoveFocus implements Navigable. If the currently focused leaf holds a
// nested Navigable (an embedded layout.Model), it's given first refusal -
// only once it reports being at its own edge (false) does this Model try
// moving focus among its own direct children instead. Cmds produced by the
// BlurMsg/FocusMsg this triggers can't be returned directly (Navigable's
// signature is bool-only); they're queued on m.state.pendingCmd instead
// (see focusState) and drained by the next Update that actually returns a
// tea.Cmd - a lag of at most one Update cycle, never user-visible.
func (m Model) MoveFocus(dir FocusDirection) bool {
if focused, ok := findNode(m.root, m.state.id); ok {
if nav, ok := focused.model.(Navigable); ok {
if nav.MoveFocus(dir) {
focused.model = nav
return true
}
}
}
from, ok := m.leafRect(m.state.id)
if !ok {
return false
}
id, ok := FindNeighbor(m.leaves, from, dir)
if !ok {
return false
}
m.state.pendingCmd = tea.Batch(m.state.pendingCmd, m.setFocus(id))
return true
}
+75
View File
@@ -0,0 +1,75 @@
package layout
import "testing"
func TestFindNeighborBasicDirections(t *testing.T) {
leaves := []LeafRect{
{ID: "left", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
{ID: "right", Rect: Rect{X: 10, Y: 0, W: 10, H: 10}},
}
if id, ok := FindNeighbor(leaves, leaves[0], FocusRight); !ok || id != "right" {
t.Fatalf("FocusRight from left: got (%q, %v), want (right, true)", id, ok)
}
if id, ok := FindNeighbor(leaves, leaves[1], FocusLeft); !ok || id != "left" {
t.Fatalf("FocusLeft from right: got (%q, %v), want (left, true)", id, ok)
}
if _, ok := FindNeighbor(leaves, leaves[0], FocusLeft); ok {
t.Fatalf("FocusLeft from left (edge): expected no neighbor")
}
}
// Two candidates at the exact same gap: the one that actually shares more
// of from's edge should win, not an arbitrary pick. This is the overlap
// tie-break, the one that matters most for a real tmux/i3-style layout
// (picking the pane that's genuinely alongside you, not just "a" neighbor).
func TestFindNeighborPrefersLargerOverlapOnEqualGap(t *testing.T) {
from := LeafRect{ID: "from", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}}
leaves := []LeafRect{
from,
{ID: "full", Rect: Rect{X: 10, Y: 0, W: 10, H: 10}},
{ID: "partial", Rect: Rect{X: 10, Y: 5, W: 10, H: 10}},
}
id, ok := FindNeighbor(leaves, from, FocusRight)
if !ok || id != "full" {
t.Fatalf("got (%q, %v), want (full, true): equal gap, full's overlap (10) beats partial's (5)", id, ok)
}
}
// When gap AND overlap are tied, center-to-center distance on the
// perpendicular axis is the last resort.
func TestFindNeighborCrossIsLastResortTiebreak(t *testing.T) {
from := LeafRect{ID: "from", Rect: Rect{X: 0, Y: 10, W: 10, H: 10}} // Y 10..20, center 15
leaves := []LeafRect{
from,
{ID: "near", Rect: Rect{X: 10, Y: 8, W: 10, H: 20}}, // Y 8..28, overlap 10, center 18
{ID: "far", Rect: Rect{X: 10, Y: 0, W: 10, H: 20}}, // Y 0..20, overlap 10, center 10
}
id, ok := FindNeighbor(leaves, from, FocusRight)
if !ok || id != "near" {
t.Fatalf("got (%q, %v), want (near, true): equal gap and overlap, near's center is closer (3 vs 5)", id, ok)
}
}
func TestFindNeighborNoCandidateBeyondEdge(t *testing.T) {
leaves := []LeafRect{
{ID: "only", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
}
if _, ok := FindNeighbor(leaves, leaves[0], FocusDown); ok {
t.Fatal("expected no neighbor with a single leaf")
}
}
func TestFindNeighborRequiresPerpendicularOverlap(t *testing.T) {
leaves := []LeafRect{
{ID: "from", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
// Directly to the right on the X axis, but no shared Y extent at
// all: not a valid candidate even though it's the only one there.
{ID: "diagonal", Rect: Rect{X: 10, Y: 10, W: 10, H: 10}},
}
if _, ok := FindNeighbor(leaves, leaves[0], FocusRight); ok {
t.Fatal("expected no neighbor: candidate shares no Y extent with from")
}
}
+210
View File
@@ -0,0 +1,210 @@
package layout
// Direction is the axis a Split divides its space along.
type Direction int
const (
// Horizontal places the first child on the left, the second on the right.
Horizontal Direction = iota
// Vertical stacks the first child on top of the second.
Vertical
)
// Node is one slot in the layout tree: either a Leaf (holding a Pane) or a
// Split (dividing its space between two child Nodes).
// Build a tree with Leaf and Split/HSplit/VSplit, then hand the root to New.
//
// Node is pointer-based on purpose: panes need a stable identity across
// arbitrarily nested splits, so there's no flat index to keep in sync the
// way a slice-based component would.
type Node struct {
id string // "" means unaddressable; always non-empty for a Leaf
leaf bool
model Pane // set when leaf
dir Direction // set when !leaf
ratio float64 // proportion of space given to first; set when !leaf
min, max int // clamp on first's resolved cell size, in cells; 0 = unset
first, second *Node // set when !leaf
}
// Leaf wraps a single pane. id must be non-empty and unique within the tree
// it ends up in: it's how the pane is targeted later by SendMsg,
// RequestFocusMsg, SplitLeaf, CloseLeaf and Resize.
func Leaf(id string, model Pane) *Node {
return &Node{id: id, leaf: true, model: model}
}
// Split divides its space between first and second along dir, giving ratio
// (0 to 1) of it to first and the rest to second. id may be "" if the split
// itself never needs to be addressed by Resize; it plays no role in pane
// addressing (only Leaf ids do).
func Split(id string, dir Direction, ratio float64, first, second *Node) *Node {
return &Node{id: id, dir: dir, ratio: ratio, first: first, second: second}
}
// HSplit is Split with Horizontal and no id: layout.HSplit(0.3, left, right).
func HSplit(ratio float64, first, second *Node) *Node {
return Split("", Horizontal, ratio, first, second)
}
// VSplit is Split with Vertical and no id: layout.VSplit(0.3, top, bottom).
func VSplit(ratio float64, first, second *Node) *Node {
return Split("", Vertical, ratio, first, second)
}
// WithID sets the id used to address this node later (currently only
// meaningful on a Split, for Resize; a Leaf already gets its id from Leaf).
func (n *Node) WithID(id string) *Node {
n.id = id
return n
}
// WithMinimum clamps first's resolved size to never go below cells. No-op on
// a Leaf, which has no size of its own to constrain.
func (n *Node) WithMinimum(cells int) *Node {
if !n.leaf {
n.min = cells
}
return n
}
// WithMaximum clamps first's resolved size to never exceed cells. No-op on a
// Leaf. Setting both WithMinimum and WithMaximum to the same value fixes
// first's size regardless of ratio.
func (n *Node) WithMaximum(cells int) *Node {
if !n.leaf {
n.max = cells
}
return n
}
// findNode returns the node with the given id anywhere in the tree rooted at
// n, without mutating anything. id == "" never matches (it's the
// unaddressable sentinel, and several Splits may share it).
func findNode(n *Node, id string) (*Node, bool) {
if n == nil || id == "" {
return nil, false
}
if n.id == id {
return n, true
}
if n.leaf {
return nil, false
}
if found, ok := findNode(n.first, id); ok {
return found, true
}
return findNode(n.second, id)
}
// splitConfig collects SplitOption values applied by SplitLeaf.
type splitConfig struct {
id string
ratio float64
}
// SplitOption configures the Split node SplitLeaf creates in place of the
// leaf it splits.
type SplitOption func(*Node, *splitConfig)
// WithSplitID addresses the split SplitLeaf creates, so it can later be
// targeted by Resize.
func WithSplitID(id string) SplitOption {
return func(_ *Node, c *splitConfig) { c.id = id }
}
// WithSplitRatio overrides SplitLeaf's default 50/50 split.
func WithSplitRatio(ratio float64) SplitOption {
return func(_ *Node, c *splitConfig) { c.ratio = ratio }
}
// WithSplitMinimum clamps the size of the original (pre-split) leaf's side
// of the new split. Equivalent to calling (*Node).WithMinimum on the split
// SplitLeaf produces.
func WithSplitMinimum(cells int) SplitOption {
return func(n *Node, _ *splitConfig) { n.WithMinimum(cells) }
}
// WithSplitMaximum clamps the size of the original (pre-split) leaf's side
// of the new split. Equivalent to calling (*Node).WithMaximum on the split
// SplitLeaf produces.
func WithSplitMaximum(cells int) SplitOption {
return func(n *Node, _ *splitConfig) { n.WithMaximum(cells) }
}
// splitLeaf replaces the leaf identified by id with a Split holding the
// original leaf as first and a new Leaf(newID, newModel) as second. Returns
// the (possibly new) tree root and whether id was found and was a leaf.
func splitLeaf(root *Node, id string, dir Direction, newID string, newModel Pane, opts ...SplitOption) (*Node, bool) {
target, ok := findNode(root, id)
if !ok || !target.leaf {
return root, false
}
cfg := splitConfig{ratio: 0.5}
split := Split("", dir, 0.5, target, Leaf(newID, newModel))
for _, opt := range opts {
opt(split, &cfg)
}
split.id = cfg.id
split.ratio = cfg.ratio
newRoot, _ := replaceNode(root, id, func(*Node) *Node { return split })
return newRoot, true
}
// replaceNode returns a copy of the tree rooted at n with the node
// identified by id swapped for transform's result. Only the path down to
// that node is cloned, everything else is shared. Returns n unchanged and
// false if id isn't found.
func replaceNode(n *Node, id string, transform func(*Node) *Node) (*Node, bool) {
if n == nil || id == "" {
return n, false
}
if n.id == id {
return transform(n), true
}
if n.leaf {
return n, false
}
if newFirst, ok := replaceNode(n.first, id, transform); ok {
clone := *n
clone.first = newFirst
return &clone, true
}
if newSecond, ok := replaceNode(n.second, id, transform); ok {
clone := *n
clone.second = newSecond
return &clone, true
}
return n, false
}
// closeLeaf removes the leaf identified by id, promoting its sibling to take
// the place of their parent Split. Returns the (possibly new) tree root and
// whether id was found as a direct child of some Split (the tree's own root
// leaf, with no parent, can never be closed this way).
func closeLeaf(root *Node, id string) (*Node, bool) {
if root == nil || root.leaf || id == "" {
return root, false
}
if root.first.leaf && root.first.id == id {
return root.second, true
}
if root.second.leaf && root.second.id == id {
return root.first, true
}
if newFirst, ok := closeLeaf(root.first, id); ok {
clone := *root
clone.first = newFirst
return &clone, true
}
if newSecond, ok := closeLeaf(root.second, id); ok {
clone := *root
clone.second = newSecond
return &clone, true
}
return root, false
}
+112
View File
@@ -0,0 +1,112 @@
package layout
import tea "charm.land/bubbletea/v2"
// Route implements Navigable: attempts to deliver msg to the leaf
// identified by target. Checks this Model's own direct leaves first, then
// recurses into any leaf whose model is itself Navigable (an embedded
// layout.Model), depth-first. Returns handled=false without touching
// anything if target isn't found anywhere in this (sub)tree - the caller
// (Model.Update, or a parent Model's own Route) is responsible for treating
// that as "silently ignore."
func (m Model) Route(target string, msg tea.Msg) (bool, tea.Cmd) {
if n, ok := findNode(m.root, target); ok && n.leaf {
updated, cmd := n.model.Update(msg)
n.model = updated
return true, cmd
}
var (
handled bool
cmd tea.Cmd
)
walk(m.root, func(n *Node) {
if handled {
return
}
if nav, ok := n.model.(Navigable); ok {
if h, c := nav.Route(target, msg); h {
n.model = nav
handled, cmd = true, c
}
}
})
return handled, cmd
}
// Focus implements Navigable: moves this (sub)tree's focus straight to id,
// wherever it is - a direct leaf, or nested inside a leaf's own Navigable.
// Unlike MoveFocus (one geometric step in a direction), this is "jump to
// this specific pane." When id lives inside a nested Navigable, that
// child's own internal focus is set first, and then this Model's own focus
// is brought to the leaf hosting it too, so the whole chain agrees on what's
// focused (required for FocusMsg/BlurMsg propagation and for MoveFocus's
// "ask the focused child first" rule to keep working afterward). That outer
// step re-notifies the leaf hosting the nested tree regardless of whether
// the nested Focus call already notified id directly - the two can't always
// be told apart cheaply (id might have already been that subtree's
// untouched default focus, which never got an initial FocusMsg at all, see
// Model.Init) - so id's pane may occasionally see FocusMsg twice for one
// real transition. Delivery is at-least-once, not exactly-once: a Pane
// should treat FocusMsg/BlurMsg as idempotent, the same way it would have
// to tolerate a redundant terminal focus event.
func (m Model) Focus(id string) (bool, tea.Cmd) {
if _, ok := m.leafRect(id); ok {
return true, m.setFocus(id)
}
var (
handled bool
innerCmd tea.Cmd
outerID string
)
walk(m.root, func(n *Node) {
if handled {
return
}
if nav, ok := n.model.(Navigable); ok {
if h, c := nav.Focus(id); h {
n.model = nav
handled, innerCmd, outerID = true, c, n.id
}
}
})
if !handled {
return false, nil
}
return true, tea.Batch(m.setFocus(outerID), innerCmd)
}
// sourceIsFocused reports whether id is the leaf currently focused
// somewhere along this (sub)tree's active focus chain: either this Model's
// own focused leaf, or - recursively - whatever's focused inside that leaf
// if it's itself a nested Navigable. Used to authorize RequestFocusMsg: only
// the pane that genuinely holds focus right now, at whatever depth, is
// allowed to redirect focus elsewhere. A blurred pane reaching this code
// (e.g. reacting to a SendMsg while in the background) is correctly refused
// since it can never appear on the active chain.
func (m Model) sourceIsFocused(id string) bool {
if m.state.id == id {
return true
}
focused, ok := findNode(m.root, m.state.id)
if !ok {
return false
}
checker, ok := focused.model.(interface{ sourceIsFocused(string) bool })
if !ok {
return false
}
return checker.sourceIsFocused(id)
}
// handleRequestFocus honors a RequestFocusMsg only once sourceIsFocused
// clears its Source, then resolves Target the same way Focus does. An
// unauthorized Source, or an unknown Target, is silently ignored.
func (m Model) handleRequestFocus(msg RequestFocusMsg) tea.Cmd {
if !m.sourceIsFocused(msg.Source) {
return nil
}
_, cmd := m.Focus(msg.Target)
return cmd
}
+150
View File
@@ -0,0 +1,150 @@
package layout
import (
"testing"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
)
// buildNested returns an outer Model with two direct leaves: "sidebar" and
// "inner-root", the latter being a nested layout.Model (itself split into
// "inner-a"/"inner-b") embedded as an ordinary Pane. Both models are sized
// before being handed back so their leaf registries are populated.
func buildNested(t *testing.T) (outer Model, sidebar *stubPane, innerA, innerB *stubPane) {
t.Helper()
sidebar = newStub()
innerA = newStub()
innerB = newStub()
// inner is deliberately built WITHOUT AsRoot(): it's embedded, so it
// must not act focused on its own until outer actually focuses the
// leaf that hosts it (see Model.Init's asRoot guard).
inner := New(HSplit(0.5, Leaf("inner-a", innerA), Leaf("inner-b", innerB)))
root := HSplit(0.3, Leaf("sidebar", sidebar), Leaf("inner-root", inner))
outer = New(root, AsRoot())
outer.Init()
updated, _ := outer.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
outer = updated.(Model)
return outer, sidebar, innerA, innerB
}
// Regression test: a nested Model used to fire its own initial FocusMsg to
// its first leaf unconditionally on Init, regardless of whether the outer
// tree's actual initial focus ever lands on the leaf hosting it - so a
// leaf buried in a subtree that isn't even initially focused would still
// show up as focused, alongside whatever the outer tree really focused.
func TestEmbeddedModelDoesNotSelfFocusOnInit(t *testing.T) {
outer, sidebar, innerA, innerB := buildNested(t)
if sidebar.focusN != 1 {
t.Fatalf("sidebar.focusN = %d, want 1 (it's the outer tree's real initial focus)", sidebar.focusN)
}
if innerA.focusN != 0 || innerB.focusN != 0 {
t.Fatalf("innerA.focusN=%d innerB.focusN=%d, want 0/0: the nested tree isn't focused yet", innerA.focusN, innerB.focusN)
}
if outer.state.id != "sidebar" {
t.Fatalf("outer focusedID = %q, want %q", outer.state.id, "sidebar")
}
}
func TestSendMsgReachesNestedLeaf(t *testing.T) {
type payload struct{ n int }
outer, _, innerA, _ := buildNested(t)
updated, _ := outer.Update(SendMsg{Target: "inner-a", Msg: payload{n: 7}})
_ = updated.(Model)
if got, ok := innerA.last().(payload); !ok || got.n != 7 {
t.Fatalf("inner-a should have received payload{7}, got %#v", innerA.last())
}
}
func TestFocusJumpsIntoNestedSubtreeAndUpdatesOuterFocus(t *testing.T) {
outer, _, _, innerB := buildNested(t)
// Outer's own focus starts on "sidebar" (first leaf, depth-first).
handled, _ := outer.Focus("inner-b")
if !handled {
t.Fatal("Focus(\"inner-b\") should have been handled")
}
if outer.state.id != "inner-root" {
t.Fatalf("outer focusedID = %q, want %q (the leaf hosting the nested tree)", outer.state.id, "inner-root")
}
// At-least-once, not exactly-once (see Focus's doc comment): the outer
// leaf's own re-notification can duplicate the nested tree's own
// dispatch, so only assert inner-b actually got notified, not a count.
if innerB.focusN < 1 {
t.Fatalf("inner-b.focusN = %d, want at least 1", innerB.focusN)
}
}
func TestRequestFocusAuthorizedThroughNestedChain(t *testing.T) {
outer, _, innerA, _ := buildNested(t)
// Move outer focus onto the nested subtree, and its own internal focus
// onto inner-a, so inner-a is genuinely the focused leaf end-to-end.
outer.Focus("inner-a")
if innerA.focusN != 1 {
t.Fatalf("inner-a.focusN = %d, want 1 before the request", innerA.focusN)
}
updated, _ := outer.Update(RequestFocusMsg{Source: "inner-a", Target: "sidebar"})
outer = updated.(Model)
if outer.state.id != "sidebar" {
t.Fatalf("focusedID = %q, want %q: inner-a is genuinely focused, its request should be honored", outer.state.id, "sidebar")
}
}
func TestRequestFocusFromNonFocusedNestedLeafIsIgnored(t *testing.T) {
outer, _, _, innerB := buildNested(t)
// Outer focus is on "sidebar"; the nested tree isn't even the focused
// branch, so nothing inside it - including inner-b - is authorized.
_ = innerB
updated, _ := outer.Update(RequestFocusMsg{Source: "inner-b", Target: "sidebar"})
outer = updated.(Model)
if outer.state.id != "sidebar" {
t.Fatalf("focusedID = %q, want unchanged %q", outer.state.id, "sidebar")
}
}
func TestMoveFocusDelegatesToNestedBeforeGeometry(t *testing.T) {
outer, _, _, innerB := buildNested(t)
outer.Focus("inner-a")
if ok := outer.MoveFocus(FocusRight); !ok {
t.Fatal("MoveFocus(FocusRight) should have been handled by the nested tree (inner-a -> inner-b)")
}
if innerB.focusN != 1 {
t.Fatalf("inner-b.focusN = %d, want 1 (moved within the nested tree)", innerB.focusN)
}
if outer.state.id != "inner-root" {
t.Fatalf("outer focusedID changed to %q, should have stayed on the nested leaf", outer.state.id)
}
// Now at inner-b, the nested tree's own rightmost leaf: pressing right
// again must bubble up and move the OUTER focus instead.
if ok := outer.MoveFocus(FocusRight); ok {
// buildNested's outer split is only sidebar|inner-root left-to-right,
// so there's nothing further right at the outer level either -
// MoveFocus should report false all the way up.
t.Fatalf("expected no further neighbor to the right at either level")
}
}
func TestHelpBindingsDelegatesThroughNesting(t *testing.T) {
outer, _, innerA, _ := buildNested(t)
outer.Focus("inner-a")
want := key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "do x"))
innerA.help = []key.Binding{want}
got := outer.HelpBindings()
if len(got) != 1 || got[0].Help().Key != "x" {
t.Fatalf("HelpBindings() = %#v, want the focused inner leaf's bindings", got)
}
}
+101
View File
@@ -0,0 +1,101 @@
# modal
A centered popup box on top of a dimmed background, triggered from anywhere in a bubbletea
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.
## Quick start
```go
import (
"github.com/anotherhadi/ilovetui/modal"
)
type model struct {
m modal.Model
width, height int
}
func newModel() model {
return model{m: modal.New()}
}
func (m model) Init() tea.Cmd { return m.m.Init() }
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")
}
if msg.String() == "esc" && m.m.Open() {
return m, modal.Close()
}
}
var cmd tea.Cmd
m.m, cmd = m.m.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
background := renderYourUI(m.width, m.height)
view := tea.NewView(m.m.Render(background))
view.AltScreen = true
return view
}
```
Any component in the same bubbletea program can trigger a modal via `modal.Show`, without holding
a reference to the `modal.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.
## Showing and dismissing
```go
return m, modal.Show("Delete file?", "This can't be undone.", modal.WithID("confirm"))
return m, modal.Dismiss("confirm") // close a specific modal by id
return m, modal.Close() // close whichever modal is on top, whatever its id
```
- `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.
## 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
beneath, still in full color. This is what lets a "delete?" confirmation open a nested "really
sure?" modal without any special-casing.
## Styling
```go
m := modal.New(modal.WithMaxWidth(60), modal.WithMaxHeight(20), modal.WithStyles(myStyles))
return m, modal.Show("Title", "Message", modal.WithModalStyle(oneOffStyles))
```
`WithMaxWidth`/`WithMaxHeight` cap how large a modal box can grow before wrapping/truncating; a
modal narrower than the cap shrinks to fit its content instead of padding out to it. A modal can
also never overflow past the edge of whatever background it's rendered on, regardless of these
caps. `WithStyles` sets the default look for every modal shown by this `Model`; `WithModalStyle`
(a `Show` option) overrides it for one modal alone. `DefaultStyles()` builds from `style.S`: the
box borrows `PanelFocused`'s border (the modal is what has focus while it's open), the dim color
reuses `Subtle`.
## Examples
- `examples/modal` - open/dismiss, nested modals, styled from theme colors.
+70
View File
@@ -0,0 +1,70 @@
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.
type Modal struct {
ID string
Title string
Content string
// Style, if non-nil, overrides the Model's default Styles for this
// modal alone.
Style *Styles
}
// 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 {
mo := Modal{Title: title, Content: content}
for _, opt := range opts {
opt(&mo)
}
return mo
}
// ShowMsg tells a modal.Model to display Modal, 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 modal.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{ Modal Modal }
// Show returns a tea.Cmd that opens a new modal on top of the stack:
//
// return m, modal.Show("Delete file?", "This can't be undone.")
func Show(title, content string, 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 }
// 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.
func Close() tea.Cmd {
return func() tea.Msg { return DismissMsg{} }
}
+125
View File
@@ -0,0 +1,125 @@
// Package modal renders a centered popup box on top of a dimmed background,
// triggered from anywhere in a bubbletea program via an exported tea.Msg
// (see ShowMsg/Show) rather than a direct reference to the Model that ends
// 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
// Model.Render and Model.View).
package modal
import (
"fmt"
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
// modal - dimmed, see Render) and the rendering config (max size, styles)
// they share. Build one with New.
type Model struct {
modals []Modal
nextID int
maxWidth int
maxHeight int
styles Styles
}
// Option configures a Model at construction. See WithMaxWidth,
// WithMaxHeight, WithStyles.
type Option func(*Model)
// WithMaxWidth caps how wide a modal box can grow before its content wraps.
// A modal narrower than this shrinks to fit its content instead of padding
// out to the cap. 0 (also the zero-value Model's default without New) means
// only the background's own size caps it.
func WithMaxWidth(w int) Option {
return func(m *Model) { m.maxWidth = w }
}
// WithMaxHeight caps how tall a modal box can grow before its content is
// truncated. 0 means only the background's own size caps it.
func WithMaxHeight(h int) Option {
return func(m *Model) { m.maxHeight = h }
}
// 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 60x20 max size, DefaultStyles.
func New(opts ...Option) Model {
m := Model{
maxWidth: 60,
maxHeight: 20,
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.Modal), nil
case DismissMsg:
return m.remove(msg.ID), nil
}
return m, nil
}
// 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
}
}
m.modals = append(m.modals, mo)
return m
}
// remove closes the modal identified by id, or the topmost one if id is
// empty (see Close).
func (m Model) remove(id string) 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
}
+136
View File
@@ -0,0 +1,136 @@
package modal
import (
"image/color"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/anotherhadi/ilovetui/style"
)
// margin is the fixed gap, in cells, kept between a modal box and the edges
// of the background it's centered on.
const margin = 2
// Render draws every open modal (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 modal in the stack first flattens
// whatever came before it - background plus any earlier modal - to a single
// flat DimColor (see dim), then draws its own box centered on top, so
// nesting a second modal 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.modals) == 0 {
return background
}
result := background
for _, mo := range m.modals {
w, h := lipgloss.Width(result), lipgloss.Height(result)
if w <= 0 || h <= 0 {
return result
}
result = m.renderOne(mo, result, w, h)
}
return result
}
// View is a convenience for a pane whose sole purpose is showing modals
// (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 mo's box centered on top of it.
func (m Model) renderOne(mo Modal, background string, w, h int) string {
s := m.styles
if mo.Style != nil {
s = *mo.Style
}
box := m.renderBox(mo, s, w, h)
bw, bh := lipgloss.Width(box), lipgloss.Height(box)
x, y := max((w-bw)/2, 0), max((h-bh)/2, 0)
compositor := lipgloss.NewCompositor(
lipgloss.NewLayer(dim(background, s.DimColor)),
lipgloss.NewLayer(box).X(x).Y(y).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 mo as a bordered, title-embedded box (style.RenderWithTitle),
// shrunk to fit its content, capped by the Model's configured max size and by
// whatever actually fits inside a bgW x bgH background.
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)
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
boxHeight := min(lipgloss.Height(content)+2, maxH)
return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight)
}
// 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)
capped := max(maxWidth-4, 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 one axis: configured
// (0 = unlimited) narrowed down to fits, whatever actually fits the
// background - a modal can never overflow past the edge of the background,
// or the terminal, when the background is a full-screen View(), regardless
// of how WithMaxWidth/WithMaxHeight 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")
}
+36
View File
@@ -0,0 +1,36 @@
package modal
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
// modal 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 modal 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 modal 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) - 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,
}
}
+1
View File
@@ -15,6 +15,7 @@ in
[
go
doctoc
(python3.withPackages (ps: [ps.pyte]))
]
++ hooks.enabledPackages;
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Run a TUI binary in a virtual terminal (via pyte) and print its rendered
screen as plain text, for visual verification of bubbletea/lipgloss output.
Dev tooling only, provided by the nix-shell. Not part of the Go module.
Usage:
tui-snapshot.py [--width W] [--height H] [--wait SECONDS] [--keys 'k:delay,...'] -- <cmd> [args...]
Example:
tui-snapshot.py --keys 'l:0.2,l:0.2,q:0' -- go run ./examples/tabs
"""
import argparse
import fcntl
import os
import pty
import select
import struct
import sys
import termios
import time
import pyte
class RepeatAwareScreen(pyte.Screen):
"""pyte has no support for the ECMA-48 REP sequence (``CSI Ps b``,
"repeat the preceding graphic character Ps times"), which bubbletea's
renderer uses to compress runs of identical styled cells (e.g. long
border/padding runs). Left unhandled, pyte silently drops it, corrupting
the frame. This adds the missing handler."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._last_drawn = " "
def draw(self, data):
super().draw(data)
if data:
self._last_drawn = data[-1]
def repeat_last_character(self, count=1):
self.draw(self._last_drawn * (count or 1))
class RepeatAwareStream(pyte.Stream):
"""Also works around a pyte bug: it maps ECMA-48 HPA (Character Position
Absolute, "move cursor to column Ps") to "'" (apostrophe, 0x27) instead
of the actual standard character "`" (backtick, 0x60) - see pyte's
escape.py. Real terminal apps send the correct backtick, which pyte then
silently no-ops on since it's not in its dispatch table, leaving the
cursor stuck instead of moving it. bubbletea's renderer uses HPA (mixed
with CUF) to jump the cursor to a border's column after erasing padding
with ECH, so without this the trailing border character gets drawn right
after the leading one instead of at the far edge."""
csi = {
**pyte.Stream.csi,
"b": "repeat_last_character",
"`": "cursor_to_column",
}
KEYMAP = {
"enter": "\r",
"esc": "\x1b",
"tab": "\t",
"up": "\x1b[A",
"down": "\x1b[B",
"right": "\x1b[C",
"left": "\x1b[D",
"space": " ",
}
def encode_key(key):
"""Resolve a --keys token to the bytes to write to the pty. Falls back to
the literal string for a plain character (e.g. 'l'), but without this,
something like 'ctrl+l' would be sent as the 6 literal characters
'c','t','r','l','+','l' instead of the single 0x0C control byte a real
terminal would send for Ctrl-L - silently testing the wrong thing."""
if key in KEYMAP:
return KEYMAP[key]
if key.startswith("ctrl+") and len(key) == 6 and key[5].isalpha():
return chr(ord(key[5].lower()) - ord("a") + 1)
return key
def set_size(fd, rows, cols):
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--width", type=int, default=80)
parser.add_argument("--height", type=int, default=24)
parser.add_argument(
"--wait", type=float, default=1.0, help="seconds to let the app render before the snapshot"
)
parser.add_argument(
"--keys",
default="",
help="comma-separated key:delay pairs sent before the final snapshot, e.g. 'l:0.2,l:0.2,q:0'",
)
parser.add_argument(
"--colors",
action="store_true",
help="also print, per row, the foreground color of each non-blank cell (for border/color bugs "
"that don't show up in the plain-text dump)",
)
parser.add_argument("cmd", nargs=argparse.REMAINDER)
args = parser.parse_args()
cmd = args.cmd
if cmd and cmd[0] == "--":
cmd = cmd[1:]
if not cmd:
parser.error("missing command to run")
screen = RepeatAwareScreen(args.width, args.height)
stream = RepeatAwareStream(screen)
# Open the pty and set its size *before* forking, so the child never
# observes a stale/default size on its first render (pyte has no way to
# recover from a corrupted initial frame drawn at the wrong width).
master_fd, slave_fd = pty.openpty()
set_size(slave_fd, args.height, args.width)
pid = os.fork()
if pid == 0:
os.close(master_fd)
os.setsid()
fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
os.dup2(slave_fd, 0)
os.dup2(slave_fd, 1)
os.dup2(slave_fd, 2)
os.close(slave_fd)
os.execvp(cmd[0], cmd)
os._exit(1)
os.close(slave_fd)
fd = master_fd
def pump(duration):
end = time.time() + duration
while True:
remaining = end - time.time()
if remaining <= 0:
break
r, _, _ = select.select([fd], [], [], remaining)
if fd not in r:
continue
try:
data = os.read(fd, 65536)
except OSError:
break
if not data:
break
stream.feed(data.decode(errors="ignore"))
pump(args.wait)
for pair in filter(None, args.keys.split(",")):
key, _, delay = pair.partition(":")
try:
os.write(fd, encode_key(key).encode())
except OSError:
break
pump(float(delay) if delay else 0.3)
try:
os.kill(pid, 15)
os.waitpid(pid, 0)
except (ProcessLookupError, ChildProcessError):
pass
for y, line in enumerate(screen.display):
print(line.rstrip())
if args.colors:
row = screen.buffer[y]
cells = []
for x in sorted(row):
ch = row[x]
if ch.data.strip():
fg = ch.fg if ch.fg != "default" else "-"
cells.append(f"{x}:{ch.data!r}:{fg}")
if cells:
print(" " + " ".join(cells))
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
# notification
Toast-style notifications, triggered from anywhere in a bubbletea program via an exported
`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.
## Quick start
```go
import (
"github.com/anotherhadi/ilovetui/notification"
)
type model struct {
notif notification.Model
width, height int
}
func newModel() model {
return model{notif: notification.New()}
}
func (m model) Init() tea.Cmd { return m.notif.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "s" {
return m, notification.Show("Saved", "Config written to disk", notification.Success)
}
}
var cmd tea.Cmd
m.notif, cmd = m.notif.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
background := renderYourUI(m.width, m.height)
view := tea.NewView(m.notif.Render(background))
view.AltScreen = true
return view
}
```
Any component in the same bubbletea program can trigger a toast via `notification.Show`, without
holding a reference to the `notification.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.
## Showing and dismissing
```go
return m, notification.Show("Saved", "Config written to disk", notification.Success)
return m, notification.Show("Sticky", "Stays until dismissed",
notification.Info, notification.WithID("sticky-demo"), notification.WithDuration(0))
return m, notification.Dismiss("sticky-demo")
```
Four kinds: `Info`, `Success`, `Warning`, `Error`, each with its own color preset (see Styling
below). By default a toast auto-dismisses after `notification.DefaultDuration` (3s);
`WithDuration(0)` makes it sticky - it stays until `Dismiss(id)` removes it, so a sticky toast
needs `WithID` to be dismissable later (an auto-generated id is never returned to the caller).
Showing again with the same id replaces the toast in place, resetting its position and timer,
instead of stacking a duplicate.
## Position and stacking
```go
n := notification.New(notification.WithPosition(notification.TopRight))
```
Six anchors: `Top`, `TopLeft`, `TopRight`, `Bottom`, `BottomLeft`, `BottomRight` - toasts always
hug an edge or corner, never the middle of the screen. Multiple toasts stack along the anchored
edge, newest closest to it; a stack that overflows the background's height clips the oldest
toasts first, so the newest ones stay visible.
## Styling
```go
n := notification.New(notification.WithMaxWidth(40), notification.WithStyles(myStyles))
return m, notification.Show("Title", "Message", notification.Success,
notification.WithToastStyle(oneOffStyle))
```
`WithMaxWidth` caps how wide a toast box can grow before its message wraps; a toast narrower than
the cap shrinks to fit its content instead of padding out to it. A toast can also never overflow
past the edge of whatever background it's rendered on, regardless of this cap. `WithStyles` sets
the default per-`Kind` look for every toast shown by this `Model`; `WithToastStyle` (a `Show`
option) overrides it for one toast alone. `DefaultStyles()` builds from `style.S`: `Info` uses
`Primary` (no dedicated "info" color in the theme), `Success`/`Warning`/`Error` use their matching
`style.S` alias.
## Examples
- `examples/notification` - all four kinds, a sticky toast with manual dismiss, cycling through
all six positions.
+115
View File
@@ -0,0 +1,115 @@
// Package notification renders toast-style notifications, triggered from
// anywhere in a bubbletea program via an exported tea.Msg (see ShowMsg/Show)
// 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
// Model.Render and Model.View).
package notification
import (
"fmt"
"time"
tea "charm.land/bubbletea/v2"
)
// Model holds the currently visible toasts and the rendering config
// (position, max width, per-Kind styles) they share. Build one with New.
type Model struct {
toasts []Toast
nextID int
position Position
maxWidth int
styles Styles
}
// Option configures a Model at construction. See WithPosition, WithMaxWidth,
// WithStyles.
type Option func(*Model)
// WithPosition sets which edge/corner the toast stack anchors to. TopRight
// by default.
func WithPosition(p Position) Option {
return func(m *Model) { m.position = p }
}
// WithMaxWidth caps how wide a toast box can grow before its message
// wraps. A toast narrower than this shrinks to fit its content instead of
// padding out to the cap. 0 (also the zero-value Model's default without
// New) means unlimited.
func WithMaxWidth(w int) Option {
return func(m *Model) { m.maxWidth = w }
}
// WithStyles overrides the default per-Kind styles (see DefaultStyles).
func WithStyles(s Styles) Option {
return func(m *Model) { m.styles = s }
}
// New builds a Model. Defaults: TopRight, a 40-cell max width, DefaultStyles.
func New(opts ...Option) Model {
m := Model{
position: TopRight,
maxWidth: 40,
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.Toast)
case DismissMsg:
return m.remove(msg.ID), nil
case expireMsg:
return m.remove(msg.id), nil
}
return m, nil
}
// show adds or replaces (see WithID) a toast, and schedules its expiry via
// tea.Tick if it isn't sticky (Duration <= 0).
func (m Model) show(t Toast) (Model, tea.Cmd) {
if t.ID == "" {
t.ID = fmt.Sprintf("toast-%d", m.nextID)
m.nextID++
}
replaced := false
for i, existing := range m.toasts {
if existing.ID == t.ID {
m.toasts[i] = t
replaced = true
break
}
}
if !replaced {
m.toasts = append(m.toasts, t)
}
if t.Duration <= 0 {
return m, nil
}
id := t.ID
return m, tea.Tick(t.Duration, func(time.Time) tea.Msg {
return expireMsg{id: id}
})
}
func (m Model) remove(id string) Model {
for i, t := range m.toasts {
if t.ID == id {
m.toasts = append(m.toasts[:i], m.toasts[i+1:]...)
break
}
}
return m
}
+58
View File
@@ -0,0 +1,58 @@
package notification
// Position anchors the toast stack to one of six spots on the rendered
// background. There's no center/middle variant: toasts always hug an edge or
// a corner, never the middle of the screen.
type Position int
const (
Top Position = iota
TopLeft
TopRight
Bottom
BottomLeft
BottomRight
)
// margin is the fixed gap, in cells, kept between the toast stack and the
// edge(s) of the background it's anchored to.
const margin = 1
// placement resolves the top-left (x, y) coordinate to draw a stack of size
// (sw, sh) at, given a background of size (w, h) and the anchor position.
func placement(pos Position, w, h, sw, sh int) (x, y int) {
switch pos {
case Top:
x = (w - sw) / 2
y = margin
case TopLeft:
x = margin
y = margin
case TopRight:
x = w - sw - margin
y = margin
case Bottom:
x = (w - sw) / 2
y = h - sh - margin
case BottomLeft:
x = margin
y = h - sh - margin
case BottomRight:
x = w - sw - margin
y = h - sh - margin
}
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
return x, y
}
// anchoredTop reports whether pos hugs the top edge, which decides both the
// stacking order (see Model.orderedToasts) and which side of an overflowing
// stack gets clipped (see clipToHeight).
func (pos Position) anchoredTop() bool {
return pos == Top || pos == TopLeft || pos == TopRight
}
+167
View File
@@ -0,0 +1,167 @@
package notification
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// Render composites the current toasts on top of background (already
// rendered, e.g. layout.Model.View() or any other component's View()) and
// returns the result. background is returned unchanged whenever there's
// nothing to draw (no toasts, or a background with no measurable size).
//
// This is what makes notification work identically with or without layout:
// the host just wraps whatever it would otherwise return from its own
// View() with this call.
func (m Model) Render(background string) string {
if len(m.toasts) == 0 {
return background
}
w, h := lipgloss.Width(background), lipgloss.Height(background)
if w <= 0 || h <= 0 {
return background
}
stack := clipToHeight(m.renderStack(effectiveMaxWidth(m.maxWidth, w)), h-2*margin, m.position.anchoredTop())
if stack == "" {
return background
}
sw, sh := lipgloss.Width(stack), lipgloss.Height(stack)
x, y := placement(m.position, w, h, sw, sh)
// Canvas.Compose(layer) alone ignores the layer's X/Y and draws it across
// the canvas's whole bounds, not just its own footprint - that's what
// made the toast layer blank out the entire background instead of
// floating over it. Compositor is what actually resolves each layer's
// absolute bounds (background at 0,0, the stack at x,y) before drawing
// each one only within its own area.
compositor := lipgloss.NewCompositor(
lipgloss.NewLayer(background),
lipgloss.NewLayer(stack).X(x).Y(y).Z(1),
)
return compositor.Render()
}
// View is a convenience for a pane whose sole purpose is showing toasts (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))
}
// renderStack stacks every visible toast into one block, newest closest to
// the anchored edge (see Position.anchoredTop), separated by a blank line,
// and aligned so the edge the stack anchors to stays flush across toasts of
// different widths.
func (m Model) renderStack(maxWidth int) string {
ordered := m.orderedToasts()
parts := make([]string, 0, len(ordered)*2-1)
for i, t := range ordered {
if i > 0 {
parts = append(parts, "")
}
parts = append(parts, m.renderToast(t, maxWidth))
}
return lipgloss.JoinVertical(stackAlign(m.position), parts...)
}
// orderedToasts returns the toasts in the order they should stack, newest
// nearest the anchored edge: reversed (newest first) for a top anchor,
// insertion order (oldest first, newest last) for a bottom anchor.
func (m Model) orderedToasts() []Toast {
if !m.position.anchoredTop() {
return m.toasts
}
ordered := make([]Toast, len(m.toasts))
for i, t := range m.toasts {
ordered[len(m.toasts)-1-i] = t
}
return ordered
}
func stackAlign(pos Position) lipgloss.Position {
switch pos {
case TopLeft, BottomLeft:
return lipgloss.Left
case TopRight, BottomRight:
return lipgloss.Right
default:
return lipgloss.Center
}
}
// renderToast draws a single toast as a box with its title embedded in the
// top border (style.RenderWithTitle), shrunk to fit its content up to
// maxWidth.
func (m Model) renderToast(t Toast, maxWidth int) string {
k := m.styles.forKind(t)
inner := contentWidth(t, maxWidth)
message := k.Message.Width(inner).Render(t.Message)
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
boxHeight := lipgloss.Height(message) + 2
return style.RenderWithTitle(k.Border, k.Title.Render(t.Title), message, boxWidth, boxHeight)
}
// contentWidth is the toast's inner (border/padding excluded) width: its
// natural size (long enough for the wider of title/message on one line),
// capped at maxWidth if positive.
func contentWidth(t Toast, maxWidth int) int {
natural := max(lipgloss.Width(t.Title), lipgloss.Width(t.Message), 1)
if maxWidth <= 0 {
return natural
}
capped := max(maxWidth-4, 1)
return min(natural, capped)
}
// effectiveMaxWidth resolves the cap actually used to render a toast:
// configured (Model.maxWidth, 0 = unlimited) narrowed down to whatever
// actually fits the background it's about to be drawn on, so a toast can
// never overflow past the edge of the background - or the terminal, when
// the background is a full-screen View() - regardless of how WithMaxWidth
// was set. bgWidth is background's own width, already measured by Render.
func effectiveMaxWidth(configured, bgWidth int) int {
fits := max(bgWidth-2*margin, 1)
if configured > 0 && configured < fits {
return configured
}
return fits
}
// clipToHeight trims stack to at most maxHeight lines when it overflows,
// keeping the lines nearest the anchored edge (top rows for a top anchor,
// bottom rows for a bottom anchor) so the newest toasts - always nearest
// that edge, see orderedToasts - are the ones that stay visible.
func clipToHeight(stack string, maxHeight int, anchoredTop bool) string {
lines := strings.Split(stack, "\n")
if len(lines) <= maxHeight {
return stack
}
if maxHeight <= 0 {
return ""
}
if anchoredTop {
lines = lines[:maxHeight]
} else {
lines = lines[len(lines)-maxHeight:]
}
return strings.Join(lines, "\n")
}
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")
}
+73
View File
@@ -0,0 +1,73 @@
package notification
import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// KindStyle is the set of lipgloss styles used to render one toast: Border
// carries the box's border (shape + color, no size), Title and Message color
// the two pieces of text drawn inside it. Building a value directly (rather
// than through a constructor) is the intended way to hand WithToastStyle a
// custom, per-toast look.
type KindStyle struct {
Border lipgloss.Style
Title lipgloss.Style
Message lipgloss.Style
}
// Styles maps each Kind to the KindStyle used to render it. Build one with
// DefaultStyles and tweak individual fields, or construct one from scratch
// for a fully custom palette across all kinds.
type Styles struct {
Info KindStyle
Success KindStyle
Warning KindStyle
Error KindStyle
}
// DefaultStyles builds a Styles from style.S: Info uses the theme's primary
// accent (style.S has no dedicated "info" color, Primary already fills that
// neutral-accent role elsewhere in this repo), Success/Warning/Error use
// their matching style.S alias.
func DefaultStyles() Styles {
return Styles{
Info: kindStyle(style.S.Primary),
Success: kindStyle(style.S.Success),
Warning: kindStyle(style.S.Warning),
Error: kindStyle(style.S.Error),
}
}
func kindStyle(c color.Color) KindStyle {
return KindStyle{
Border: lipgloss.NewStyle().
Border(style.S.BorderType).
BorderForeground(c).
Padding(0, 1),
Title: lipgloss.NewStyle().Bold(true).Foreground(c),
Message: lipgloss.NewStyle().Foreground(style.S.Text),
}
}
// forKind resolves the KindStyle to render t with: its own Style override if
// set, otherwise s's preset for t.Kind (falling back to Info for an
// out-of-range Kind).
func (s Styles) forKind(t Toast) KindStyle {
if t.Style != nil {
return *t.Style
}
switch t.Kind {
case Success:
return s.Success
case Warning:
return s.Warning
case Error:
return s.Error
default:
return s.Info
}
}
+107
View File
@@ -0,0 +1,107 @@
package notification
import (
"time"
tea "charm.land/bubbletea/v2"
)
// Kind picks which of Styles' presets a toast renders with, unless
// overridden per-toast via WithToastStyle.
type Kind int
const (
Info Kind = iota
Success
Warning
Error
)
// DefaultDuration is how long a toast stays visible when WithDuration isn't
// used. Show has no reference to a Model (see ShowMsg's doc comment), so this
// lives as a package constant rather than a Model-level default.
const DefaultDuration = 3 * time.Second
// Toast is one notification. Build it via Show's opts rather than a literal:
// ID and Duration both get defaults (see WithID, DefaultDuration) that a bare
// literal would silently skip.
type Toast struct {
ID string
Title string
Message string
Kind Kind
// Duration is how long the toast stays up before auto-dismissing. 0
// means sticky: it stays until DismissMsg/Dismiss(ID) removes it.
Duration time.Duration
// Style, if non-nil, overrides the Model's Kind-based preset for this
// toast alone.
Style *KindStyle
}
// ToastOption configures a Toast built by Show.
type ToastOption func(*Toast)
// WithID gives the toast a stable id, so a later Show reusing the same id
// replaces it in place (resetting its position and timer) instead of
// stacking a duplicate, and so it can be targeted by Dismiss.
func WithID(id string) ToastOption {
return func(t *Toast) { t.ID = id }
}
// WithDuration overrides DefaultDuration. 0 makes the toast sticky: it never
// auto-dismisses, only Dismiss(ID) removes it.
func WithDuration(d time.Duration) ToastOption {
return func(t *Toast) { t.Duration = d }
}
// WithToastStyle overrides the Model's Kind-based preset for this toast
// alone, for a one-off custom look instead of the type-based theme.
func WithToastStyle(s KindStyle) ToastOption {
return func(t *Toast) { t.Style = &s }
}
func newToast(title, message string, kind Kind, opts ...ToastOption) Toast {
t := Toast{
Title: title,
Message: message,
Kind: kind,
Duration: DefaultDuration,
}
for _, opt := range opts {
opt(&t)
}
return t
}
// ShowMsg tells a notification.Model to display Toast. Any component in the
// same bubbletea program can trigger one via Show, without holding a
// reference to the notification.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{ Toast Toast }
// Show returns a tea.Cmd that shows a new toast of the given kind. Call it
// from any component's Update:
//
// return m, notification.Show("Saved", "Config written to disk", notification.Success)
func Show(title, message string, kind Kind, opts ...ToastOption) tea.Cmd {
t := newToast(title, message, kind, opts...)
return func() tea.Msg { return ShowMsg{Toast: t} }
}
// DismissMsg removes the toast identified by ID, whether it's sticky or
// mid-countdown. A no-op if ID isn't currently shown (already expired, or
// never had an explicit id in the first place - see WithID).
type DismissMsg struct{ ID string }
// Dismiss returns a tea.Cmd that removes the toast identified by id. Only
// useful for toasts shown with WithID, since an auto-generated id is never
// exposed back to the caller.
func Dismiss(id string) tea.Cmd {
return func() tea.Msg { return DismissMsg{ID: id} }
}
// expireMsg fires once a toast's Duration has elapsed, scheduled by
// Model.show via tea.Tick. Unexported: nothing outside the package should
// construct or match on it directly, that's what DismissMsg is for.
type expireMsg struct{ id string }
+72
View File
@@ -0,0 +1,72 @@
package style
import (
"strings"
"charm.land/lipgloss/v2"
)
// borderTypes maps the `border:` config value to a lipgloss.Border. Only the
// symmetric, general-purpose border kinds are exposed here; MarkdownBorder,
// BlockBorder and the half-block variants are content-specific rather than a
// theming choice.
var borderTypes = map[string]lipgloss.Border{
"rounded": lipgloss.RoundedBorder(),
"normal": lipgloss.NormalBorder(),
"thick": lipgloss.ThickBorder(),
"double": lipgloss.DoubleBorder(),
"hidden": lipgloss.HiddenBorder(),
"ascii": lipgloss.ASCIIBorder(),
}
// resolveBorderType maps a `border:` config value to a lipgloss.Border,
// falling back to RoundedBorder for an empty or unrecognized name.
func resolveBorderType(name string) lipgloss.Border {
if b, ok := borderTypes[strings.ToLower(strings.TrimSpace(name))]; ok {
return b
}
return lipgloss.RoundedBorder()
}
// ContentHeight returns the usable inner height for a bordered panel of totalH rows.
func ContentHeight(totalH int) int {
h := totalH - 2
if h < 0 {
return 0
}
return h
}
// RenderWithTitle renders a bordered box with a title embedded in the top border.
// title may contain ANSI color codes. width and height are the total outer dimensions.
//
// Example:
//
// box := style.RenderWithTitle(style.S.PanelFocused, "Header", content, w, h)
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
boxH := height - 1
if contentH := boxH - 1; contentH > 0 {
lines := strings.Split(content, "\n")
if len(lines) > contentH {
content = strings.Join(lines[:contentH], "\n")
}
}
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
titleW := lipgloss.Width(title)
// Pull the corner/fill glyphs from the style's own border spec instead of
// hardcoding rounded-border characters, so this respects style.S.BorderType.
b, _, _, _, _ := border.GetBorder()
topLeft, top, topRight := b.TopLeft, b.Top, b.TopRight
fillW := boxWidth - titleW - lipgloss.Width(topLeft) - lipgloss.Width(topRight) - 2 // 2 = the spaces around the title
if fillW < 0 {
fillW = 0
}
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
topLine := bc.Render(topLeft+" ") + bc.Render(title) + bc.Render(" "+strings.Repeat(top, fillW)+topRight)
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
}
+64
View File
@@ -0,0 +1,64 @@
package style
type colorsYAML struct {
Base00 string `yaml:"base00"`
Base01 string `yaml:"base01"`
Base02 string `yaml:"base02"`
Base03 string `yaml:"base03"`
Base04 string `yaml:"base04"`
Base05 string `yaml:"base05"`
Base06 string `yaml:"base06"`
Base07 string `yaml:"base07"`
Base08 string `yaml:"base08"`
Base09 string `yaml:"base09"`
Base0A string `yaml:"base0a"`
Base0B string `yaml:"base0b"`
Base0C string `yaml:"base0c"`
Base0D string `yaml:"base0d"`
Base0E string `yaml:"base0e"`
Base0F string `yaml:"base0f"`
}
type configYAML struct {
Colors colorsYAML `yaml:"colors"`
NerdFonts bool `yaml:"nerd_fonts"`
Border string `yaml:"border"`
}
func pickString(base, user string) string {
if user != "" {
return user
}
return base
}
func mergeConfig(base, user configYAML) configYAML {
return configYAML{
Colors: mergeColors(base.Colors, user.Colors),
// The embedded default is always nerd_fonts: false, so this just
// reduces to "whatever the user set".
NerdFonts: base.NerdFonts || user.NerdFonts,
Border: pickString(base.Border, user.Border),
}
}
func mergeColors(base, user colorsYAML) colorsYAML {
return colorsYAML{
Base00: pickString(base.Base00, user.Base00),
Base01: pickString(base.Base01, user.Base01),
Base02: pickString(base.Base02, user.Base02),
Base03: pickString(base.Base03, user.Base03),
Base04: pickString(base.Base04, user.Base04),
Base05: pickString(base.Base05, user.Base05),
Base06: pickString(base.Base06, user.Base06),
Base07: pickString(base.Base07, user.Base07),
Base08: pickString(base.Base08, user.Base08),
Base09: pickString(base.Base09, user.Base09),
Base0A: pickString(base.Base0A, user.Base0A),
Base0B: pickString(base.Base0B, user.Base0B),
Base0C: pickString(base.Base0C, user.Base0C),
Base0D: pickString(base.Base0D, user.Base0D),
Base0E: pickString(base.Base0E, user.Base0E),
Base0F: pickString(base.Base0F, user.Base0F),
}
}
+10 -1
View File
@@ -1,5 +1,14 @@
# ilovetui default theme
# ilovetui default config
# Copy to ~/.config/ilovetui/config.yaml and edit to customize.
# Whether components may use Nerd Font glyphs (requires a patched font).
# Leave false for maximum terminal/font compatibility.
nerd_fonts: false
# Border style used by panels across all ilovetui-based TUIs.
# One of: rounded, normal, thick, double, hidden, ascii.
border: rounded
colors:
base00: "#110F12" # Background
base01: "#1C1920" # Lighter Background / Status Bars
+1 -1
View File
@@ -1,4 +1,4 @@
package ilovetui
package style
import (
"fmt"
+14 -5
View File
@@ -1,4 +1,4 @@
package ilovetui
package style
import (
"image/color"
@@ -44,7 +44,11 @@ type Styles struct {
Bold lipgloss.Style
Faint lipgloss.Style
// Pre-built panel styles (rounded border)
// User preferences, read from config
NerdFonts bool
BorderType lipgloss.Border
// Pre-built panel styles, bordered with BorderType
Panel lipgloss.Style
PanelFocused lipgloss.Style
@@ -53,7 +57,7 @@ type Styles struct {
PagerDotInactive string
}
func newStyles(c colorsYAML) Styles {
func newStyles(c colorsYAML, nerdFonts bool, borderName string) Styles {
lc := func(s string) color.Color {
s = strings.TrimSpace(s)
if s != "" && s[0] != '#' {
@@ -79,6 +83,8 @@ func newStyles(c colorsYAML) Styles {
b0E := lc(c.Base0E)
b0F := lc(c.Base0F)
borderType := resolveBorderType(borderName)
return Styles{
Base00: b00, Base01: b01, Base02: b02, Base03: b03,
Base04: b04, Base05: b05, Base06: b06, Base07: b07,
@@ -99,12 +105,15 @@ func newStyles(c colorsYAML) Styles {
Bold: lipgloss.NewStyle().Bold(true),
Faint: lipgloss.NewStyle().Foreground(b03).Faint(true),
NerdFonts: nerdFonts,
BorderType: borderType,
Panel: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Border(borderType).
BorderForeground(b03),
PanelFocused: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Border(borderType).
BorderForeground(b0D),
PagerDotActive: lipgloss.NewStyle().Foreground(b0D).SetString("•").String(),
+117
View File
@@ -0,0 +1,117 @@
// Package style provides a shared Base16 color theme for bubbletea/lipgloss
// applications. The theme is loaded automatically on import from
// ~/.config/ilovetui/config.yaml (falling back to the embedded
// default config). Access colors and styles via the package-level variable S.
//
// import "github.com/anotherhadi/ilovetui/style"
//
// s := lipgloss.NewStyle().Foreground(style.S.Primary)
// box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h)
package style
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
//go:embed default.yaml
var DefaultConfig []byte
// S is the active theme. It is populated automatically at import time and can
// be reloaded at any point by calling Init, InitFrom, or InitFromBytes.
var S Styles
func init() {
path := DefaultConfigPath()
if data, err := os.ReadFile(path); err == nil {
if s, err := stylesFromBytes(data); err == nil {
S = s
return
}
}
// Silent fallback: embedded default always works.
s, _ := stylesFromBytes(DefaultConfig)
S = s
}
// Init reloads S from the user config file, falling back to the embedded
// default if the file is missing. Returns an error only on parse failures.
func Init() error {
path := DefaultConfigPath()
data, err := os.ReadFile(path)
if err != nil {
s, e := stylesFromBytes(DefaultConfig)
if e != nil {
return e
}
S = s
return nil
}
return InitFromBytes(data)
}
// InitFrom reloads S from an explicit file path.
func InitFrom(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("style: read config: %w", err)
}
return InitFromBytes(data)
}
// InitFromBytes reloads S from raw YAML. Accepts hex strings with or without
// the leading '#'.
func InitFromBytes(data []byte) error {
s, err := stylesFromBytes(data)
if err != nil {
return err
}
S = s
return nil
}
// WriteDefaultConfig writes the embedded default config to path, creating
// parent directories as needed. No-op if the file already exists.
func WriteDefaultConfig(path string) error {
if _, err := os.Stat(path); err == nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("style: create config dir: %w", err)
}
if err := os.WriteFile(path, DefaultConfig, 0o600); err != nil {
return fmt.Errorf("style: write config: %w", err)
}
return nil
}
// DefaultConfigPath returns the canonical user config path,
// respecting $XDG_CONFIG_HOME.
func DefaultConfigPath() string {
return filepath.Join(configDir(), "ilovetui", "config.yaml")
}
func stylesFromBytes(data []byte) (Styles, error) {
var base configYAML
if err := yaml.Unmarshal(DefaultConfig, &base); err != nil {
return Styles{}, fmt.Errorf("style: parse default config: %w", err)
}
var user configYAML
if err := yaml.Unmarshal(data, &user); err != nil {
return Styles{}, fmt.Errorf("style: parse config: %w", err)
}
merged := mergeConfig(base, user)
return newStyles(merged.Colors, merged.NerdFonts, merged.Border), nil
}
func configDir() string {
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
return dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config")
}
+112
View File
@@ -0,0 +1,112 @@
# tabs
A horizontal tab bar, styled from `style.S`. Switches between a set of items with
`left`/`right`/`h`/`l`/`tab`/`shift+tab`, wrapping around at either end by default. Draws its own
frame (tab bar + a `Content` box below it) that follows the theme's configured border family
(`style.S.BorderType`) and reads as one continuous box.
`tabs` only renders the bar and the frame around the active item's content - it never runs the
content itself; that's the host's job, same as any other custom component in this repo.
## Concepts
- **`Tab`** is what each tab shows: `Init() tea.Cmd`, `Update(tea.Msg) (Tab, tea.Cmd)`,
`View() string` - the same shape used by every other custom component in this repo.
- **`Item`** pairs a `Tab` with the `Title` shown on its tab.
- **`Model`** is the running tab bar: the item list, which one is active, focus state, size, and
styles. Build one with `tabs.New(items, opts...)`.
## Quick start
```go
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/tabs"
)
func main() {
items := []tabs.Item{
{Title: "First", Model: newPane("First")},
{Title: "Second", Model: newPane("Second")},
{Title: "Third", Model: newPane("Third")},
}
m := model{tabs: tabs.New(items)}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
```
See `examples/tabs` for the full `model`/`Tab` implementation, including sizing.
## Writing a Tab
```go
type Tab interface {
Init() tea.Cmd
Update(tea.Msg) (Tab, tea.Cmd)
View() string
}
```
`tabs.Update` routes every message that isn't a Next/Prev key press straight to the active item's
`Update`, so a `Tab` behaves like any other bubbletea model - it just never sees messages while a
different tab is active.
## Sizing
`tabs` has no generic way to size an arbitrary `Tab` itself (the interface is intentionally
minimal), so a host building a fullscreen app sizes the whole component, then forwards the actual
content area back into it:
```go
m.tabs.SetSize(width, height)
var cmd tea.Cmd
m.tabs, cmd = m.tabs.Update(tea.WindowSizeMsg{
Width: m.tabs.ContentWidth(),
Height: m.tabs.ContentHeight(),
})
```
The tab bar itself always keeps its intrinsic width (the sum of its tab labels); only `Content`
stretches to fill `Width`, so the bar never looks artificially stretched. `ContentWidth`/
`ContentHeight` report the usable inner area once `Width`/`Height` are set (`Content`'s box size
minus its own border and padding) - forward that to whatever `Tab` implementation needs to know
its own size, exactly as you'd size any other nested bubbles component.
When there are more tabs than fit `Width`, `tabs` collapses the overflow into a single trailing
`+N` badge, keeping a contiguous window around the active tab.
## Focus vs. active tab
Two independent things:
- **`Focused()`/`Focus()`/`Blur()`/`WithFocus(bool)`** (on by default) control the frame's border
color: `style.S.Primary` when focused, `style.S.Subtle` when blurred. Meant for host apps with
several panes that toggle focus between them (e.g. alongside `layout`) - the border color never
depends on which tab is active, only on whether `tabs` itself currently has keyboard focus.
- **Which item is active** is shown only by the tab's title style (`Styles.ActiveTitle` vs.
`InactiveTitle`), not by border color.
## Navigation
```go
km := tabs.DefaultKeyMap()
km.Next = key.NewBinding(key.WithKeys("right"), key.WithHelp("→", "next"))
m := tabs.New(items, tabs.WithKeyMap(km))
```
`WithLoop(false)` clamps at either end instead of wrapping; `WithActive(i)` sets the initial tab.
## Examples
- `examples/tabs` - a full fullscreen app: sizing, per-tab independent state, `+`/counter demo.
+533
View File
@@ -0,0 +1,533 @@
package tabs
import (
"fmt"
"image/color"
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
type Tab interface {
Init() tea.Cmd
Update(tea.Msg) (Tab, tea.Cmd)
View() string
}
type Item struct {
Title string
Model Tab
}
type KeyMap struct {
Next key.Binding
Prev key.Binding
}
func DefaultKeyMap() KeyMap {
return KeyMap{
Next: key.NewBinding(
key.WithKeys("right", "l", "tab"),
key.WithHelp("→/tab", "next tab"),
),
Prev: key.NewBinding(
key.WithKeys("left", "h", "shift+tab"),
key.WithHelp("←/shift+tab", "previous tab"),
),
}
}
type Styles struct {
// Border shape/padding for the tab boxes, color-less: the actual border
// color is picked at render time from FocusedBorder/BlurredBorder
// depending on Model.Focused(), so the whole tabs+content frame always
// reads as one continuous, single-colored box.
ActiveTab lipgloss.Style
InactiveTab lipgloss.Style
// Title text styles: this is what actually distinguishes the active tab
// from the others.
ActiveTitle lipgloss.Style
InactiveTitle lipgloss.Style
// Border shape/padding for the content pane, same color-less rule as
// above.
Content lipgloss.Style
// The border family (rounded, normal, thick...) tabs and Content are
// built from, snapshotted from style.S.BorderType at DefaultStyles()
// time. Kept around so renderBar can pick the right per-position notch
// glyph (corner vs. T-junction) for that same family at render time.
BorderType lipgloss.Border
FocusedBorder color.Color
BlurredBorder color.Color
}
func DefaultStyles() Styles {
bt := style.S.BorderType
inactiveBorder, activeBorder := tabBorders(bt)
return Styles{
InactiveTab: lipgloss.NewStyle().
Border(inactiveBorder, true).
Padding(0, 1),
ActiveTab: lipgloss.NewStyle().
Border(activeBorder, true).
Padding(0, 1),
ActiveTitle: lipgloss.NewStyle().Foreground(style.S.Primary).Bold(true),
InactiveTitle: lipgloss.NewStyle().Foreground(style.S.Subtle),
Content: lipgloss.NewStyle().
Border(bt).
UnsetBorderTop().
Padding(1, 2),
BorderType: bt,
FocusedBorder: style.S.Primary,
BlurredBorder: style.S.Subtle,
}
}
// tabBorders derives the inactive/active tab border shapes from a border
// family, using its own junction glyphs (MiddleBottom, MiddleLeft,
// MiddleRight...) instead of hardcoded characters, so tabs follow
// style.S.BorderType instead of always looking rounded regardless of config.
//
// Inactive tabs get a plain "┴"-style bottom (a Content has UnsetBorderTop,
// so this line is what actually separates the bar from Content below).
// The active tab's bottom is left open (blank) with its corners swapped to
// the family's own BottomLeft/BottomRight glyphs, so its sides appear to
// flow straight down into Content.
func tabBorders(bt lipgloss.Border) (inactive, active lipgloss.Border) {
inactive = bt
inactive.BottomLeft = bt.MiddleBottom
inactive.BottomRight = bt.MiddleBottom
active = bt
active.Bottom = " "
active.BottomLeft = bt.BottomRight
active.BottomRight = bt.BottomLeft
return inactive, active
}
type Model struct {
items []Item
active int
focused bool
loop bool
width int
height int
styles Styles
keyMap KeyMap
}
type Option func(*Model)
func WithStyles(s Styles) Option {
return func(m *Model) {
m.styles = s
}
}
func WithKeyMap(k KeyMap) Option {
return func(m *Model) {
m.keyMap = k
}
}
func WithActive(i int) Option {
return func(m *Model) {
m.active = i
}
}
// WithLoop sets whether Next/Prev navigation wraps around: Next from the
// last tab goes to the first, Prev from the first tab goes to the last.
// On by default; pass false to clamp at either end instead.
func WithLoop(l bool) Option {
return func(m *Model) {
m.loop = l
}
}
// WithFocus sets the initial focus state. A focused tabs bar renders its
// border in the accent color, a blurred one in the muted color, letting a
// host app with several panes show which one is currently active.
func WithFocus(f bool) Option {
return func(m *Model) {
m.focused = f
}
}
func New(items []Item, opts ...Option) Model {
m := Model{
items: items,
focused: true,
loop: true,
styles: DefaultStyles(),
keyMap: DefaultKeyMap(),
}
for _, opt := range opts {
opt(&m)
}
m.active = clamp(m.active, 0, len(m.items)-1)
return m
}
func (m Model) Init() tea.Cmd {
cmds := make([]tea.Cmd, len(m.items))
for i, item := range m.items {
cmds[i] = item.Model.Init()
}
return tea.Batch(cmds...)
}
func (m Model) Active() int {
return m.active
}
func (m *Model) SetActive(i int) {
m.active = clamp(i, 0, len(m.items)-1)
}
func (m Model) Items() []Item {
return m.items
}
func (m Model) ActiveItem() Item {
return m.items[m.active]
}
func (m Model) Focused() bool {
return m.focused
}
func (m *Model) Focus() {
m.focused = true
}
func (m *Model) Blur() {
m.focused = false
}
func (m Model) Loop() bool {
return m.loop
}
func (m *Model) SetLoop(l bool) {
m.loop = l
}
func (m Model) Width() int {
return m.width
}
// SetWidth sets the target outer width for the whole component. The tab bar
// itself always keeps its intrinsic width (the sum of its tab labels);
// Content stretches to Width if that's wider, so a host building a
// fullscreen app can make the content pane fill the terminal without the tab
// bar itself looking artificially stretched.
func (m *Model) SetWidth(w int) {
m.width = w
}
func (m Model) Height() int {
return m.height
}
// SetHeight sets the target outer height for the whole component. Content's
// height is Height minus the bar's own (fixed) height; SetHeight is a no-op
// on the render until called, so the zero-value Model keeps auto-sizing
// Content to whatever the active item's View() returns.
func (m *Model) SetHeight(h int) {
m.height = h
}
// SetSize is a shorthand for SetWidth followed by SetHeight.
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
}
// ContentWidth and ContentHeight report the usable inner area available to
// the active item's View() once Width/Height are set: Content's box size
// minus its own border and padding. tabs has no generic way to size an
// arbitrary Tab itself (the interface is intentionally minimal), so a host
// building a fullscreen app calls these after SetSize and forwards the
// result to its own Tab implementations, exactly as it would size any other
// nested bubbles component. ContentHeight returns 0 until SetHeight has been
// called (see SetHeight).
func (m Model) ContentWidth() int {
if len(m.items) == 0 {
return 0
}
w := lipgloss.Width(m.renderBar(m.collapsedSegments(m.width), m.styles.BlurredBorder, false))
if m.width > w {
w = m.width
}
if inner := w - m.styles.Content.GetHorizontalFrameSize(); inner > 0 {
return inner
}
return 0
}
func (m Model) ContentHeight() int {
if len(m.items) == 0 || m.height <= 0 {
return 0
}
barHeight := lipgloss.Height(m.renderBar(m.collapsedSegments(m.width), m.styles.BlurredBorder, false))
if inner := m.height - barHeight - m.styles.Content.GetVerticalFrameSize(); inner > 0 {
return inner
}
return 0
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
switch {
case key.Matches(keyMsg, m.keyMap.Next):
m.active = m.step(1)
return m, nil
case key.Matches(keyMsg, m.keyMap.Prev):
m.active = m.step(-1)
return m, nil
}
}
if len(m.items) == 0 {
return m, nil
}
var cmd tea.Cmd
m.items[m.active].Model, cmd = m.items[m.active].Model.Update(msg)
return m, cmd
}
// tabSegment is a single box drawn on the bar: either a real item, or the
// synthetic "+N" segment standing in for tabs collapsed by collapsedSegments.
// It's never active and never has a backing Item.
type tabSegment struct {
title string
isActive bool
isMore bool
}
func (m Model) segments() []tabSegment {
segs := make([]tabSegment, len(m.items))
for i, item := range m.items {
segs[i] = tabSegment{title: item.Title, isActive: i == m.active}
}
return segs
}
// segmentWidth measures a segment as it would actually render, without
// needing a border color (color doesn't affect measured width).
func (m Model) segmentWidth(seg tabSegment) int {
tabStyle := m.styles.InactiveTab
titleStyle := m.styles.InactiveTitle
if seg.isActive {
tabStyle = m.styles.ActiveTab
titleStyle = m.styles.ActiveTitle
}
return lipgloss.Width(tabStyle.Render(titleStyle.Render(seg.title)))
}
// collapsedSegments returns the full segment list unchanged if it already
// fits within budget (or budget is unset). Otherwise it keeps a contiguous
// window of tabs that always includes the active one - grown outward from
// active, alternating backward/forward, as far as it fits - and folds
// everything left out of that window into a single trailing "+N" segment.
func (m Model) collapsedSegments(budget int) []tabSegment {
segs := m.segments()
if budget <= 0 {
return segs
}
widths := make([]int, len(segs))
total := 0
for i, seg := range segs {
widths[i] = m.segmentWidth(seg)
total += widths[i]
}
if total <= budget {
return segs
}
moreWidth := m.segmentWidth(tabSegment{title: fmt.Sprintf("+%d", len(segs)-1), isMore: true})
fitBudget := budget - moreWidth
if fitBudget < widths[m.active] {
// Not even room for active + the badge: guarantee active alone
// fits, even if that leaves the badge slightly cramped.
fitBudget = widths[m.active]
}
start, end := m.active, m.active
used := widths[m.active]
for {
grew := false
if start > 0 && used+widths[start-1] <= fitBudget {
start--
used += widths[start]
grew = true
}
if end < len(segs)-1 && used+widths[end+1] <= fitBudget {
end++
used += widths[end]
grew = true
}
if !grew {
break
}
}
hidden := len(segs) - (end - start + 1)
if hidden <= 0 {
return segs
}
visible := append([]tabSegment{}, segs[start:end+1]...)
visible = append(visible, tabSegment{title: fmt.Sprintf("+%d", hidden), isMore: true})
return visible
}
func (m Model) View() string {
if len(m.items) == 0 {
return ""
}
borderColor := m.styles.BlurredBorder
if m.focused {
borderColor = m.styles.FocusedBorder
}
segs := m.collapsedSegments(m.width)
bar := m.renderBar(segs, borderColor, false)
contentWidth := lipgloss.Width(bar)
if m.width > contentWidth {
// Re-render with the last tab's right edge treated as an interior
// junction instead of the widget's outer edge, since the cap line
// now continues past it into the extension.
bar = m.extendBarCap(m.renderBar(segs, borderColor, true), m.width, borderColor)
contentWidth = m.width
}
contentStyle := m.styles.Content.
BorderForeground(borderColor).
Width(contentWidth)
if m.height > 0 {
if h := m.height - lipgloss.Height(bar); h > 0 {
contentStyle = contentStyle.Height(h)
}
}
content := contentStyle.Render(m.items[m.active].Model.View())
return lipgloss.JoinVertical(lipgloss.Left, bar, content)
}
// renderBar builds the tab bar. extendCap should be true when the caller
// already knows the cap line will be stretched past the last tab (see
// extendBarCap): in that case the last tab's right edge is drawn as an
// interior junction (bt.MiddleLeft) rather than the widget's outer edge,
// since the horizontal line continues past it instead of terminating there.
func (m Model) renderBar(segs []tabSegment, borderColor color.Color, extendCap bool) string {
rendered := make([]string, len(segs))
for i, seg := range segs {
isFirst, isLast := i == 0, i == len(segs)-1
tabStyle := m.styles.InactiveTab
titleStyle := m.styles.InactiveTitle
if seg.isActive {
tabStyle = m.styles.ActiveTab
titleStyle = m.styles.ActiveTitle
}
tabStyle = tabStyle.BorderForeground(borderColor)
bt := m.styles.BorderType
border, _, _, _, _ := tabStyle.GetBorder()
switch {
case isFirst && seg.isActive:
border.BottomLeft = bt.Left
case isFirst && !seg.isActive:
border.BottomLeft = bt.MiddleLeft
case isLast && seg.isActive && !extendCap:
border.BottomRight = bt.Right
case isLast && !seg.isActive && !extendCap:
border.BottomRight = bt.MiddleRight
}
// extendCap: no BottomRight override at all, so the last tab falls
// back to its type's plain default (already set in DefaultStyles:
// bt.MiddleBottom for inactive, the swap-trick corner for active) -
// same as every other, non-edge tab. The isLast-specific corners
// above only make sense when this really is the widget's edge and
// Content's own border aligns right below it; once the cap extends
// past it, that's no longer true.
tabStyle = tabStyle.Border(border)
rendered[i] = tabStyle.Render(titleStyle.Render(seg.title))
}
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
}
// extendBarCap stretches only the bar's bottom row out to width. That row
// doubles as Content's own top border (Content has UnsetBorderTop), so when
// Content is wider than the bar's natural width, it needs to reach all the
// way across or the frame looks broken open above the extra space.
// lipgloss.JoinVertical would otherwise pad the shorter bar rows with plain
// spaces, not border characters.
func (m Model) extendBarCap(bar string, width int, borderColor color.Color) string {
gap := width - lipgloss.Width(bar)
if gap <= 0 {
return bar
}
bt := m.styles.BorderType
fill := lipgloss.NewStyle().
Foreground(borderColor).
Render(strings.Repeat(bt.Bottom, gap-1) + bt.TopRight)
lines := strings.Split(bar, "\n")
lines[len(lines)-1] += fill
return strings.Join(lines, "\n")
}
// step moves the active index by delta (+1 for Next, -1 for Prev). With Loop
// it wraps around at either end; otherwise it just clamps, so Next on the
// last tab (or Prev on the first) is a no-op.
func (m Model) step(delta int) int {
n := len(m.items)
if n == 0 {
return 0
}
if m.loop {
return ((m.active+delta)%n + n) % n
}
return clamp(m.active+delta, 0, n-1)
}
func clamp(v, low, high int) int {
if high < low {
return low
}
if v < low {
return low
}
if v > high {
return high
}
return v
}