mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 12:05:49 +02:00
init layout, notifications, tabs & more
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -1,22 +1,28 @@
|
|||||||
# Ilovetui
|
# 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.
|
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
|
## 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`.
|
If no config exists, it falls back to the embedded default. The active theme is exposed as the package-level variable `S`.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "github.com/anotherhadi/ilovetui"
|
import "github.com/anotherhadi/ilovetui/style"
|
||||||
|
|
||||||
// Use colors directly
|
// Use colors directly
|
||||||
style := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
s := lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||||
|
|
||||||
// Use pre-built panel styles
|
// 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.
|
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 |
|
| `Warning` | Base09 | Integers / Constants / Booleans |
|
||||||
| `Error` | Base08 | Variables / Errors / Diff Deleted |
|
| `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
|
```sh
|
||||||
mkdir -p ~/.config/ilovetui
|
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:
|
Or let your app write it on first run:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
ilovetui.WriteDefaultConfig(ilovetui.DefaultConfigPath())
|
style.WriteDefaultConfig(style.DefaultConfigPath())
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pre-built styles
|
## Pre-built styles
|
||||||
@@ -72,22 +78,59 @@ ilovetui.WriteDefaultConfig(ilovetui.DefaultConfigPath())
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
// Inner usable height of a bordered panel with outer height h
|
// 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
|
// 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
|
## API
|
||||||
|
|
||||||
```go
|
```go
|
||||||
ilovetui.Init() // Reload from default config path
|
style.Init() // Reload from default config path
|
||||||
ilovetui.InitFrom(path string) // Reload from a custom path
|
style.InitFrom(path string) // Reload from a custom path
|
||||||
ilovetui.InitFromBytes(data []byte) // Parse raw YAML
|
style.InitFromBytes(data []byte) // Parse raw YAML
|
||||||
ilovetui.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml
|
style.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml
|
||||||
ilovetui.WriteDefaultConfig(path) // Write default config if missing
|
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
|
## 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.
|
- [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.
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,19 +4,20 @@ go 1.25.8
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
charm.land/bubbles/v2 v2.1.0
|
charm.land/bubbles/v2 v2.1.0
|
||||||
|
charm.land/bubbletea/v2 v2.0.2
|
||||||
charm.land/glamour/v2 v2.0.0
|
charm.land/glamour/v2 v2.0.0
|
||||||
charm.land/lipgloss/v2 v2.0.3
|
charm.land/lipgloss/v2 v2.0.3
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.7
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
charm.land/bubbletea/v2 v2.0.2 // indirect
|
|
||||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // 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/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/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/charmbracelet/x/termios v0.1.1 // 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/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.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/gorilla/css v1.0.1 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.7.8 // indirect
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
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 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
||||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
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/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 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
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 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
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/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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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=
|
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
|||||||
-115
@@ -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
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
# layout
|
||||||
|
|
||||||
|
Arrange panes in a binary split tree (BSP, tmux/i3-style), navigate between them with
|
||||||
|
spatial `ctrl+hjkl`, and get a help bar that always reflects whatever's focused - no
|
||||||
|
matter how deep the tree is, or how many of those splits are themselves other `layout`
|
||||||
|
trees nested inside a pane.
|
||||||
|
|
||||||
|
`layout` only owns geometry, focus and routing. It draws no border and imposes no
|
||||||
|
style: every pane decides how to render itself for the size and focus state it's given.
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
Three things:
|
||||||
|
|
||||||
|
- **`Pane`** is your content: `Init() tea.Cmd`, `Update(tea.Msg) (Pane, tea.Cmd)`,
|
||||||
|
`View() string` - the same shape used by every other custom component in this repo.
|
||||||
|
- **`Node`** is the shape of the tree. `Leaf(id, pane)` is a slot holding one `Pane`,
|
||||||
|
addressed everywhere else (`SendMsg`, `RequestFocusMsg`, `SplitLeaf`, `CloseLeaf`,
|
||||||
|
`Resize`) by that `id`. `Split`/`HSplit`/`VSplit` divide space between two child
|
||||||
|
`Node`s.
|
||||||
|
- **`Model`** is the running layout: a `Node` tree plus focus, sizing and the help bar.
|
||||||
|
Build one with `layout.New(root, layout.AsRoot())`.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/layout"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := layout.HSplit(0.3,
|
||||||
|
layout.Leaf("sidebar", newSidebarPane()),
|
||||||
|
layout.Leaf("content", newContentPane()),
|
||||||
|
)
|
||||||
|
m := layout.New(root, layout.AsRoot())
|
||||||
|
|
||||||
|
if err := layout.Run(m); err != nil {
|
||||||
|
fmt.Println("Error running program:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That's a working two-pane app: `ctrl+h`/`ctrl+l` move focus between "sidebar" and
|
||||||
|
"content", `?` toggles the help bar, everything resizes with the terminal.
|
||||||
|
`layout.Run(m, opts ...tea.ProgramOption)` wraps `m` for `tea.NewProgram` and runs it,
|
||||||
|
alt-screen included. `layout` itself reserves no quit key - deciding how (and whether)
|
||||||
|
the app quits is the host's call, same as any other app-level policy; see the examples
|
||||||
|
for the usual `ctrl+c`/`q` pattern.
|
||||||
|
|
||||||
|
## Writing a pane
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Pane interface {
|
||||||
|
Init() tea.Cmd
|
||||||
|
Update(tea.Msg) (Pane, tea.Cmd)
|
||||||
|
View() string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`layout` tells a pane its size and focus state entirely through messages - never assume
|
||||||
|
either any other way:
|
||||||
|
|
||||||
|
- **`SizeMsg{ID, Width, Height}`** whenever the pane's allocated space changes (first
|
||||||
|
layout, terminal resize, a sibling split/close/resize...). `ID` is the pane's own id,
|
||||||
|
learned here so it can later fill `RequestFocusMsg.Source` (see below).
|
||||||
|
- **`FocusMsg{}`** / **`BlurMsg{}`** whenever the pane gains or loses keyboard focus.
|
||||||
|
Named distinctly from bubbletea's own `tea.FocusMsg`/`tea.BlurMsg`, which are about
|
||||||
|
terminal focus, not pane focus.
|
||||||
|
|
||||||
|
Two rules to actually get right:
|
||||||
|
|
||||||
|
- **`View()` must render exactly the last width/height you were told.** `layout`
|
||||||
|
composes panes side by side with `lipgloss.JoinHorizontal`/`JoinVertical` - if a pane
|
||||||
|
renders the wrong size, the whole layout visibly misaligns.
|
||||||
|
- **You own your own chrome.** `layout` never draws a border. `layout.Bordered(focused,
|
||||||
|
w, h, content)` covers the common case (border color follows focus, via
|
||||||
|
`style.S.Primary`/`style.S.Subtle`) as an optional helper - draw nothing, or draw
|
||||||
|
something else entirely, if you want.
|
||||||
|
|
||||||
|
## Building the tree
|
||||||
|
|
||||||
|
```go
|
||||||
|
root := layout.HSplit(0.3,
|
||||||
|
layout.Leaf("sidebar", newSidebarPane()),
|
||||||
|
layout.Leaf("content", newContentPane()),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `layout.Leaf(id, pane)` - one pane, addressed by `id` everywhere else in the API.
|
||||||
|
- `layout.HSplit(ratio, first, second)` / `layout.VSplit(ratio, first, second)` - divide
|
||||||
|
space horizontally (side by side) or vertically (stacked), giving `ratio` (0 to 1) to
|
||||||
|
`first` and the rest to `second`. `layout.Split(id, dir, ratio, first, second)` is the
|
||||||
|
general form if the split itself needs an `id` (see "Resizing at runtime" below).
|
||||||
|
Nest freely:
|
||||||
|
|
||||||
|
```go
|
||||||
|
root := layout.HSplit(0.25,
|
||||||
|
sidebar,
|
||||||
|
layout.VSplit(0.7,
|
||||||
|
layout.HSplit(0.5, topLeft, topRight),
|
||||||
|
bottom,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sizing
|
||||||
|
|
||||||
|
```go
|
||||||
|
layout.HSplit(0.3, sidebar, content) // sidebar gets 30%, content the rest
|
||||||
|
layout.HSplit(0.3, sidebar, content).WithMinimum(20) // 30%, but never below 20 cells
|
||||||
|
layout.HSplit(0.3, sidebar, content).WithMaximum(40) // 30%, but never above 40 cells
|
||||||
|
layout.HSplit(0.3, sidebar, content).WithMinimum(20).WithMaximum(20) // fixed at 20 cells
|
||||||
|
```
|
||||||
|
|
||||||
|
`WithMinimum`/`WithMaximum` clamp the resolved size of the split's *first* child (in
|
||||||
|
cells: columns for a horizontal split, rows for a vertical one). Setting both to the
|
||||||
|
same value pins it regardless of ratio - the way to get an exact-width sidebar.
|
||||||
|
|
||||||
|
## Navigation and the help bar
|
||||||
|
|
||||||
|
`ctrl+h`/`ctrl+l`/`ctrl+j`/`ctrl+k` move focus spatially - whichever pane is actually
|
||||||
|
adjacent in that direction (tmux's `select-pane -L/-D/-U/-R`), not "next in the tree".
|
||||||
|
`?` toggles the help bar between its short and full form. Both work out of the box.
|
||||||
|
|
||||||
|
To customize the keys:
|
||||||
|
|
||||||
|
```go
|
||||||
|
km := layout.DefaultKeyMap()
|
||||||
|
km.FocusLeft = key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "focus left"))
|
||||||
|
m := layout.New(root, layout.AsRoot(), layout.WithKeyMap(km))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Making a pane show up in the help bar
|
||||||
|
|
||||||
|
Implement `HelpProvider`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type HelpProvider interface {
|
||||||
|
HelpBindings() []key.Binding
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`layout` reads it fresh every render from whichever pane is currently focused, at
|
||||||
|
whatever depth (see "Composing bigger apps" below) - nothing to push or keep in sync. A
|
||||||
|
pane that doesn't implement it just contributes nothing; the bar still shows `layout`'s
|
||||||
|
own controls (`?`, `ctrl+hjkl`), it never disappears.
|
||||||
|
|
||||||
|
**Only the outermost `Model` should render a help bar.** Pass `layout.AsRoot()` only to
|
||||||
|
the one actually handed to `Run`/`tea.NewProgram` - an embedded `Model` (see "Composing
|
||||||
|
bigger apps") built without it contributes its focused pane's bindings to the outer bar
|
||||||
|
instead of drawing a second one of its own.
|
||||||
|
|
||||||
|
## Talking between panes
|
||||||
|
|
||||||
|
A pane never holds a reference to another - it returns a `tea.Cmd` and lets `layout`
|
||||||
|
deliver it, by id, wherever that id lives in the tree (including inside a nested
|
||||||
|
`layout.Model` - see "Composing bigger apps"):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// deliver an arbitrary message to another pane's Update, regardless of focus
|
||||||
|
return p, func() tea.Msg {
|
||||||
|
return layout.SendMsg{Target: "content", Msg: pageChangedMsg{page: selected}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
An unknown `Target` is silently ignored.
|
||||||
|
|
||||||
|
### Asking layout to move focus
|
||||||
|
|
||||||
|
```go
|
||||||
|
return p, func() tea.Msg {
|
||||||
|
return layout.RequestFocusMsg{Source: p.id, Target: "content"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`p.id` is whatever the pane last learned from `SizeMsg.ID`. **Only honored when
|
||||||
|
`Source` is the pane that currently, genuinely holds focus** - a blurred pane (say,
|
||||||
|
reacting to a `SendMsg` while in the background) can't redirect focus this way, for
|
||||||
|
itself or anyone else; only the pane actually focused right now can hand focus off to
|
||||||
|
another. An unauthorized `Source`, or an unknown `Target`, is silently ignored.
|
||||||
|
|
||||||
|
A concrete pattern: a sidebar list drives *and* jumps to a content pane, purely through
|
||||||
|
messages:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (p sidebarPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
||||||
|
prevIndex := p.list.Index()
|
||||||
|
var cmd tea.Cmd
|
||||||
|
p.list, cmd = p.list.Update(msg)
|
||||||
|
|
||||||
|
if p.list.Index() != prevIndex {
|
||||||
|
selected := p.list.SelectedItem().(page)
|
||||||
|
cmd = tea.Batch(cmd,
|
||||||
|
func() tea.Msg { return layout.SendMsg{Target: "content", Msg: pageChangedMsg{selected}} },
|
||||||
|
func() tea.Msg { return layout.RequestFocusMsg{Source: p.id, Target: "content"} },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return p, cmd
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reshaping the tree at runtime
|
||||||
|
|
||||||
|
```go
|
||||||
|
m, cmd := m.SplitLeaf("editor", layout.Vertical, "terminal", newTerminalPane())
|
||||||
|
m, cmd = m.CloseLeaf("terminal")
|
||||||
|
m, cmd = m.Resize("main-split", 0.6)
|
||||||
|
m, cmd = m.SetPane("workspace", newSecondPagePane())
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`SplitLeaf(id, dir, newID, newModel, opts ...SplitOption)`** splits the leaf `id`
|
||||||
|
into two: `id` keeps its original pane on one side, `Leaf(newID, newModel)` takes the
|
||||||
|
other, joined by a 50/50 split by default. Override with `WithSplitID`,
|
||||||
|
`WithSplitRatio`, `WithSplitMinimum`, `WithSplitMaximum`.
|
||||||
|
- **`CloseLeaf(id)`** removes a leaf; its sibling takes the place of their parent split.
|
||||||
|
Focus moves elsewhere automatically if `id` was focused. The tree's own last
|
||||||
|
remaining leaf can't be closed this way.
|
||||||
|
- **`Resize(splitID, ratio)`** changes a split's ratio. Only reachable if the split was
|
||||||
|
given an id, via `(*Node).WithID` (or `WithSplitID` when it was created by
|
||||||
|
`SplitLeaf`) - `HSplit`/`VSplit` leave it unaddressable (`""`) by default.
|
||||||
|
- **`SetPane(id, newPane)`** swaps what's rendered at an existing leaf without touching
|
||||||
|
the tree's shape - the way an app switches its content area between entirely
|
||||||
|
different pages/sub-apps, each potentially its own package, as opposed to a pane
|
||||||
|
updating its own internal state in response to a message. The new pane is `Init`'d and
|
||||||
|
immediately told its size; it's told `FocusMsg` too if `id` currently holds focus,
|
||||||
|
since whatever it's replacing never will be again.
|
||||||
|
|
||||||
|
A pane never holds a reference to the `Model` it lives in, so from inside a pane's own
|
||||||
|
`Update`, use the message forms instead - `SplitLeafMsg`, `CloseLeafMsg`, `ResizeMsg`,
|
||||||
|
`SetPaneMsg`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
return p, func() tea.Msg {
|
||||||
|
return layout.SplitLeafMsg{ID: "editor", Dir: layout.Vertical, NewID: "terminal", NewModel: newTerminalPane()}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Composing bigger apps
|
||||||
|
|
||||||
|
A `layout.Model` is itself a `Pane` (and a `Navigable`, see below) - embed one inside
|
||||||
|
another directly, no wrapper needed:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func newWorkspace() layout.Model {
|
||||||
|
root := layout.VSplit(0.7,
|
||||||
|
layout.Leaf("editor", newEditorPane()),
|
||||||
|
layout.Leaf("terminal", newTerminalPane()),
|
||||||
|
)
|
||||||
|
return layout.New(root) // no AsRoot(): the outer Model already renders one help bar
|
||||||
|
}
|
||||||
|
|
||||||
|
root := layout.HSplit(0.25,
|
||||||
|
layout.Leaf("sidebar", newSidebarPane()),
|
||||||
|
layout.Leaf("workspace", newWorkspace()),
|
||||||
|
)
|
||||||
|
m := layout.New(root, layout.AsRoot())
|
||||||
|
```
|
||||||
|
|
||||||
|
Once embedded this way, everything works transparently:
|
||||||
|
|
||||||
|
- `ctrl+hjkl` tries moving focus *inside* whatever's currently focused first; only once
|
||||||
|
that reports being at its own edge does the level above move between its own direct
|
||||||
|
children instead.
|
||||||
|
- The help bar keeps showing exactly one bar, reflecting whatever's focused anywhere in
|
||||||
|
the nesting.
|
||||||
|
- `SendMsg`/`RequestFocusMsg` reach an id inside a nested tree automatically, without
|
||||||
|
the outer tree needing to know it's there.
|
||||||
|
- The nested tree's own shape stays its own business - nothing from outside reaches
|
||||||
|
into it structurally, only messages cross that boundary.
|
||||||
|
|
||||||
|
This all works because `layout.Model` implements `Navigable`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Navigable interface {
|
||||||
|
Pane
|
||||||
|
Leaves() []LeafRect
|
||||||
|
MoveFocus(dir FocusDirection) bool
|
||||||
|
Route(target string, msg tea.Msg) (handled bool, cmd tea.Cmd)
|
||||||
|
Focus(id string) (handled bool, cmd tea.Cmd)
|
||||||
|
FocusedHelp() []key.Binding
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A hand-rolled `Pane` never needs to implement this itself - it's what lets one
|
||||||
|
`layout.Model` recognize *another* `layout.Model` sitting in one of its leaves and
|
||||||
|
delegate to it, at arbitrary nesting depth. You'll only reach for it directly if you're
|
||||||
|
building something that itself wants to compose with `layout` the same way `layout`
|
||||||
|
composes with itself.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
- `examples/layout/basic` - a 2x2 grid, no custom border, the minimum to get started.
|
||||||
|
- `examples/layout/bordered` - each pane draws its own border via `layout.Bordered`,
|
||||||
|
following focus.
|
||||||
|
- `examples/layout/nested` - a whole `layout.Model` embedded as a pane, ctrl+hjkl and
|
||||||
|
the help bar both working transparently across the boundary.
|
||||||
|
- `examples/layout/messaging` - a "control" pane driving and focusing an "editor" pane
|
||||||
|
by id via `SendMsg`/`RequestFocusMsg`.
|
||||||
|
- `examples/layout/help` - the help bar changing to match whatever's focused, including
|
||||||
|
a pane that implements no bindings at all.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
// Rect is an axis-aligned screen region in terminal cells, origin top-left.
|
||||||
|
type Rect struct {
|
||||||
|
X, Y, W, H int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeafRect pairs a Leaf's id with the Rect it was allocated by the most
|
||||||
|
// recent layout pass.
|
||||||
|
type LeafRect struct {
|
||||||
|
ID string
|
||||||
|
Rect Rect
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeLayout descends the tree rooted at n, allocating r between its
|
||||||
|
// leaves according to each Split's ratio/min/max, and returns a flat
|
||||||
|
// registry of every leaf's resolved Rect. Order is deterministic (a
|
||||||
|
// depth-first walk, first child before second), which is what makes it safe
|
||||||
|
// to use directly as a stable iteration order elsewhere (Init, routing).
|
||||||
|
func computeLayout(n *Node, r Rect) []LeafRect {
|
||||||
|
if n == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n.leaf {
|
||||||
|
return []LeafRect{{ID: n.id, Rect: r}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstRect, secondRect Rect
|
||||||
|
if n.dir == Horizontal {
|
||||||
|
w1 := resolveSize(n, r.W)
|
||||||
|
firstRect = Rect{X: r.X, Y: r.Y, W: w1, H: r.H}
|
||||||
|
secondRect = Rect{X: r.X + w1, Y: r.Y, W: r.W - w1, H: r.H}
|
||||||
|
} else {
|
||||||
|
h1 := resolveSize(n, r.H)
|
||||||
|
firstRect = Rect{X: r.X, Y: r.Y, W: r.W, H: h1}
|
||||||
|
secondRect = Rect{X: r.X, Y: r.Y + h1, W: r.W, H: r.H - h1}
|
||||||
|
}
|
||||||
|
|
||||||
|
leaves := computeLayout(n.first, firstRect)
|
||||||
|
return append(leaves, computeLayout(n.second, secondRect)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSize returns the cell size a Split's first child gets out of total,
|
||||||
|
// starting from n.ratio and then clamped to [n.min, n.max] (0 on either side
|
||||||
|
// means that bound is unset). n.min == n.max fixes the size outright,
|
||||||
|
// regardless of ratio.
|
||||||
|
func resolveSize(n *Node, total int) int {
|
||||||
|
size := int(math.Round(n.ratio * float64(total)))
|
||||||
|
if n.min > 0 && size < n.min {
|
||||||
|
size = n.min
|
||||||
|
}
|
||||||
|
if n.max > 0 && size > n.max {
|
||||||
|
size = n.max
|
||||||
|
}
|
||||||
|
if size < 0 {
|
||||||
|
size = 0
|
||||||
|
}
|
||||||
|
if size > total {
|
||||||
|
size = total
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestComputeLayoutEvenSplit(t *testing.T) {
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
||||||
|
leaves := computeLayout(root, Rect{W: 100, H: 40})
|
||||||
|
|
||||||
|
want := map[string]Rect{
|
||||||
|
"a": {X: 0, Y: 0, W: 50, H: 40},
|
||||||
|
"b": {X: 50, Y: 0, W: 50, H: 40},
|
||||||
|
}
|
||||||
|
assertLeafRects(t, leaves, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeLayoutVerticalRatio(t *testing.T) {
|
||||||
|
root := VSplit(0.25, Leaf("top", newStub()), Leaf("bottom", newStub()))
|
||||||
|
leaves := computeLayout(root, Rect{W: 80, H: 40})
|
||||||
|
|
||||||
|
want := map[string]Rect{
|
||||||
|
"top": {X: 0, Y: 0, W: 80, H: 10},
|
||||||
|
"bottom": {X: 0, Y: 10, W: 80, H: 30},
|
||||||
|
}
|
||||||
|
assertLeafRects(t, leaves, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeLayoutMinimum(t *testing.T) {
|
||||||
|
root := HSplit(0.1, Leaf("a", newStub()), Leaf("b", newStub())).WithMinimum(20)
|
||||||
|
leaves := computeLayout(root, Rect{W: 100, H: 10})
|
||||||
|
|
||||||
|
want := map[string]Rect{
|
||||||
|
"a": {X: 0, Y: 0, W: 20, H: 10},
|
||||||
|
"b": {X: 20, Y: 0, W: 80, H: 10},
|
||||||
|
}
|
||||||
|
assertLeafRects(t, leaves, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeLayoutMaximum(t *testing.T) {
|
||||||
|
root := HSplit(0.9, Leaf("a", newStub()), Leaf("b", newStub())).WithMaximum(20)
|
||||||
|
leaves := computeLayout(root, Rect{W: 100, H: 10})
|
||||||
|
|
||||||
|
want := map[string]Rect{
|
||||||
|
"a": {X: 0, Y: 0, W: 20, H: 10},
|
||||||
|
"b": {X: 20, Y: 0, W: 80, H: 10},
|
||||||
|
}
|
||||||
|
assertLeafRects(t, leaves, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeLayoutFixedWhenMinEqualsMax(t *testing.T) {
|
||||||
|
root := HSplit(0.9, Leaf("a", newStub()), Leaf("b", newStub())).WithMinimum(20).WithMaximum(20)
|
||||||
|
leaves := computeLayout(root, Rect{W: 100, H: 10})
|
||||||
|
|
||||||
|
want := map[string]Rect{
|
||||||
|
"a": {X: 0, Y: 0, W: 20, H: 10},
|
||||||
|
"b": {X: 20, Y: 0, W: 80, H: 10},
|
||||||
|
}
|
||||||
|
assertLeafRects(t, leaves, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeLayoutNested(t *testing.T) {
|
||||||
|
root := HSplit(0.3,
|
||||||
|
Leaf("sidebar", newStub()),
|
||||||
|
VSplit(0.5, Leaf("top", newStub()), Leaf("bottom", newStub())),
|
||||||
|
)
|
||||||
|
leaves := computeLayout(root, Rect{W: 100, H: 20})
|
||||||
|
|
||||||
|
want := map[string]Rect{
|
||||||
|
"sidebar": {X: 0, Y: 0, W: 30, H: 20},
|
||||||
|
"top": {X: 30, Y: 0, W: 70, H: 10},
|
||||||
|
"bottom": {X: 30, Y: 10, W: 70, H: 10},
|
||||||
|
}
|
||||||
|
assertLeafRects(t, leaves, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertLeafRects(t *testing.T, leaves []LeafRect, want map[string]Rect) {
|
||||||
|
t.Helper()
|
||||||
|
if len(leaves) != len(want) {
|
||||||
|
t.Fatalf("got %d leaves, want %d (%v)", len(leaves), len(want), leaves)
|
||||||
|
}
|
||||||
|
for _, lr := range leaves {
|
||||||
|
wr, ok := want[lr.ID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected leaf %q", lr.ID)
|
||||||
|
}
|
||||||
|
if lr.Rect != wr {
|
||||||
|
t.Errorf("leaf %q: got %+v, want %+v", lr.ID, lr.Rect, wr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HelpProvider is how a pane opts into the help bar: implement it and
|
||||||
|
// return whatever bindings you want shown while you're focused. A pane
|
||||||
|
// that doesn't implement it just contributes nothing - the help bar still
|
||||||
|
// shows layout's own controls (ctrl+hjkl, ?), it never disappears entirely.
|
||||||
|
type HelpProvider interface {
|
||||||
|
HelpBindings() []key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// HelpBindings implements HelpProvider by delegating to FocusedHelp, so a
|
||||||
|
// Model embedded as a HelpProvider behaves identically to one consulted as
|
||||||
|
// a Navigable.
|
||||||
|
func (m Model) HelpBindings() []key.Binding {
|
||||||
|
return m.FocusedHelp()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FocusedHelp implements Navigable: the bindings for whatever pane
|
||||||
|
// currently has focus, drilling into a nested Navigable automatically until
|
||||||
|
// it reaches the real pane at the bottom. Returns nil if the focused pane
|
||||||
|
// (at any depth) implements neither Navigable nor HelpProvider.
|
||||||
|
func (m Model) FocusedHelp() []key.Binding {
|
||||||
|
focused, ok := findNode(m.root, m.state.id)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if nav, ok := focused.model.(Navigable); ok {
|
||||||
|
return nav.FocusedHelp()
|
||||||
|
}
|
||||||
|
if hp, ok := focused.model.(HelpProvider); ok {
|
||||||
|
return hp.HelpBindings()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpKeyMap adapts a focused pane's flat HelpProvider bindings plus
|
||||||
|
// layout's own KeyMap into the shape bubbles/help.KeyMap expects.
|
||||||
|
type helpKeyMap struct {
|
||||||
|
pane []key.Binding
|
||||||
|
own KeyMap
|
||||||
|
width int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp implements help.KeyMap. own leads (its ToggleHelp is always
|
||||||
|
// first, see KeyMap.ShortHelp), the focused pane's own bindings follow.
|
||||||
|
func (h helpKeyMap) ShortHelp() []key.Binding {
|
||||||
|
return append(append([]key.Binding{}, h.own.ShortHelp()...), h.pane...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp implements help.KeyMap. Rather than the fixed grouping ShortHelp
|
||||||
|
// mirrors, it flattens every binding into one ordered list - own first, so
|
||||||
|
// ToggleHelp lands in the first column's first row, then the pane's - and
|
||||||
|
// re-flows it into as many columns as fit within width, maximizing columns
|
||||||
|
// to minimize the number of rows the full help view takes.
|
||||||
|
func (h helpKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
all := append(append([]key.Binding{}, flattenGroups(h.own.FullHelp())...), h.pane...)
|
||||||
|
return flowColumns(all, h.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenGroups(groups [][]key.Binding) []key.Binding {
|
||||||
|
var flat []key.Binding
|
||||||
|
for _, g := range groups {
|
||||||
|
flat = append(flat, g...)
|
||||||
|
}
|
||||||
|
return flat
|
||||||
|
}
|
||||||
|
|
||||||
|
// flowColumns arranges bindings into as many columns as fit within width
|
||||||
|
// without overflowing. It fills each column top-to-bottom before moving to
|
||||||
|
// the next, which is what bubbles/help.FullHelpView expects: one inner
|
||||||
|
// slice per column, rendered as a vertical stack.
|
||||||
|
func flowColumns(bindings []key.Binding, width int) [][]key.Binding {
|
||||||
|
enabled := make([]key.Binding, 0, len(bindings))
|
||||||
|
for _, kb := range bindings {
|
||||||
|
if kb.Enabled() {
|
||||||
|
enabled = append(enabled, kb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(enabled) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if width <= 0 {
|
||||||
|
return [][]key.Binding{enabled}
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows := 1; rows <= len(enabled); rows++ {
|
||||||
|
groups := chunkRows(enabled, rows)
|
||||||
|
if columnsWidth(groups) <= width {
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [][]key.Binding{enabled}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunkRows splits bindings into groups of at most rows items each, filling
|
||||||
|
// each group before moving to the next - the column-major order help's
|
||||||
|
// FullHelpView renders (first group is the leftmost column).
|
||||||
|
func chunkRows(bindings []key.Binding, rows int) [][]key.Binding {
|
||||||
|
var groups [][]key.Binding
|
||||||
|
for i := 0; i < len(bindings); i += rows {
|
||||||
|
end := min(i+rows, len(bindings))
|
||||||
|
groups = append(groups, bindings[i:end])
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// columnsWidth mirrors bubbles/help.FullHelpView's own width accounting:
|
||||||
|
// each column is as wide as its longest key plus a space plus its longest
|
||||||
|
// description, columns separated by FullSeparator's width (4 cells, " ").
|
||||||
|
func columnsWidth(groups [][]key.Binding) int {
|
||||||
|
const separatorWidth = 4
|
||||||
|
total := 0
|
||||||
|
for i, group := range groups {
|
||||||
|
if i > 0 {
|
||||||
|
total += separatorWidth
|
||||||
|
}
|
||||||
|
var keyWidth, descWidth int
|
||||||
|
for _, kb := range group {
|
||||||
|
keyWidth = max(keyWidth, lipgloss.Width(kb.Help().Key))
|
||||||
|
descWidth = max(descWidth, lipgloss.Width(kb.Help().Desc))
|
||||||
|
}
|
||||||
|
total += keyWidth + 1 + descWidth
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubPane is a minimal Pane used across the test suite: it records every
|
||||||
|
// message it receives (and counts Focus/Blur specifically) and can be told
|
||||||
|
// to return a fixed cmd on its next Update, so tests can assert on both
|
||||||
|
// sides of the layout <-> pane contract without a real component.
|
||||||
|
type stubPane struct {
|
||||||
|
focusN, blurN int
|
||||||
|
w, h int
|
||||||
|
msgs []tea.Msg
|
||||||
|
nextCmd tea.Cmd
|
||||||
|
help []key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStub() *stubPane { return &stubPane{} }
|
||||||
|
|
||||||
|
func (p *stubPane) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (p *stubPane) Update(msg tea.Msg) (Pane, tea.Cmd) {
|
||||||
|
p.msgs = append(p.msgs, msg)
|
||||||
|
switch m := msg.(type) {
|
||||||
|
case FocusMsg:
|
||||||
|
p.focusN++
|
||||||
|
case BlurMsg:
|
||||||
|
p.blurN++
|
||||||
|
case SizeMsg:
|
||||||
|
p.w, p.h = m.Width, m.Height
|
||||||
|
}
|
||||||
|
cmd := p.nextCmd
|
||||||
|
p.nextCmd = nil
|
||||||
|
return p, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *stubPane) View() string { return "" }
|
||||||
|
|
||||||
|
// HelpBindings implements HelpProvider.
|
||||||
|
func (p *stubPane) HelpBindings() []key.Binding { return p.help }
|
||||||
|
|
||||||
|
func (p *stubPane) last() tea.Msg {
|
||||||
|
if len(p.msgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.msgs[len(p.msgs)-1]
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import "charm.land/bubbles/v2/key"
|
||||||
|
|
||||||
|
// KeyMap holds the bindings Model itself reacts to: directional focus
|
||||||
|
// movement and toggling the help bar. A pane's own bindings are separate
|
||||||
|
// (see HelpProvider) - these are only the ones layout intercepts before a
|
||||||
|
// key ever reaches the focused pane.
|
||||||
|
type KeyMap struct {
|
||||||
|
FocusLeft key.Binding
|
||||||
|
FocusRight key.Binding
|
||||||
|
FocusUp key.Binding
|
||||||
|
FocusDown key.Binding
|
||||||
|
ToggleHelp key.Binding
|
||||||
|
|
||||||
|
// ShowFocusInShortHelp also lists ctrl+hjkl on the short help line, not
|
||||||
|
// just the full one. Off by default (see DefaultKeyMap): the four
|
||||||
|
// bindings crowd a single line for little gain, since they're always
|
||||||
|
// one '?' away in the full view regardless. Set it on a KeyMap passed
|
||||||
|
// to WithKeyMap to opt back in.
|
||||||
|
ShowFocusInShortHelp bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultKeyMap returns the standard tmux/vim-style bindings: ctrl+h/j/k/l
|
||||||
|
// to move focus, ? to toggle the help bar.
|
||||||
|
func DefaultKeyMap() KeyMap {
|
||||||
|
return KeyMap{
|
||||||
|
FocusLeft: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+h"),
|
||||||
|
key.WithHelp("ctrl+h", "focus left"),
|
||||||
|
),
|
||||||
|
FocusRight: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+l"),
|
||||||
|
key.WithHelp("ctrl+l", "focus right"),
|
||||||
|
),
|
||||||
|
FocusUp: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+k"),
|
||||||
|
key.WithHelp("ctrl+k", "focus up"),
|
||||||
|
),
|
||||||
|
FocusDown: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+j"),
|
||||||
|
key.WithHelp("ctrl+j", "focus down"),
|
||||||
|
),
|
||||||
|
ToggleHelp: key.NewBinding(
|
||||||
|
key.WithKeys("?"),
|
||||||
|
key.WithHelp("?", "toggle help"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusBindings returns the four directional bindings in reading order.
|
||||||
|
func (k KeyMap) focusBindings() []key.Binding {
|
||||||
|
return []key.Binding{k.FocusLeft, k.FocusDown, k.FocusUp, k.FocusRight}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp implements help.KeyMap so KeyMap can be fed to bubbles/help
|
||||||
|
// directly for layout's own controls. ToggleHelp always leads - see
|
||||||
|
// helpKeyMap.ShortHelp, which relies on that to put '?' first in the
|
||||||
|
// composed bar too.
|
||||||
|
func (k KeyMap) ShortHelp() []key.Binding {
|
||||||
|
bindings := []key.Binding{k.ToggleHelp}
|
||||||
|
if k.ShowFocusInShortHelp {
|
||||||
|
bindings = append(bindings, k.focusBindings()...)
|
||||||
|
}
|
||||||
|
return bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp implements help.KeyMap. ToggleHelp always leads, same reasoning
|
||||||
|
// as ShortHelp; the focus bindings show here unconditionally, regardless of
|
||||||
|
// ShowFocusInShortHelp.
|
||||||
|
func (k KeyMap) FullHelp() [][]key.Binding {
|
||||||
|
return [][]key.Binding{
|
||||||
|
{k.ToggleHelp},
|
||||||
|
{k.FocusLeft, k.FocusRight},
|
||||||
|
{k.FocusUp, k.FocusDown},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
// Package layout arranges Pane content in a binary split tree (BSP,
|
||||||
|
// tmux/i3-style), with spatial ctrl+hjkl focus navigation, message routing
|
||||||
|
// between panes by id, and a help bar that always reflects whatever pane is
|
||||||
|
// currently focused, however deep it's nested. It owns geometry, focus and
|
||||||
|
// routing only - it draws no border and imposes no style: each pane decides
|
||||||
|
// how to render itself for the size and focus state it's given (see SizeMsg,
|
||||||
|
// FocusMsg, BlurMsg).
|
||||||
|
package layout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/help"
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/bubbles"
|
||||||
|
"github.com/anotherhadi/ilovetui/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pane is a leaf's content. It's the same Init/Update/View shape used by
|
||||||
|
// every other custom component in this repo (see tabs.Tab): distinct from
|
||||||
|
// the real tea.Model, whose View returns tea.View rather than string -
|
||||||
|
// that's the top level's job (see Run), not a nested pane's.
|
||||||
|
type Pane interface {
|
||||||
|
Init() tea.Cmd
|
||||||
|
Update(tea.Msg) (Pane, tea.Cmd)
|
||||||
|
View() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigable is what makes a Model composable: embed one layout.Model inside
|
||||||
|
// another (Leaf(id, innerModel)) and it works transparently, because
|
||||||
|
// layout.Model itself implements Navigable. ctrl+hjkl first tries
|
||||||
|
// MoveFocus on whatever's currently focused; SendMsg/RequestFocusMsg reach
|
||||||
|
// into nested trees via Route/Focus; the help bar drills in via
|
||||||
|
// FocusedHelp. A pane that isn't itself a layout.Model just doesn't
|
||||||
|
// implement this, and is treated as an ordinary leaf everywhere.
|
||||||
|
type Navigable interface {
|
||||||
|
Pane
|
||||||
|
Leaves() []LeafRect
|
||||||
|
MoveFocus(dir FocusDirection) bool
|
||||||
|
Route(target string, msg tea.Msg) (handled bool, cmd tea.Cmd)
|
||||||
|
Focus(id string) (handled bool, cmd tea.Cmd)
|
||||||
|
FocusedHelp() []key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusState holds the pieces of Model's state that Navigable's MoveFocus
|
||||||
|
// and Focus must be able to mutate despite having value receivers - a
|
||||||
|
// requirement of Model being usable by value as a Leaf's tea.Model and
|
||||||
|
// still satisfying Navigable when type-asserted back out of that interface.
|
||||||
|
// Boxed behind a pointer so the mutation persists across every copy of
|
||||||
|
// Model that shares it.
|
||||||
|
type focusState struct {
|
||||||
|
id string
|
||||||
|
// pendingCmd queues cmds produced by BlurMsg/FocusMsg dispatch that
|
||||||
|
// happened inside MoveFocus, which - being bool-only, per Navigable -
|
||||||
|
// has no return path for them. Drained by the nearest Update that
|
||||||
|
// actually returns a tea.Cmd; delivery lags by at most one Update
|
||||||
|
// cycle, never user-visible in practice.
|
||||||
|
pendingCmd tea.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is a running layout: a Node tree, focus, sizing, and (if AsRoot)
|
||||||
|
// the help bar. Build one with New.
|
||||||
|
type Model struct {
|
||||||
|
root *Node
|
||||||
|
state *focusState
|
||||||
|
|
||||||
|
leaves []LeafRect
|
||||||
|
|
||||||
|
width, height int
|
||||||
|
|
||||||
|
keyMap KeyMap
|
||||||
|
help help.Model
|
||||||
|
showHelp bool
|
||||||
|
asRoot bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures a Model at construction. See AsRoot, WithKeyMap.
|
||||||
|
type Option func(*Model)
|
||||||
|
|
||||||
|
// AsRoot marks this Model as the outermost one: only a root Model renders
|
||||||
|
// its own help bar in View. Off by default, so an embedded Model (see
|
||||||
|
// Navigable) never shows a duplicate bar - only pass this to the Model
|
||||||
|
// actually handed to Run/tea.NewProgram.
|
||||||
|
func AsRoot() Option {
|
||||||
|
return func(m *Model) { m.asRoot = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithKeyMap overrides the default ctrl+hjkl/? bindings.
|
||||||
|
func WithKeyMap(k KeyMap) Option {
|
||||||
|
return func(m *Model) { m.keyMap = k }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Model from root. The first leaf (depth-first, first child
|
||||||
|
// before second) starts focused.
|
||||||
|
func New(root *Node, opts ...Option) Model {
|
||||||
|
m := Model{
|
||||||
|
root: root,
|
||||||
|
state: &focusState{id: firstLeafID(root)},
|
||||||
|
keyMap: DefaultKeyMap(),
|
||||||
|
help: bubbles.NewHelp(),
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&m)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// program adapts a Model (a Pane, like any other layout leaf content) into
|
||||||
|
// a real tea.Model for tea.NewProgram: the only place a Model's View needs
|
||||||
|
// to become a tea.View instead of a string (see Pane's doc comment).
|
||||||
|
type program struct{ m Model }
|
||||||
|
|
||||||
|
func (p program) Init() tea.Cmd { return p.m.Init() }
|
||||||
|
|
||||||
|
func (p program) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
updated, cmd := p.m.Update(msg)
|
||||||
|
p.m = updated.(Model)
|
||||||
|
return p, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p program) View() tea.View {
|
||||||
|
view := tea.NewView(p.m.View())
|
||||||
|
view.AltScreen = true
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run builds and starts a tea.Program for m (which should have been
|
||||||
|
// constructed with AsRoot). A convenience for the common standalone-binary
|
||||||
|
// case; an app assembling layout into a bigger tea.Program of its own can
|
||||||
|
// wrap m the same way program does above instead.
|
||||||
|
func Run(m Model, opts ...tea.ProgramOption) error {
|
||||||
|
_, err := tea.NewProgram(program{m: m}, opts...).Run()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLeafID(n *Node) string {
|
||||||
|
for n != nil && !n.leaf {
|
||||||
|
n = n.first
|
||||||
|
}
|
||||||
|
if n == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return n.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// walk visits every leaf in the tree rooted at n, depth-first, first child
|
||||||
|
// before second - the same order computeLayout produces, so it's safe to
|
||||||
|
// rely on for anything that should stay in step with the flat registry.
|
||||||
|
func walk(n *Node, fn func(*Node)) {
|
||||||
|
if n == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n.leaf {
|
||||||
|
fn(n)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
walk(n.first, fn)
|
||||||
|
walk(n.second, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
walk(m.root, func(n *Node) {
|
||||||
|
if cmd := n.model.Init(); cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only the actual root originates the initial FocusMsg. An embedded
|
||||||
|
// Model's own state.id already defaults to its first leaf (see New),
|
||||||
|
// but it must stay quiet about it until its parent actually focuses the
|
||||||
|
// leaf hosting it - which happens naturally through the ordinary
|
||||||
|
// FocusMsg/BlurMsg case in Update, cascading down as deep as needed.
|
||||||
|
// Without this guard, every nested Model fires its own initial
|
||||||
|
// FocusMsg independently, so a leaf that isn't even the outer tree's
|
||||||
|
// initial focus still shows as focused until the first real move.
|
||||||
|
if m.asRoot {
|
||||||
|
if n, ok := findNode(m.root, m.state.id); ok {
|
||||||
|
updated, cmd := n.model.Update(FocusMsg{})
|
||||||
|
n.model = updated
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (Pane, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
return m.resize()
|
||||||
|
|
||||||
|
case SizeMsg:
|
||||||
|
// A nested Model receiving its own allocation from a parent
|
||||||
|
// layout.Model - equivalent to tea.WindowSizeMsg at the root.
|
||||||
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
return m.resize()
|
||||||
|
|
||||||
|
case FocusMsg, BlurMsg:
|
||||||
|
// This whole (sub)tree just gained/lost focus at the parent's
|
||||||
|
// level: redistribute to whichever of our own leaves is focused,
|
||||||
|
// not to the tree "globally" (see Navigable doc).
|
||||||
|
return m, m.deliverToFocused(msg)
|
||||||
|
|
||||||
|
case SendMsg:
|
||||||
|
_, cmd := m.Route(msg.Target, msg.Msg)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case RequestFocusMsg:
|
||||||
|
return m, m.handleRequestFocus(msg)
|
||||||
|
|
||||||
|
case SetPaneMsg:
|
||||||
|
return m.SetPane(msg.ID, msg.NewPane)
|
||||||
|
|
||||||
|
case SplitLeafMsg:
|
||||||
|
return m.SplitLeaf(msg.ID, msg.Dir, msg.NewID, msg.NewModel, msg.Opts...)
|
||||||
|
|
||||||
|
case CloseLeafMsg:
|
||||||
|
return m.CloseLeaf(msg.ID)
|
||||||
|
|
||||||
|
case ResizeMsg:
|
||||||
|
return m.Resize(msg.SplitID, msg.Ratio)
|
||||||
|
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keyMap.ToggleHelp):
|
||||||
|
m.showHelp = !m.showHelp
|
||||||
|
m.help.ShowAll = m.showHelp
|
||||||
|
return m.resize()
|
||||||
|
case key.Matches(msg, m.keyMap.FocusLeft):
|
||||||
|
m.MoveFocus(FocusLeft)
|
||||||
|
return m, m.drainCmd()
|
||||||
|
case key.Matches(msg, m.keyMap.FocusRight):
|
||||||
|
m.MoveFocus(FocusRight)
|
||||||
|
return m, m.drainCmd()
|
||||||
|
case key.Matches(msg, m.keyMap.FocusUp):
|
||||||
|
m.MoveFocus(FocusUp)
|
||||||
|
return m, m.drainCmd()
|
||||||
|
case key.Matches(msg, m.keyMap.FocusDown):
|
||||||
|
m.MoveFocus(FocusDown)
|
||||||
|
return m, m.drainCmd()
|
||||||
|
default:
|
||||||
|
return m, m.deliverToFocused(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return m, m.broadcast(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) drainCmd() tea.Cmd {
|
||||||
|
cmd := m.state.pendingCmd
|
||||||
|
m.state.pendingCmd = nil
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverToFocused sends msg to the currently focused leaf's model only.
|
||||||
|
// Used for ordinary key presses (only the focused pane should react to
|
||||||
|
// keyboard input) and for relaying FocusMsg/BlurMsg into a nested subtree.
|
||||||
|
func (m Model) deliverToFocused(msg tea.Msg) tea.Cmd {
|
||||||
|
n, ok := findNode(m.root, m.state.id)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
updated, cmd := n.model.Update(msg)
|
||||||
|
n.model = updated
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcast sends msg to every leaf's model, focused or not - the default
|
||||||
|
// for anything that isn't one of layout's own reserved message types or a
|
||||||
|
// key press, so a blurred pane can still receive its own async messages
|
||||||
|
// (a tick driving a spinner, an HTTP response, ...).
|
||||||
|
func (m Model) broadcast(msg tea.Msg) tea.Cmd {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
walk(m.root, func(n *Node) {
|
||||||
|
updated, cmd := n.model.Update(msg)
|
||||||
|
n.model = updated
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFocus moves focus to id (assumed already validated as an existing
|
||||||
|
// leaf), dispatching BlurMsg to the old focus and FocusMsg to the new one,
|
||||||
|
// and returns the resulting batched cmd. A no-op (nil cmd) if id is already
|
||||||
|
// focused.
|
||||||
|
func (m Model) setFocus(id string) tea.Cmd {
|
||||||
|
if id == m.state.id {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
if old, ok := findNode(m.root, m.state.id); ok {
|
||||||
|
updated, cmd := old.model.Update(BlurMsg{})
|
||||||
|
old.model = updated
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.state.id = id
|
||||||
|
if n, ok := findNode(m.root, id); ok {
|
||||||
|
updated, cmd := n.model.Update(FocusMsg{})
|
||||||
|
n.model = updated
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitLeaf splits the leaf identified by id into two: id keeps its
|
||||||
|
// original pane on one side, a new Leaf(newID, newModel) takes the other,
|
||||||
|
// joined by a 50/50 Split (override via WithSplitRatio, WithSplitID,
|
||||||
|
// WithSplitMinimum, WithSplitMaximum). A no-op (m unchanged, nil cmd) if id
|
||||||
|
// doesn't identify an existing leaf.
|
||||||
|
func (m Model) SplitLeaf(id string, dir Direction, newID string, newModel Pane, opts ...SplitOption) (Model, tea.Cmd) {
|
||||||
|
newRoot, ok := splitLeaf(m.root, id, dir, newID, newModel, opts...)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.root = newRoot
|
||||||
|
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
if cmd := newModel.Init(); cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
resized, cmd := m.resize()
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
return resized, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPane replaces the Pane at leaf id with newPane, keeping its place and
|
||||||
|
// shape in the tree unchanged - unlike SplitLeaf/CloseLeaf, which reshape
|
||||||
|
// the tree, this only swaps what's rendered at an existing slot (the way an
|
||||||
|
// app switches its content area between entirely different sub-apps/pages,
|
||||||
|
// each its own package). newPane is Init'd and immediately told its size
|
||||||
|
// via SizeMsg (using id's current Rect, which by definition hasn't changed);
|
||||||
|
// it's also told FocusMsg if id currently holds focus, since the pane it's
|
||||||
|
// replacing never will. A no-op if id doesn't identify an existing leaf.
|
||||||
|
func (m Model) SetPane(id string, newPane Pane) (Model, tea.Cmd) {
|
||||||
|
n, ok := findNode(m.root, id)
|
||||||
|
if !ok || !n.leaf {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
n.model = newPane
|
||||||
|
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
if cmd := newPane.Init(); cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
if lr, ok := m.leafRect(id); ok {
|
||||||
|
updated, cmd := n.model.Update(SizeMsg{ID: id, Width: lr.Rect.W, Height: lr.Rect.H})
|
||||||
|
n.model = updated
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.state.id == id {
|
||||||
|
updated, cmd := n.model.Update(FocusMsg{})
|
||||||
|
n.model = updated
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseLeaf removes the leaf identified by id, promoting its sibling to take
|
||||||
|
// the place of their parent Split. If id currently has focus, focus moves to
|
||||||
|
// the tree's new first leaf. A no-op if id is the tree's own root (the last
|
||||||
|
// remaining pane can't be closed this way) or doesn't exist.
|
||||||
|
func (m Model) CloseLeaf(id string) (Model, tea.Cmd) {
|
||||||
|
newRoot, ok := closeLeaf(m.root, id)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.root = newRoot
|
||||||
|
|
||||||
|
var focusCmd tea.Cmd
|
||||||
|
if m.state.id == id {
|
||||||
|
focusCmd = m.setFocus(firstLeafID(m.root))
|
||||||
|
}
|
||||||
|
|
||||||
|
resized, resizeCmd := m.resize()
|
||||||
|
return resized, tea.Batch(focusCmd, resizeCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize sets the ratio of the first child of the Split identified by
|
||||||
|
// splitID (only reachable if it was given one, via (*Node).WithID or
|
||||||
|
// WithSplitID). A no-op if splitID isn't found or identifies a Leaf.
|
||||||
|
func (m Model) Resize(splitID string, ratio float64) (Model, tea.Cmd) {
|
||||||
|
n, ok := findNode(m.root, splitID)
|
||||||
|
if !ok || n.leaf {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
n.ratio = ratio
|
||||||
|
return m.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resize recomputes the flat leaf registry from the current root/width/
|
||||||
|
// height and dispatches SizeMsg to every leaf whose Rect actually changed
|
||||||
|
// (not just the ones directly touched by whatever triggered this - a
|
||||||
|
// sibling's size can shift too). Shared by every path that can change
|
||||||
|
// geometry: tea.WindowSizeMsg, SizeMsg (nested), SplitLeaf, CloseLeaf,
|
||||||
|
// Resize, and toggling the help bar (which changes how much height the tree
|
||||||
|
// itself gets).
|
||||||
|
func (m Model) resize() (Model, tea.Cmd) {
|
||||||
|
if m.width <= 0 || m.height <= 0 {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.help.SetWidth(m.width)
|
||||||
|
|
||||||
|
rect := m.treeRect()
|
||||||
|
newLeaves := computeLayout(m.root, rect)
|
||||||
|
|
||||||
|
old := make(map[string]Rect, len(m.leaves))
|
||||||
|
for _, lr := range m.leaves {
|
||||||
|
old[lr.ID] = lr.Rect
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
for _, lr := range newLeaves {
|
||||||
|
if prev, ok := old[lr.ID]; ok && prev == lr.Rect {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, ok := findNode(m.root, lr.ID); ok {
|
||||||
|
updated, cmd := n.model.Update(SizeMsg{ID: lr.ID, Width: lr.Rect.W, Height: lr.Rect.H})
|
||||||
|
n.model = updated
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.leaves = newLeaves
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeRect is the region left for the split tree once the help bar (if
|
||||||
|
// AsRoot) has taken its share of the height. resize and View both go
|
||||||
|
// through this so they can never disagree about where the tree ends and
|
||||||
|
// the help bar begins.
|
||||||
|
func (m Model) treeRect() Rect {
|
||||||
|
h := m.height - m.helpHeight()
|
||||||
|
if h < 0 {
|
||||||
|
h = 0
|
||||||
|
}
|
||||||
|
return Rect{W: m.width, H: h}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) helpHeight() int {
|
||||||
|
if !m.asRoot {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if rendered := m.renderHelp(); rendered != "" {
|
||||||
|
return lipgloss.Height(rendered)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderHelp() string {
|
||||||
|
return m.help.View(helpKeyMap{pane: m.FocusedHelp(), own: m.keyMap, width: m.width})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaves implements Navigable.
|
||||||
|
func (m Model) Leaves() []LeafRect {
|
||||||
|
return m.leaves
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) View() string {
|
||||||
|
if m.width <= 0 || m.height <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
rect := m.treeRect()
|
||||||
|
tree := renderNode(m.root, rect.W, rect.H)
|
||||||
|
|
||||||
|
if !m.asRoot {
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
help := m.renderHelp()
|
||||||
|
if help == "" {
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, tree, help)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderNode mirrors computeLayout's own allocation (same resolveSize calls
|
||||||
|
// on the same w/h at each level), so what's rendered here always matches
|
||||||
|
// the SizeMsg values leaves were already told via resize.
|
||||||
|
func renderNode(n *Node, w, h int) string {
|
||||||
|
if n.leaf {
|
||||||
|
// A misbehaving Pane that renders wider/taller than the SizeMsg it
|
||||||
|
// was given would otherwise desync every ancestor Join*, so clip it
|
||||||
|
// here rather than trusting the contract to hold. MaxWidth/MaxHeight
|
||||||
|
// truncate via ansi.Truncate internally, so this stays escape-code
|
||||||
|
// safe instead of mangling a Pane's own styling mid-sequence.
|
||||||
|
return lipgloss.NewStyle().MaxWidth(w).MaxHeight(h).Render(n.model.View())
|
||||||
|
}
|
||||||
|
if n.dir == Horizontal {
|
||||||
|
w1 := resolveSize(n, w)
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
|
renderNode(n.first, w1, h),
|
||||||
|
renderNode(n.second, w-w1, h),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
h1 := resolveSize(n, h)
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
renderNode(n.first, w, h1),
|
||||||
|
renderNode(n.second, w, h-h1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bordered is an optional helper for panes that want the common look: a
|
||||||
|
// border that follows focus (style.S.Primary focused, style.S.Subtle
|
||||||
|
// blurred) drawn with the configured BorderType. Not required - a pane
|
||||||
|
// that wants something else, or nothing, just doesn't call this. Renders
|
||||||
|
// to exactly w by h, border included, as View() must (see FocusMsg/BlurMsg
|
||||||
|
// and SizeMsg docs).
|
||||||
|
func Bordered(focused bool, w, h int, content string) string {
|
||||||
|
color := style.S.Subtle
|
||||||
|
if focused {
|
||||||
|
color = style.S.Primary
|
||||||
|
}
|
||||||
|
// lipgloss's Width/Height already count the border as part of the box
|
||||||
|
// (they subtract its size internally before sizing the content), so w
|
||||||
|
// and h go straight through - no manual -2 here, or the box comes out
|
||||||
|
// two cells smaller than asked in both dimensions.
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Border(style.S.BorderType).
|
||||||
|
BorderForeground(color).
|
||||||
|
Width(max(w, 0)).
|
||||||
|
Height(max(h, 0)).
|
||||||
|
Render(content)
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestModel(t *testing.T, root *Node) (Model, map[string]*stubPane) {
|
||||||
|
t.Helper()
|
||||||
|
panes := map[string]*stubPane{}
|
||||||
|
walk(root, func(n *Node) {
|
||||||
|
if s, ok := n.model.(*stubPane); ok {
|
||||||
|
panes[n.id] = s
|
||||||
|
}
|
||||||
|
})
|
||||||
|
m := New(root, AsRoot())
|
||||||
|
m.Init()
|
||||||
|
return m, panes
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitFocusesFirstLeaf(t *testing.T) {
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
||||||
|
m, panes := newTestModel(t, root)
|
||||||
|
|
||||||
|
if m.state.id != "a" {
|
||||||
|
t.Fatalf("focusedID = %q, want %q", m.state.id, "a")
|
||||||
|
}
|
||||||
|
if panes["a"].focusN != 1 {
|
||||||
|
t.Fatalf("a.focusN = %d, want 1", panes["a"].focusN)
|
||||||
|
}
|
||||||
|
if panes["b"].focusN != 0 {
|
||||||
|
t.Fatalf("b.focusN = %d, want 0", panes["b"].focusN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindowSizeDispatchesSizeMsg(t *testing.T) {
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
||||||
|
m, panes := newTestModel(t, root)
|
||||||
|
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
||||||
|
m = updated.(Model)
|
||||||
|
|
||||||
|
// newTestModel builds with AsRoot(), so the tree gets 40 minus the
|
||||||
|
// 1-row help bar - see treeRect.
|
||||||
|
if panes["a"].w != 50 || panes["a"].h != 39 {
|
||||||
|
t.Fatalf("a size = %dx%d, want 50x39", panes["a"].w, panes["a"].h)
|
||||||
|
}
|
||||||
|
if panes["b"].w != 50 || panes["b"].h != 39 {
|
||||||
|
t.Fatalf("b size = %dx%d, want 50x39", panes["b"].w, panes["b"].h)
|
||||||
|
}
|
||||||
|
if got := len(m.Leaves()); got != 2 {
|
||||||
|
t.Fatalf("len(Leaves()) = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCtrlLMovesFocusAndDispatchesBlurFocus(t *testing.T) {
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
||||||
|
m, panes := newTestModel(t, root)
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
||||||
|
m = updated.(Model)
|
||||||
|
|
||||||
|
updated, _ = m.Update(tea.KeyPressMsg{Code: 'l', Mod: tea.ModCtrl, Text: ""})
|
||||||
|
m = updated.(Model)
|
||||||
|
|
||||||
|
if m.state.id != "b" {
|
||||||
|
t.Fatalf("focusedID = %q, want %q", m.state.id, "b")
|
||||||
|
}
|
||||||
|
if panes["a"].blurN != 1 {
|
||||||
|
t.Fatalf("a.blurN = %d, want 1", panes["a"].blurN)
|
||||||
|
}
|
||||||
|
if panes["b"].focusN != 1 {
|
||||||
|
t.Fatalf("b.focusN = %d, want 1", panes["b"].focusN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyPressOnlyReachesFocusedPane(t *testing.T) {
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
||||||
|
m, panes := newTestModel(t, root)
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
||||||
|
m = updated.(Model)
|
||||||
|
|
||||||
|
updated, _ = m.Update(tea.KeyPressMsg{Text: "x"})
|
||||||
|
_ = updated.(Model)
|
||||||
|
|
||||||
|
if _, ok := panes["a"].last().(tea.KeyPressMsg); !ok {
|
||||||
|
t.Fatalf("focused pane a should have received the key press, got %#v", panes["a"].last())
|
||||||
|
}
|
||||||
|
if _, ok := panes["b"].last().(tea.KeyPressMsg); ok {
|
||||||
|
t.Fatalf("blurred pane b should not have received the key press")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitLeafAddsAndSizesNewPane(t *testing.T) {
|
||||||
|
root := Leaf("a", newStub())
|
||||||
|
m, panes := newTestModel(t, root)
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
||||||
|
m = updated.(Model)
|
||||||
|
|
||||||
|
newPane := newStub()
|
||||||
|
m, _ = m.SplitLeaf("a", Horizontal, "b", newPane)
|
||||||
|
|
||||||
|
if got := len(m.Leaves()); got != 2 {
|
||||||
|
t.Fatalf("len(Leaves()) = %d, want 2", got)
|
||||||
|
}
|
||||||
|
if newPane.w == 0 || newPane.h == 0 {
|
||||||
|
t.Fatalf("new pane never received a SizeMsg: w=%d h=%d", newPane.w, newPane.h)
|
||||||
|
}
|
||||||
|
if panes["a"].w != 50 {
|
||||||
|
t.Fatalf("a.w = %d, want 50 after 50/50 split", panes["a"].w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetPaneSwapsContentKeepingShape(t *testing.T) {
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
||||||
|
m, panes := newTestModel(t, root)
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
||||||
|
m = updated.(Model)
|
||||||
|
// "a" is focused (first leaf).
|
||||||
|
|
||||||
|
replacement := newStub()
|
||||||
|
m, _ = m.SetPane("a", replacement)
|
||||||
|
|
||||||
|
if got := len(m.Leaves()); got != 2 {
|
||||||
|
t.Fatalf("len(Leaves()) = %d, want 2 (SetPane must not reshape the tree)", got)
|
||||||
|
}
|
||||||
|
if replacement.w != 50 || replacement.h != 39 {
|
||||||
|
t.Fatalf("replacement size = %dx%d, want 50x39 (a's existing Rect)", replacement.w, replacement.h)
|
||||||
|
}
|
||||||
|
if replacement.focusN != 1 {
|
||||||
|
t.Fatalf("replacement.focusN = %d, want 1: \"a\" currently holds focus", replacement.focusN)
|
||||||
|
}
|
||||||
|
if panes["b"].w != 50 {
|
||||||
|
t.Fatalf("b.w = %d, want unchanged 50", panes["b"].w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetPaneOnBlurredLeafDoesNotFocus(t *testing.T) {
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
||||||
|
m, _ := newTestModel(t, root)
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
||||||
|
m = updated.(Model)
|
||||||
|
// "b" is blurred (only "a" is focused initially).
|
||||||
|
|
||||||
|
replacement := newStub()
|
||||||
|
m, _ = m.SetPane("b", replacement)
|
||||||
|
|
||||||
|
if replacement.focusN != 0 {
|
||||||
|
t.Fatalf("replacement.focusN = %d, want 0: \"b\" isn't focused", replacement.focusN)
|
||||||
|
}
|
||||||
|
if replacement.w != 50 || replacement.h != 39 {
|
||||||
|
t.Fatalf("replacement size = %dx%d, want 50x39", replacement.w, replacement.h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetPaneUnknownIDIsNoop(t *testing.T) {
|
||||||
|
root := Leaf("a", newStub())
|
||||||
|
m, _ := newTestModel(t, root)
|
||||||
|
|
||||||
|
m2, cmd := m.SetPane("nope", newStub())
|
||||||
|
if cmd != nil {
|
||||||
|
t.Fatalf("expected nil cmd for an unknown SetPane id, got %v", cmd)
|
||||||
|
}
|
||||||
|
if len(m2.Leaves()) != len(m.Leaves()) {
|
||||||
|
t.Fatalf("tree changed after a no-op SetPane")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseLeafReassignsFocus(t *testing.T) {
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
||||||
|
m, _ := newTestModel(t, root)
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
||||||
|
m = updated.(Model)
|
||||||
|
// focus is on "a"; closing it must move focus to what remains.
|
||||||
|
|
||||||
|
m, _ = m.CloseLeaf("a")
|
||||||
|
|
||||||
|
if got := len(m.Leaves()); got != 1 {
|
||||||
|
t.Fatalf("len(Leaves()) = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if m.state.id != "b" {
|
||||||
|
t.Fatalf("focusedID = %q, want %q after closing the focused leaf", m.state.id, "b")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseLeafRootIsNoop(t *testing.T) {
|
||||||
|
root := Leaf("only", newStub())
|
||||||
|
m, _ := newTestModel(t, root)
|
||||||
|
|
||||||
|
m2, cmd := m.CloseLeaf("only")
|
||||||
|
if cmd != nil {
|
||||||
|
t.Fatalf("expected nil cmd closing the tree's only leaf, got %v", cmd)
|
||||||
|
}
|
||||||
|
if got := len(m2.Leaves()); got != len(m.Leaves()) {
|
||||||
|
t.Fatalf("tree changed after a no-op close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResizeChangesRatio(t *testing.T) {
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub())).WithID("split")
|
||||||
|
m, panes := newTestModel(t, root)
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
||||||
|
m = updated.(Model)
|
||||||
|
|
||||||
|
m, _ = m.Resize("split", 0.8)
|
||||||
|
|
||||||
|
if panes["a"].w != 80 {
|
||||||
|
t.Fatalf("a.w = %d, want 80 after Resize to 0.8", panes["a"].w)
|
||||||
|
}
|
||||||
|
if panes["b"].w != 20 {
|
||||||
|
t.Fatalf("b.w = %d, want 20 after Resize to 0.8", panes["b"].w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMsgDeliversToTarget(t *testing.T) {
|
||||||
|
type payload struct{ n int }
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
||||||
|
m, panes := newTestModel(t, root)
|
||||||
|
// a is focused via Init's initial FocusMsg; b never received anything yet.
|
||||||
|
|
||||||
|
updated, _ := m.Update(SendMsg{Target: "b", Msg: payload{n: 42}})
|
||||||
|
m = updated.(Model)
|
||||||
|
|
||||||
|
if got, ok := panes["b"].last().(payload); !ok || got.n != 42 {
|
||||||
|
t.Fatalf("b should have received payload{42}, got %#v", panes["b"].last())
|
||||||
|
}
|
||||||
|
for _, msg := range panes["a"].msgs {
|
||||||
|
if _, ok := msg.(payload); ok {
|
||||||
|
t.Fatalf("a should not have received the SendMsg meant for b")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMsgUnknownTargetIsIgnored(t *testing.T) {
|
||||||
|
root := Leaf("a", newStub())
|
||||||
|
m, _ := newTestModel(t, root)
|
||||||
|
|
||||||
|
updated, cmd := m.Update(SendMsg{Target: "nope", Msg: struct{}{}})
|
||||||
|
_ = updated.(Model)
|
||||||
|
if cmd != nil {
|
||||||
|
t.Fatalf("expected nil cmd for an unknown SendMsg target, got %v", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestFocusFromFocusedPaneSucceeds(t *testing.T) {
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
||||||
|
m, panes := newTestModel(t, root)
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
||||||
|
m = updated.(Model)
|
||||||
|
|
||||||
|
updated, _ = m.Update(RequestFocusMsg{Source: "a", Target: "b"})
|
||||||
|
m = updated.(Model)
|
||||||
|
|
||||||
|
if m.state.id != "b" {
|
||||||
|
t.Fatalf("focusedID = %q, want %q", m.state.id, "b")
|
||||||
|
}
|
||||||
|
if panes["b"].focusN != 1 {
|
||||||
|
t.Fatalf("b.focusN = %d, want 1", panes["b"].focusN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestFocusFromBlurredPaneIsIgnored(t *testing.T) {
|
||||||
|
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
||||||
|
third := Leaf("c", newStub())
|
||||||
|
_ = third
|
||||||
|
m, _ := newTestModel(t, root)
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
||||||
|
m = updated.(Model)
|
||||||
|
// "a" is focused; "b" (blurred) tries to redirect focus to itself.
|
||||||
|
|
||||||
|
updated, _ = m.Update(RequestFocusMsg{Source: "b", Target: "b"})
|
||||||
|
m = updated.(Model)
|
||||||
|
|
||||||
|
if m.state.id != "a" {
|
||||||
|
t.Fatalf("focusedID = %q, want %q (unauthorized request must be ignored)", m.state.id, "a")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
// SizeMsg tells a pane the exact width/height it's been allocated, and its
|
||||||
|
// own id (so it can identify itself as the Source of a later
|
||||||
|
// RequestFocusMsg). Sent to every leaf whose Rect changed, whenever the tree
|
||||||
|
// is resized, split, closed or reshaped - never assume a pane can deduce its
|
||||||
|
// size any other way.
|
||||||
|
type SizeMsg struct {
|
||||||
|
ID string
|
||||||
|
Width, Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// FocusMsg tells a pane it just gained keyboard focus. Named distinctly from
|
||||||
|
// bubbletea's own tea.FocusMsg (which is about terminal focus, not pane
|
||||||
|
// focus) to avoid any confusion between the two.
|
||||||
|
type FocusMsg struct{}
|
||||||
|
|
||||||
|
// BlurMsg tells a pane it just lost keyboard focus. See FocusMsg.
|
||||||
|
type BlurMsg struct{}
|
||||||
|
|
||||||
|
// SendMsg delivers Msg to the pane identified by Target, wherever it lives
|
||||||
|
// in the tree (including inside a nested layout.Model), regardless of which
|
||||||
|
// pane currently has focus. A pane never holds a reference to another, so
|
||||||
|
// this is how they talk: return
|
||||||
|
//
|
||||||
|
// func() tea.Msg { return layout.SendMsg{Target: "editor", Msg: myMsg{}} }
|
||||||
|
//
|
||||||
|
// as a tea.Cmd from Update. An unknown Target is silently ignored.
|
||||||
|
type SendMsg struct {
|
||||||
|
Target string
|
||||||
|
Msg tea.Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestFocusMsg asks the layout to move keyboard focus to Target. Only
|
||||||
|
// honored when Source is the id of the pane that currently holds focus (a
|
||||||
|
// blurred pane can send other panes messages via SendMsg, but can't move
|
||||||
|
// focus itself or on anyone's behalf) - fill Source from the ID a pane
|
||||||
|
// learned via SizeMsg:
|
||||||
|
//
|
||||||
|
// func() tea.Msg { return layout.RequestFocusMsg{Source: p.id, Target: "content"} }
|
||||||
|
//
|
||||||
|
// A mismatched Source, or an unknown Target, is silently ignored.
|
||||||
|
type RequestFocusMsg struct {
|
||||||
|
Source string
|
||||||
|
Target string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPaneMsg is the message form of Model.SetPane, for a pane that wants to
|
||||||
|
// swap another leaf's content without holding a reference to its Model
|
||||||
|
// (which it never does).
|
||||||
|
type SetPaneMsg struct {
|
||||||
|
ID string
|
||||||
|
NewPane Pane
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitLeafMsg is the message form of Model.SplitLeaf, for a pane that wants
|
||||||
|
// to reshape the tree without holding a reference to its Model (which it
|
||||||
|
// never does). See Model.SplitLeaf for parameters.
|
||||||
|
type SplitLeafMsg struct {
|
||||||
|
ID string
|
||||||
|
Dir Direction
|
||||||
|
NewID string
|
||||||
|
NewModel Pane
|
||||||
|
Opts []SplitOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseLeafMsg is the message form of Model.CloseLeaf.
|
||||||
|
type CloseLeafMsg struct {
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResizeMsg is the message form of Model.Resize: sets the ratio of the
|
||||||
|
// first child of the Split identified by SplitID (a Split only reachable by
|
||||||
|
// id if it was given one via WithID or WithSplitID).
|
||||||
|
type ResizeMsg struct {
|
||||||
|
SplitID string
|
||||||
|
Ratio float64
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
// FocusDirection is a screen-space direction for moving focus between
|
||||||
|
// panes: ctrl+h/j/k/l. Distinct from Direction (a Split's axis) because a
|
||||||
|
// split only ever has two sides, while focus needs to move one of four
|
||||||
|
// ways.
|
||||||
|
type FocusDirection int
|
||||||
|
|
||||||
|
const (
|
||||||
|
FocusLeft FocusDirection = iota
|
||||||
|
FocusRight
|
||||||
|
FocusUp
|
||||||
|
FocusDown
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindNeighbor picks the leaf, among leaves, that's geometrically closest to
|
||||||
|
// from in dir - tmux's select-pane -L/-D/-U/-R, not a tree walk. A
|
||||||
|
// candidate must be strictly positioned beyond from's edge in dir and share
|
||||||
|
// some extent with it on the perpendicular axis. Ranked by, in order: edge
|
||||||
|
// gap (smaller wins), then shared perpendicular extent (larger wins - the
|
||||||
|
// real tie-breaker between two equally-close neighbors), then
|
||||||
|
// center-to-center distance on the perpendicular axis (last resort). Pure
|
||||||
|
// and independent of Model so it's testable on its own.
|
||||||
|
func FindNeighbor(leaves []LeafRect, from LeafRect, dir FocusDirection) (id string, ok bool) {
|
||||||
|
type candidate struct {
|
||||||
|
id string
|
||||||
|
gap, overlap, cross int
|
||||||
|
}
|
||||||
|
var best *candidate
|
||||||
|
|
||||||
|
for _, lr := range leaves {
|
||||||
|
if lr.ID == from.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gap, overlap, cross, ok := edgeScore(from.Rect, lr.Rect, dir)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := candidate{id: lr.ID, gap: gap, overlap: overlap, cross: cross}
|
||||||
|
if best == nil ||
|
||||||
|
c.gap < best.gap ||
|
||||||
|
(c.gap == best.gap && c.overlap > best.overlap) ||
|
||||||
|
(c.gap == best.gap && c.overlap == best.overlap && c.cross < best.cross) {
|
||||||
|
best = &c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return best.id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// edgeScore returns the ranking tuple used by FindNeighbor for a single
|
||||||
|
// (from, other) pair, and ok=false if other isn't a valid candidate in dir
|
||||||
|
// at all (wrong side, or zero overlap on the perpendicular axis).
|
||||||
|
func edgeScore(from, other Rect, dir FocusDirection) (gap, overlap, cross int, ok bool) {
|
||||||
|
switch dir {
|
||||||
|
case FocusLeft:
|
||||||
|
if other.X+other.W > from.X {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
gap = from.X - (other.X + other.W)
|
||||||
|
overlap = spanOverlap(from.Y, from.Y+from.H, other.Y, other.Y+other.H)
|
||||||
|
cross = abs((from.Y + from.H/2) - (other.Y + other.H/2))
|
||||||
|
case FocusRight:
|
||||||
|
if other.X < from.X+from.W {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
gap = other.X - (from.X + from.W)
|
||||||
|
overlap = spanOverlap(from.Y, from.Y+from.H, other.Y, other.Y+other.H)
|
||||||
|
cross = abs((from.Y + from.H/2) - (other.Y + other.H/2))
|
||||||
|
case FocusUp:
|
||||||
|
if other.Y+other.H > from.Y {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
gap = from.Y - (other.Y + other.H)
|
||||||
|
overlap = spanOverlap(from.X, from.X+from.W, other.X, other.X+other.W)
|
||||||
|
cross = abs((from.X + from.W/2) - (other.X + other.W/2))
|
||||||
|
case FocusDown:
|
||||||
|
if other.Y < from.Y+from.H {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
gap = other.Y - (from.Y + from.H)
|
||||||
|
overlap = spanOverlap(from.X, from.X+from.W, other.X, other.X+other.W)
|
||||||
|
cross = abs((from.X + from.W/2) - (other.X + other.W/2))
|
||||||
|
}
|
||||||
|
if overlap <= 0 {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
return gap, overlap, cross, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// spanOverlap returns the length shared by [aStart,aEnd) and [bStart,bEnd).
|
||||||
|
func spanOverlap(aStart, aEnd, bStart, bEnd int) int {
|
||||||
|
start := max(aStart, bStart)
|
||||||
|
end := min(aEnd, bEnd)
|
||||||
|
if end <= start {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return end - start
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(n int) int {
|
||||||
|
if n < 0 {
|
||||||
|
return -n
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// leafRect looks up id's current Rect in this Model's own flat registry.
|
||||||
|
func (m Model) leafRect(id string) (LeafRect, bool) {
|
||||||
|
for _, lr := range m.leaves {
|
||||||
|
if lr.ID == id {
|
||||||
|
return lr, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LeafRect{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveFocus implements Navigable. If the currently focused leaf holds a
|
||||||
|
// nested Navigable (an embedded layout.Model), it's given first refusal -
|
||||||
|
// only once it reports being at its own edge (false) does this Model try
|
||||||
|
// moving focus among its own direct children instead. Cmds produced by the
|
||||||
|
// BlurMsg/FocusMsg this triggers can't be returned directly (Navigable's
|
||||||
|
// signature is bool-only); they're queued on m.state.pendingCmd instead
|
||||||
|
// (see focusState) and drained by the next Update that actually returns a
|
||||||
|
// tea.Cmd - a lag of at most one Update cycle, never user-visible.
|
||||||
|
func (m Model) MoveFocus(dir FocusDirection) bool {
|
||||||
|
if focused, ok := findNode(m.root, m.state.id); ok {
|
||||||
|
if nav, ok := focused.model.(Navigable); ok {
|
||||||
|
if nav.MoveFocus(dir) {
|
||||||
|
focused.model = nav
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
from, ok := m.leafRect(m.state.id)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
id, ok := FindNeighbor(m.leaves, from, dir)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m.state.pendingCmd = tea.Batch(m.state.pendingCmd, m.setFocus(id))
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFindNeighborBasicDirections(t *testing.T) {
|
||||||
|
leaves := []LeafRect{
|
||||||
|
{ID: "left", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
|
||||||
|
{ID: "right", Rect: Rect{X: 10, Y: 0, W: 10, H: 10}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if id, ok := FindNeighbor(leaves, leaves[0], FocusRight); !ok || id != "right" {
|
||||||
|
t.Fatalf("FocusRight from left: got (%q, %v), want (right, true)", id, ok)
|
||||||
|
}
|
||||||
|
if id, ok := FindNeighbor(leaves, leaves[1], FocusLeft); !ok || id != "left" {
|
||||||
|
t.Fatalf("FocusLeft from right: got (%q, %v), want (left, true)", id, ok)
|
||||||
|
}
|
||||||
|
if _, ok := FindNeighbor(leaves, leaves[0], FocusLeft); ok {
|
||||||
|
t.Fatalf("FocusLeft from left (edge): expected no neighbor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two candidates at the exact same gap: the one that actually shares more
|
||||||
|
// of from's edge should win, not an arbitrary pick. This is the overlap
|
||||||
|
// tie-break, the one that matters most for a real tmux/i3-style layout
|
||||||
|
// (picking the pane that's genuinely alongside you, not just "a" neighbor).
|
||||||
|
func TestFindNeighborPrefersLargerOverlapOnEqualGap(t *testing.T) {
|
||||||
|
from := LeafRect{ID: "from", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}}
|
||||||
|
leaves := []LeafRect{
|
||||||
|
from,
|
||||||
|
{ID: "full", Rect: Rect{X: 10, Y: 0, W: 10, H: 10}},
|
||||||
|
{ID: "partial", Rect: Rect{X: 10, Y: 5, W: 10, H: 10}},
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := FindNeighbor(leaves, from, FocusRight)
|
||||||
|
if !ok || id != "full" {
|
||||||
|
t.Fatalf("got (%q, %v), want (full, true): equal gap, full's overlap (10) beats partial's (5)", id, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When gap AND overlap are tied, center-to-center distance on the
|
||||||
|
// perpendicular axis is the last resort.
|
||||||
|
func TestFindNeighborCrossIsLastResortTiebreak(t *testing.T) {
|
||||||
|
from := LeafRect{ID: "from", Rect: Rect{X: 0, Y: 10, W: 10, H: 10}} // Y 10..20, center 15
|
||||||
|
leaves := []LeafRect{
|
||||||
|
from,
|
||||||
|
{ID: "near", Rect: Rect{X: 10, Y: 8, W: 10, H: 20}}, // Y 8..28, overlap 10, center 18
|
||||||
|
{ID: "far", Rect: Rect{X: 10, Y: 0, W: 10, H: 20}}, // Y 0..20, overlap 10, center 10
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := FindNeighbor(leaves, from, FocusRight)
|
||||||
|
if !ok || id != "near" {
|
||||||
|
t.Fatalf("got (%q, %v), want (near, true): equal gap and overlap, near's center is closer (3 vs 5)", id, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindNeighborNoCandidateBeyondEdge(t *testing.T) {
|
||||||
|
leaves := []LeafRect{
|
||||||
|
{ID: "only", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
|
||||||
|
}
|
||||||
|
if _, ok := FindNeighbor(leaves, leaves[0], FocusDown); ok {
|
||||||
|
t.Fatal("expected no neighbor with a single leaf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindNeighborRequiresPerpendicularOverlap(t *testing.T) {
|
||||||
|
leaves := []LeafRect{
|
||||||
|
{ID: "from", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
|
||||||
|
// Directly to the right on the X axis, but no shared Y extent at
|
||||||
|
// all: not a valid candidate even though it's the only one there.
|
||||||
|
{ID: "diagonal", Rect: Rect{X: 10, Y: 10, W: 10, H: 10}},
|
||||||
|
}
|
||||||
|
if _, ok := FindNeighbor(leaves, leaves[0], FocusRight); ok {
|
||||||
|
t.Fatal("expected no neighbor: candidate shares no Y extent with from")
|
||||||
|
}
|
||||||
|
}
|
||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
// Direction is the axis a Split divides its space along.
|
||||||
|
type Direction int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Horizontal places the first child on the left, the second on the right.
|
||||||
|
Horizontal Direction = iota
|
||||||
|
// Vertical stacks the first child on top of the second.
|
||||||
|
Vertical
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node is one slot in the layout tree: either a Leaf (holding a Pane) or a
|
||||||
|
// Split (dividing its space between two child Nodes).
|
||||||
|
// Build a tree with Leaf and Split/HSplit/VSplit, then hand the root to New.
|
||||||
|
//
|
||||||
|
// Node is pointer-based on purpose: panes need a stable identity across
|
||||||
|
// arbitrarily nested splits, so there's no flat index to keep in sync the
|
||||||
|
// way a slice-based component would.
|
||||||
|
type Node struct {
|
||||||
|
id string // "" means unaddressable; always non-empty for a Leaf
|
||||||
|
|
||||||
|
leaf bool
|
||||||
|
model Pane // set when leaf
|
||||||
|
|
||||||
|
dir Direction // set when !leaf
|
||||||
|
ratio float64 // proportion of space given to first; set when !leaf
|
||||||
|
min, max int // clamp on first's resolved cell size, in cells; 0 = unset
|
||||||
|
first, second *Node // set when !leaf
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaf wraps a single pane. id must be non-empty and unique within the tree
|
||||||
|
// it ends up in: it's how the pane is targeted later by SendMsg,
|
||||||
|
// RequestFocusMsg, SplitLeaf, CloseLeaf and Resize.
|
||||||
|
func Leaf(id string, model Pane) *Node {
|
||||||
|
return &Node{id: id, leaf: true, model: model}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split divides its space between first and second along dir, giving ratio
|
||||||
|
// (0 to 1) of it to first and the rest to second. id may be "" if the split
|
||||||
|
// itself never needs to be addressed by Resize; it plays no role in pane
|
||||||
|
// addressing (only Leaf ids do).
|
||||||
|
func Split(id string, dir Direction, ratio float64, first, second *Node) *Node {
|
||||||
|
return &Node{id: id, dir: dir, ratio: ratio, first: first, second: second}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HSplit is Split with Horizontal and no id: layout.HSplit(0.3, left, right).
|
||||||
|
func HSplit(ratio float64, first, second *Node) *Node {
|
||||||
|
return Split("", Horizontal, ratio, first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VSplit is Split with Vertical and no id: layout.VSplit(0.3, top, bottom).
|
||||||
|
func VSplit(ratio float64, first, second *Node) *Node {
|
||||||
|
return Split("", Vertical, ratio, first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithID sets the id used to address this node later (currently only
|
||||||
|
// meaningful on a Split, for Resize; a Leaf already gets its id from Leaf).
|
||||||
|
func (n *Node) WithID(id string) *Node {
|
||||||
|
n.id = id
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMinimum clamps first's resolved size to never go below cells. No-op on
|
||||||
|
// a Leaf, which has no size of its own to constrain.
|
||||||
|
func (n *Node) WithMinimum(cells int) *Node {
|
||||||
|
if !n.leaf {
|
||||||
|
n.min = cells
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMaximum clamps first's resolved size to never exceed cells. No-op on a
|
||||||
|
// Leaf. Setting both WithMinimum and WithMaximum to the same value fixes
|
||||||
|
// first's size regardless of ratio.
|
||||||
|
func (n *Node) WithMaximum(cells int) *Node {
|
||||||
|
if !n.leaf {
|
||||||
|
n.max = cells
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// findNode returns the node with the given id anywhere in the tree rooted at
|
||||||
|
// n, without mutating anything. id == "" never matches (it's the
|
||||||
|
// unaddressable sentinel, and several Splits may share it).
|
||||||
|
func findNode(n *Node, id string) (*Node, bool) {
|
||||||
|
if n == nil || id == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if n.id == id {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
if n.leaf {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if found, ok := findNode(n.first, id); ok {
|
||||||
|
return found, true
|
||||||
|
}
|
||||||
|
return findNode(n.second, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitConfig collects SplitOption values applied by SplitLeaf.
|
||||||
|
type splitConfig struct {
|
||||||
|
id string
|
||||||
|
ratio float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitOption configures the Split node SplitLeaf creates in place of the
|
||||||
|
// leaf it splits.
|
||||||
|
type SplitOption func(*Node, *splitConfig)
|
||||||
|
|
||||||
|
// WithSplitID addresses the split SplitLeaf creates, so it can later be
|
||||||
|
// targeted by Resize.
|
||||||
|
func WithSplitID(id string) SplitOption {
|
||||||
|
return func(_ *Node, c *splitConfig) { c.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSplitRatio overrides SplitLeaf's default 50/50 split.
|
||||||
|
func WithSplitRatio(ratio float64) SplitOption {
|
||||||
|
return func(_ *Node, c *splitConfig) { c.ratio = ratio }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSplitMinimum clamps the size of the original (pre-split) leaf's side
|
||||||
|
// of the new split. Equivalent to calling (*Node).WithMinimum on the split
|
||||||
|
// SplitLeaf produces.
|
||||||
|
func WithSplitMinimum(cells int) SplitOption {
|
||||||
|
return func(n *Node, _ *splitConfig) { n.WithMinimum(cells) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSplitMaximum clamps the size of the original (pre-split) leaf's side
|
||||||
|
// of the new split. Equivalent to calling (*Node).WithMaximum on the split
|
||||||
|
// SplitLeaf produces.
|
||||||
|
func WithSplitMaximum(cells int) SplitOption {
|
||||||
|
return func(n *Node, _ *splitConfig) { n.WithMaximum(cells) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitLeaf replaces the leaf identified by id with a Split holding the
|
||||||
|
// original leaf as first and a new Leaf(newID, newModel) as second. Returns
|
||||||
|
// the (possibly new) tree root and whether id was found and was a leaf.
|
||||||
|
func splitLeaf(root *Node, id string, dir Direction, newID string, newModel Pane, opts ...SplitOption) (*Node, bool) {
|
||||||
|
target, ok := findNode(root, id)
|
||||||
|
if !ok || !target.leaf {
|
||||||
|
return root, false
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := splitConfig{ratio: 0.5}
|
||||||
|
split := Split("", dir, 0.5, target, Leaf(newID, newModel))
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(split, &cfg)
|
||||||
|
}
|
||||||
|
split.id = cfg.id
|
||||||
|
split.ratio = cfg.ratio
|
||||||
|
|
||||||
|
newRoot, _ := replaceNode(root, id, func(*Node) *Node { return split })
|
||||||
|
return newRoot, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceNode returns a copy of the tree rooted at n with the node
|
||||||
|
// identified by id swapped for transform's result. Only the path down to
|
||||||
|
// that node is cloned, everything else is shared. Returns n unchanged and
|
||||||
|
// false if id isn't found.
|
||||||
|
func replaceNode(n *Node, id string, transform func(*Node) *Node) (*Node, bool) {
|
||||||
|
if n == nil || id == "" {
|
||||||
|
return n, false
|
||||||
|
}
|
||||||
|
if n.id == id {
|
||||||
|
return transform(n), true
|
||||||
|
}
|
||||||
|
if n.leaf {
|
||||||
|
return n, false
|
||||||
|
}
|
||||||
|
if newFirst, ok := replaceNode(n.first, id, transform); ok {
|
||||||
|
clone := *n
|
||||||
|
clone.first = newFirst
|
||||||
|
return &clone, true
|
||||||
|
}
|
||||||
|
if newSecond, ok := replaceNode(n.second, id, transform); ok {
|
||||||
|
clone := *n
|
||||||
|
clone.second = newSecond
|
||||||
|
return &clone, true
|
||||||
|
}
|
||||||
|
return n, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeLeaf removes the leaf identified by id, promoting its sibling to take
|
||||||
|
// the place of their parent Split. Returns the (possibly new) tree root and
|
||||||
|
// whether id was found as a direct child of some Split (the tree's own root
|
||||||
|
// leaf, with no parent, can never be closed this way).
|
||||||
|
func closeLeaf(root *Node, id string) (*Node, bool) {
|
||||||
|
if root == nil || root.leaf || id == "" {
|
||||||
|
return root, false
|
||||||
|
}
|
||||||
|
if root.first.leaf && root.first.id == id {
|
||||||
|
return root.second, true
|
||||||
|
}
|
||||||
|
if root.second.leaf && root.second.id == id {
|
||||||
|
return root.first, true
|
||||||
|
}
|
||||||
|
if newFirst, ok := closeLeaf(root.first, id); ok {
|
||||||
|
clone := *root
|
||||||
|
clone.first = newFirst
|
||||||
|
return &clone, true
|
||||||
|
}
|
||||||
|
if newSecond, ok := closeLeaf(root.second, id); ok {
|
||||||
|
clone := *root
|
||||||
|
clone.second = newSecond
|
||||||
|
return &clone, true
|
||||||
|
}
|
||||||
|
return root, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
// Route implements Navigable: attempts to deliver msg to the leaf
|
||||||
|
// identified by target. Checks this Model's own direct leaves first, then
|
||||||
|
// recurses into any leaf whose model is itself Navigable (an embedded
|
||||||
|
// layout.Model), depth-first. Returns handled=false without touching
|
||||||
|
// anything if target isn't found anywhere in this (sub)tree - the caller
|
||||||
|
// (Model.Update, or a parent Model's own Route) is responsible for treating
|
||||||
|
// that as "silently ignore."
|
||||||
|
func (m Model) Route(target string, msg tea.Msg) (bool, tea.Cmd) {
|
||||||
|
if n, ok := findNode(m.root, target); ok && n.leaf {
|
||||||
|
updated, cmd := n.model.Update(msg)
|
||||||
|
n.model = updated
|
||||||
|
return true, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
handled bool
|
||||||
|
cmd tea.Cmd
|
||||||
|
)
|
||||||
|
walk(m.root, func(n *Node) {
|
||||||
|
if handled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if nav, ok := n.model.(Navigable); ok {
|
||||||
|
if h, c := nav.Route(target, msg); h {
|
||||||
|
n.model = nav
|
||||||
|
handled, cmd = true, c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return handled, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus implements Navigable: moves this (sub)tree's focus straight to id,
|
||||||
|
// wherever it is - a direct leaf, or nested inside a leaf's own Navigable.
|
||||||
|
// Unlike MoveFocus (one geometric step in a direction), this is "jump to
|
||||||
|
// this specific pane." When id lives inside a nested Navigable, that
|
||||||
|
// child's own internal focus is set first, and then this Model's own focus
|
||||||
|
// is brought to the leaf hosting it too, so the whole chain agrees on what's
|
||||||
|
// focused (required for FocusMsg/BlurMsg propagation and for MoveFocus's
|
||||||
|
// "ask the focused child first" rule to keep working afterward). That outer
|
||||||
|
// step re-notifies the leaf hosting the nested tree regardless of whether
|
||||||
|
// the nested Focus call already notified id directly - the two can't always
|
||||||
|
// be told apart cheaply (id might have already been that subtree's
|
||||||
|
// untouched default focus, which never got an initial FocusMsg at all, see
|
||||||
|
// Model.Init) - so id's pane may occasionally see FocusMsg twice for one
|
||||||
|
// real transition. Delivery is at-least-once, not exactly-once: a Pane
|
||||||
|
// should treat FocusMsg/BlurMsg as idempotent, the same way it would have
|
||||||
|
// to tolerate a redundant terminal focus event.
|
||||||
|
func (m Model) Focus(id string) (bool, tea.Cmd) {
|
||||||
|
if _, ok := m.leafRect(id); ok {
|
||||||
|
return true, m.setFocus(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
handled bool
|
||||||
|
innerCmd tea.Cmd
|
||||||
|
outerID string
|
||||||
|
)
|
||||||
|
walk(m.root, func(n *Node) {
|
||||||
|
if handled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if nav, ok := n.model.(Navigable); ok {
|
||||||
|
if h, c := nav.Focus(id); h {
|
||||||
|
n.model = nav
|
||||||
|
handled, innerCmd, outerID = true, c, n.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !handled {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, tea.Batch(m.setFocus(outerID), innerCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceIsFocused reports whether id is the leaf currently focused
|
||||||
|
// somewhere along this (sub)tree's active focus chain: either this Model's
|
||||||
|
// own focused leaf, or - recursively - whatever's focused inside that leaf
|
||||||
|
// if it's itself a nested Navigable. Used to authorize RequestFocusMsg: only
|
||||||
|
// the pane that genuinely holds focus right now, at whatever depth, is
|
||||||
|
// allowed to redirect focus elsewhere. A blurred pane reaching this code
|
||||||
|
// (e.g. reacting to a SendMsg while in the background) is correctly refused
|
||||||
|
// since it can never appear on the active chain.
|
||||||
|
func (m Model) sourceIsFocused(id string) bool {
|
||||||
|
if m.state.id == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
focused, ok := findNode(m.root, m.state.id)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
checker, ok := focused.model.(interface{ sourceIsFocused(string) bool })
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return checker.sourceIsFocused(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRequestFocus honors a RequestFocusMsg only once sourceIsFocused
|
||||||
|
// clears its Source, then resolves Target the same way Focus does. An
|
||||||
|
// unauthorized Source, or an unknown Target, is silently ignored.
|
||||||
|
func (m Model) handleRequestFocus(msg RequestFocusMsg) tea.Cmd {
|
||||||
|
if !m.sourceIsFocused(msg.Source) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, cmd := m.Focus(msg.Target)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildNested returns an outer Model with two direct leaves: "sidebar" and
|
||||||
|
// "inner-root", the latter being a nested layout.Model (itself split into
|
||||||
|
// "inner-a"/"inner-b") embedded as an ordinary Pane. Both models are sized
|
||||||
|
// before being handed back so their leaf registries are populated.
|
||||||
|
func buildNested(t *testing.T) (outer Model, sidebar *stubPane, innerA, innerB *stubPane) {
|
||||||
|
t.Helper()
|
||||||
|
sidebar = newStub()
|
||||||
|
innerA = newStub()
|
||||||
|
innerB = newStub()
|
||||||
|
|
||||||
|
// inner is deliberately built WITHOUT AsRoot(): it's embedded, so it
|
||||||
|
// must not act focused on its own until outer actually focuses the
|
||||||
|
// leaf that hosts it (see Model.Init's asRoot guard).
|
||||||
|
inner := New(HSplit(0.5, Leaf("inner-a", innerA), Leaf("inner-b", innerB)))
|
||||||
|
|
||||||
|
root := HSplit(0.3, Leaf("sidebar", sidebar), Leaf("inner-root", inner))
|
||||||
|
outer = New(root, AsRoot())
|
||||||
|
outer.Init()
|
||||||
|
updated, _ := outer.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
||||||
|
outer = updated.(Model)
|
||||||
|
return outer, sidebar, innerA, innerB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression test: a nested Model used to fire its own initial FocusMsg to
|
||||||
|
// its first leaf unconditionally on Init, regardless of whether the outer
|
||||||
|
// tree's actual initial focus ever lands on the leaf hosting it - so a
|
||||||
|
// leaf buried in a subtree that isn't even initially focused would still
|
||||||
|
// show up as focused, alongside whatever the outer tree really focused.
|
||||||
|
func TestEmbeddedModelDoesNotSelfFocusOnInit(t *testing.T) {
|
||||||
|
outer, sidebar, innerA, innerB := buildNested(t)
|
||||||
|
|
||||||
|
if sidebar.focusN != 1 {
|
||||||
|
t.Fatalf("sidebar.focusN = %d, want 1 (it's the outer tree's real initial focus)", sidebar.focusN)
|
||||||
|
}
|
||||||
|
if innerA.focusN != 0 || innerB.focusN != 0 {
|
||||||
|
t.Fatalf("innerA.focusN=%d innerB.focusN=%d, want 0/0: the nested tree isn't focused yet", innerA.focusN, innerB.focusN)
|
||||||
|
}
|
||||||
|
if outer.state.id != "sidebar" {
|
||||||
|
t.Fatalf("outer focusedID = %q, want %q", outer.state.id, "sidebar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMsgReachesNestedLeaf(t *testing.T) {
|
||||||
|
type payload struct{ n int }
|
||||||
|
outer, _, innerA, _ := buildNested(t)
|
||||||
|
|
||||||
|
updated, _ := outer.Update(SendMsg{Target: "inner-a", Msg: payload{n: 7}})
|
||||||
|
_ = updated.(Model)
|
||||||
|
|
||||||
|
if got, ok := innerA.last().(payload); !ok || got.n != 7 {
|
||||||
|
t.Fatalf("inner-a should have received payload{7}, got %#v", innerA.last())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFocusJumpsIntoNestedSubtreeAndUpdatesOuterFocus(t *testing.T) {
|
||||||
|
outer, _, _, innerB := buildNested(t)
|
||||||
|
// Outer's own focus starts on "sidebar" (first leaf, depth-first).
|
||||||
|
|
||||||
|
handled, _ := outer.Focus("inner-b")
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("Focus(\"inner-b\") should have been handled")
|
||||||
|
}
|
||||||
|
if outer.state.id != "inner-root" {
|
||||||
|
t.Fatalf("outer focusedID = %q, want %q (the leaf hosting the nested tree)", outer.state.id, "inner-root")
|
||||||
|
}
|
||||||
|
// At-least-once, not exactly-once (see Focus's doc comment): the outer
|
||||||
|
// leaf's own re-notification can duplicate the nested tree's own
|
||||||
|
// dispatch, so only assert inner-b actually got notified, not a count.
|
||||||
|
if innerB.focusN < 1 {
|
||||||
|
t.Fatalf("inner-b.focusN = %d, want at least 1", innerB.focusN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestFocusAuthorizedThroughNestedChain(t *testing.T) {
|
||||||
|
outer, _, innerA, _ := buildNested(t)
|
||||||
|
|
||||||
|
// Move outer focus onto the nested subtree, and its own internal focus
|
||||||
|
// onto inner-a, so inner-a is genuinely the focused leaf end-to-end.
|
||||||
|
outer.Focus("inner-a")
|
||||||
|
if innerA.focusN != 1 {
|
||||||
|
t.Fatalf("inner-a.focusN = %d, want 1 before the request", innerA.focusN)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := outer.Update(RequestFocusMsg{Source: "inner-a", Target: "sidebar"})
|
||||||
|
outer = updated.(Model)
|
||||||
|
|
||||||
|
if outer.state.id != "sidebar" {
|
||||||
|
t.Fatalf("focusedID = %q, want %q: inner-a is genuinely focused, its request should be honored", outer.state.id, "sidebar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestFocusFromNonFocusedNestedLeafIsIgnored(t *testing.T) {
|
||||||
|
outer, _, _, innerB := buildNested(t)
|
||||||
|
// Outer focus is on "sidebar"; the nested tree isn't even the focused
|
||||||
|
// branch, so nothing inside it - including inner-b - is authorized.
|
||||||
|
_ = innerB
|
||||||
|
|
||||||
|
updated, _ := outer.Update(RequestFocusMsg{Source: "inner-b", Target: "sidebar"})
|
||||||
|
outer = updated.(Model)
|
||||||
|
|
||||||
|
if outer.state.id != "sidebar" {
|
||||||
|
t.Fatalf("focusedID = %q, want unchanged %q", outer.state.id, "sidebar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveFocusDelegatesToNestedBeforeGeometry(t *testing.T) {
|
||||||
|
outer, _, _, innerB := buildNested(t)
|
||||||
|
outer.Focus("inner-a")
|
||||||
|
|
||||||
|
if ok := outer.MoveFocus(FocusRight); !ok {
|
||||||
|
t.Fatal("MoveFocus(FocusRight) should have been handled by the nested tree (inner-a -> inner-b)")
|
||||||
|
}
|
||||||
|
if innerB.focusN != 1 {
|
||||||
|
t.Fatalf("inner-b.focusN = %d, want 1 (moved within the nested tree)", innerB.focusN)
|
||||||
|
}
|
||||||
|
if outer.state.id != "inner-root" {
|
||||||
|
t.Fatalf("outer focusedID changed to %q, should have stayed on the nested leaf", outer.state.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now at inner-b, the nested tree's own rightmost leaf: pressing right
|
||||||
|
// again must bubble up and move the OUTER focus instead.
|
||||||
|
if ok := outer.MoveFocus(FocusRight); ok {
|
||||||
|
// buildNested's outer split is only sidebar|inner-root left-to-right,
|
||||||
|
// so there's nothing further right at the outer level either -
|
||||||
|
// MoveFocus should report false all the way up.
|
||||||
|
t.Fatalf("expected no further neighbor to the right at either level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpBindingsDelegatesThroughNesting(t *testing.T) {
|
||||||
|
outer, _, innerA, _ := buildNested(t)
|
||||||
|
outer.Focus("inner-a")
|
||||||
|
|
||||||
|
want := key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "do x"))
|
||||||
|
innerA.help = []key.Binding{want}
|
||||||
|
|
||||||
|
got := outer.HelpBindings()
|
||||||
|
if len(got) != 1 || got[0].Help().Key != "x" {
|
||||||
|
t.Fatalf("HelpBindings() = %#v, want the focused inner leaf's bindings", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ in
|
|||||||
[
|
[
|
||||||
go
|
go
|
||||||
doctoc
|
doctoc
|
||||||
|
(python3.withPackages (ps: [ps.pyte]))
|
||||||
]
|
]
|
||||||
++ hooks.enabledPackages;
|
++ hooks.enabledPackages;
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# ilovetui default theme
|
# ilovetui default config
|
||||||
# Copy to ~/.config/ilovetui/config.yaml and edit to customize.
|
# 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:
|
colors:
|
||||||
base00: "#110F12" # Background
|
base00: "#110F12" # Background
|
||||||
base01: "#1C1920" # Lighter Background / Status Bars
|
base01: "#1C1920" # Lighter Background / Status Bars
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package ilovetui
|
package style
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package ilovetui
|
package style
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image/color"
|
"image/color"
|
||||||
@@ -44,7 +44,11 @@ type Styles struct {
|
|||||||
Bold lipgloss.Style
|
Bold lipgloss.Style
|
||||||
Faint 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
|
Panel lipgloss.Style
|
||||||
PanelFocused lipgloss.Style
|
PanelFocused lipgloss.Style
|
||||||
|
|
||||||
@@ -53,7 +57,7 @@ type Styles struct {
|
|||||||
PagerDotInactive string
|
PagerDotInactive string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStyles(c colorsYAML) Styles {
|
func newStyles(c colorsYAML, nerdFonts bool, borderName string) Styles {
|
||||||
lc := func(s string) color.Color {
|
lc := func(s string) color.Color {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if s != "" && s[0] != '#' {
|
if s != "" && s[0] != '#' {
|
||||||
@@ -79,6 +83,8 @@ func newStyles(c colorsYAML) Styles {
|
|||||||
b0E := lc(c.Base0E)
|
b0E := lc(c.Base0E)
|
||||||
b0F := lc(c.Base0F)
|
b0F := lc(c.Base0F)
|
||||||
|
|
||||||
|
borderType := resolveBorderType(borderName)
|
||||||
|
|
||||||
return Styles{
|
return Styles{
|
||||||
Base00: b00, Base01: b01, Base02: b02, Base03: b03,
|
Base00: b00, Base01: b01, Base02: b02, Base03: b03,
|
||||||
Base04: b04, Base05: b05, Base06: b06, Base07: b07,
|
Base04: b04, Base05: b05, Base06: b06, Base07: b07,
|
||||||
@@ -99,12 +105,15 @@ func newStyles(c colorsYAML) Styles {
|
|||||||
Bold: lipgloss.NewStyle().Bold(true),
|
Bold: lipgloss.NewStyle().Bold(true),
|
||||||
Faint: lipgloss.NewStyle().Foreground(b03).Faint(true),
|
Faint: lipgloss.NewStyle().Foreground(b03).Faint(true),
|
||||||
|
|
||||||
|
NerdFonts: nerdFonts,
|
||||||
|
BorderType: borderType,
|
||||||
|
|
||||||
Panel: lipgloss.NewStyle().
|
Panel: lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(borderType).
|
||||||
BorderForeground(b03),
|
BorderForeground(b03),
|
||||||
|
|
||||||
PanelFocused: lipgloss.NewStyle().
|
PanelFocused: lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(borderType).
|
||||||
BorderForeground(b0D),
|
BorderForeground(b0D),
|
||||||
|
|
||||||
PagerDotActive: lipgloss.NewStyle().Foreground(b0D).SetString("•").String(),
|
PagerDotActive: lipgloss.NewStyle().Foreground(b0D).SetString("•").String(),
|
||||||
+117
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user