diff --git a/README.md b/README.md index 9ae2bfa..44cf5cc 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,28 @@ # Ilovetui -A minimal Go library that provides a shared [Base16](https://github.com/tinted-theming/home) color theme for terminal UIs built with [bubbletea](https://github.com/charmbracelet/bubbletea) and [lipgloss](https://github.com/charmbracelet/lipgloss). +A minimal Go library that provides a shared [Base16](https://github.com/tinted-theming/home) color theme for terminal UIs built with [bubbletea](https://github.com/charmbracelet/bubbletea) and [lipgloss](https://github.com/charmbracelet/lipgloss), plus a small collection of Bubble Tea v2 components on top of it. The idea is simple: instead of every TUI app managing its own colors, they all share one theme file so the user customizes once and every app looks consistent. +## Packages + +- `github.com/anotherhadi/ilovetui/style` — the theme itself: colors, pre-built panel styles, config loading. +- `github.com/anotherhadi/ilovetui/bubbles` — themed constructors for official `bubbles/v2` components (`help`, `textarea`, `textinput`, `list`, `table`, `filepicker`, `spinner`, `progress`, `paginator`, `viewport`). +- `github.com/anotherhadi/ilovetui/tabs`, `.../layout`, `.../modal`, `.../notification` — custom components not found in the official `bubbles` library, styled from the same theme. See each package's own README: [`tabs`](tabs/README.md), [`layout`](layout/README.md), [`modal`](modal/README.md), [`notification`](notification/README.md). + ## How it works -On import, `ilovetui` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (respecting `$XDG_CONFIG_HOME`). +On import, `style` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (respecting `$XDG_CONFIG_HOME`). If no config exists, it falls back to the embedded default. The active theme is exposed as the package-level variable `S`. ```go -import "github.com/anotherhadi/ilovetui" +import "github.com/anotherhadi/ilovetui/style" // Use colors directly -style := lipgloss.NewStyle().Foreground(ilovetui.S.Primary) +s := lipgloss.NewStyle().Foreground(style.S.Primary) // Use pre-built panel styles -box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Title", content, w, h) +box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h) ``` No setup required — just import and use. @@ -44,17 +50,17 @@ The theme follows the [Base16](https://github.com/tinted-theming/home) standard | `Warning` | Base09 | Integers / Constants / Booleans | | `Error` | Base08 | Variables / Errors / Diff Deleted | -The default theme is `./default.yaml`. Copy it and edit to customize: +The default theme is `style/default.yaml`. Copy it and edit to customize: ```sh mkdir -p ~/.config/ilovetui -cp $(go env GOPATH)/pkg/mod/github.com/anotherhadi/ilovetui*/default.yaml ~/.config/ilovetui/config.yaml +cp $(go env GOPATH)/pkg/mod/github.com/anotherhadi/ilovetui*/style/default.yaml ~/.config/ilovetui/config.yaml ``` Or let your app write it on first run: ```go -ilovetui.WriteDefaultConfig(ilovetui.DefaultConfigPath()) +style.WriteDefaultConfig(style.DefaultConfigPath()) ``` ## Pre-built styles @@ -72,22 +78,59 @@ ilovetui.WriteDefaultConfig(ilovetui.DefaultConfigPath()) ```go // Inner usable height of a bordered panel with outer height h -inner := ilovetui.ContentHeight(h) +inner := style.ContentHeight(h) // Render a box with a title embedded in the top border -box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Header", content, w, h) +box := style.RenderWithTitle(style.S.PanelFocused, "Header", content, w, h) ``` ## API ```go -ilovetui.Init() // Reload from default config path -ilovetui.InitFrom(path string) // Reload from a custom path -ilovetui.InitFromBytes(data []byte) // Parse raw YAML -ilovetui.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml -ilovetui.WriteDefaultConfig(path) // Write default config if missing +style.Init() // Reload from default config path +style.InitFrom(path string) // Reload from a custom path +style.InitFromBytes(data []byte) // Parse raw YAML +style.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml +style.WriteDefaultConfig(path) // Write default config if missing ``` +## Themed official components + +```go +import "github.com/anotherhadi/ilovetui/bubbles" + +h := bubbles.NewHelp() +ta := bubbles.NewTextarea(false) +ti := bubbles.NewTextInput() +l := bubbles.NewList(items, width, height) +t := bubbles.NewTable() +fp := bubbles.NewFilePicker() +sp := bubbles.NewSpinner() +pr := bubbles.NewProgress() +pg := bubbles.NewPaginator() +vp := bubbles.NewViewport() +``` + +Each constructor mirrors the official component's own `New`, then applies `style.S` on top. Where the +official `New` takes options (`spinner`, `table`, `progress`), they're forwarded before the theme is +applied, so you can still customize behavior; anything you pass that also sets colors will be overridden +by the theme afterward. + +## Custom components + +```go +import "github.com/anotherhadi/ilovetui/tabs" + +t := tabs.New([]tabs.Item{{Title: "First", Model: firstPane}, {Title: "Second", Model: secondPane}}) +``` + +`tabs` renders a horizontal tab bar styled from `style.S`. The host application renders the content +below it; see [`tabs/README.md`](tabs/README.md) and `examples/tabs` for a full example. + +For the other custom components, see their own README: [`layout`](layout/README.md) (BSP pane +layout with spatial focus navigation), [`modal`](modal/README.md) (centered popup dialogs) and +[`notification`](notification/README.md) (toast notifications). + ## Projects using ilovetui - [anotherhadi/spilltea](https://github.com/anotherhadi/spilltea): A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players. Think Burp Suite or Caido, but entirely in your terminal. diff --git a/border.go b/border.go deleted file mode 100644 index 7245ad1..0000000 --- a/border.go +++ /dev/null @@ -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) -} diff --git a/bubbles/bubbles.go b/bubbles/bubbles.go new file mode 100644 index 0000000..f7ae7e2 --- /dev/null +++ b/bubbles/bubbles.go @@ -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 diff --git a/bubbles/filepicker.go b/bubbles/filepicker.go new file mode 100644 index 0000000..d0b913f --- /dev/null +++ b/bubbles/filepicker.go @@ -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 +} diff --git a/bubbles/help.go b/bubbles/help.go new file mode 100644 index 0000000..7066979 --- /dev/null +++ b/bubbles/help.go @@ -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 +} diff --git a/bubbles/layout.go b/bubbles/layout.go new file mode 100644 index 0000000..eff8d7e --- /dev/null +++ b/bubbles/layout.go @@ -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 +} diff --git a/bubbles/list.go b/bubbles/list.go new file mode 100644 index 0000000..551e4ec --- /dev/null +++ b/bubbles/list.go @@ -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 +} diff --git a/bubbles/paginator.go b/bubbles/paginator.go new file mode 100644 index 0000000..ccbcb47 --- /dev/null +++ b/bubbles/paginator.go @@ -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 +} diff --git a/bubbles/progress.go b/bubbles/progress.go new file mode 100644 index 0000000..86ad64c --- /dev/null +++ b/bubbles/progress.go @@ -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 +} diff --git a/bubbles/spinner.go b/bubbles/spinner.go new file mode 100644 index 0000000..1efaa71 --- /dev/null +++ b/bubbles/spinner.go @@ -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 +} diff --git a/bubbles/table.go b/bubbles/table.go new file mode 100644 index 0000000..d1d62b1 --- /dev/null +++ b/bubbles/table.go @@ -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 +} diff --git a/bubbles/textarea.go b/bubbles/textarea.go new file mode 100644 index 0000000..5e25ceb --- /dev/null +++ b/bubbles/textarea.go @@ -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 +} diff --git a/bubbles/textinput.go b/bubbles/textinput.go new file mode 100644 index 0000000..268069d --- /dev/null +++ b/bubbles/textinput.go @@ -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 +} diff --git a/bubbles/viewport.go b/bubbles/viewport.go new file mode 100644 index 0000000..d3a1fad --- /dev/null +++ b/bubbles/viewport.go @@ -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") +} diff --git a/components.go b/components.go deleted file mode 100644 index 8c9e179..0000000 --- a/components.go +++ /dev/null @@ -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") -} diff --git a/config.go b/config.go deleted file mode 100644 index c20905a..0000000 --- a/config.go +++ /dev/null @@ -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), - } -} diff --git a/examples/layout/basic/main.go b/examples/layout/basic/main.go new file mode 100644 index 0000000..6b14f6b --- /dev/null +++ b/examples/layout/basic/main.go @@ -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) + } +} diff --git a/examples/layout/bordered/main.go b/examples/layout/bordered/main.go new file mode 100644 index 0000000..b1092b2 --- /dev/null +++ b/examples/layout/bordered/main.go @@ -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) + } +} diff --git a/examples/layout/fullapp/first/first.go b/examples/layout/fullapp/first/first.go new file mode 100644 index 0000000..ff57b3a --- /dev/null +++ b/examples/layout/fullapp/first/first.go @@ -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) +} diff --git a/examples/layout/fullapp/main.go b/examples/layout/fullapp/main.go new file mode 100644 index 0000000..0bc16f0 --- /dev/null +++ b/examples/layout/fullapp/main.go @@ -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) + } +} diff --git a/examples/layout/fullapp/router/router.go b/examples/layout/fullapp/router/router.go new file mode 100644 index 0000000..618321c --- /dev/null +++ b/examples/layout/fullapp/router/router.go @@ -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() +} diff --git a/examples/layout/fullapp/second/second.go b/examples/layout/fullapp/second/second.go new file mode 100644 index 0000000..554e22b --- /dev/null +++ b/examples/layout/fullapp/second/second.go @@ -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())) +} diff --git a/examples/layout/fullapp/sidebar/sidebar.go b/examples/layout/fullapp/sidebar/sidebar.go new file mode 100644 index 0000000..c1c247c --- /dev/null +++ b/examples/layout/fullapp/sidebar/sidebar.go @@ -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")), + } +} diff --git a/examples/layout/fullapp/third/third.go b/examples/layout/fullapp/third/third.go new file mode 100644 index 0000000..11e19ae --- /dev/null +++ b/examples/layout/fullapp/third/third.go @@ -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())) +} diff --git a/examples/layout/help/main.go b/examples/layout/help/main.go new file mode 100644 index 0000000..2b7df3f --- /dev/null +++ b/examples/layout/help/main.go @@ -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) + } +} diff --git a/examples/layout/messaging/main.go b/examples/layout/messaging/main.go new file mode 100644 index 0000000..c4aed93 --- /dev/null +++ b/examples/layout/messaging/main.go @@ -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) + } +} diff --git a/examples/layout/nested/main.go b/examples/layout/nested/main.go new file mode 100644 index 0000000..414bebe --- /dev/null +++ b/examples/layout/nested/main.go @@ -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) + } +} diff --git a/examples/modal/main.go b/examples/modal/main.go new file mode 100644 index 0000000..908f93a --- /dev/null +++ b/examples/modal/main.go @@ -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) + } +} diff --git a/examples/notification/main.go b/examples/notification/main.go new file mode 100644 index 0000000..ce1581d --- /dev/null +++ b/examples/notification/main.go @@ -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) + } +} diff --git a/examples/tabs/main.go b/examples/tabs/main.go new file mode 100644 index 0000000..d75be53 --- /dev/null +++ b/examples/tabs/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod index 43e358f..0e5b916 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,20 @@ go 1.25.8 require ( charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.2 charm.land/glamour/v2 v2.0.0 charm.land/lipgloss/v2 v2.0.3 + github.com/charmbracelet/x/ansi v0.11.7 gopkg.in/yaml.v3 v3.0.1 ) require ( - charm.land/bubbletea/v2 v2.0.2 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -24,12 +25,14 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect diff --git a/go.sum b/go.sum index 5b7e997..5b5d7fc 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= @@ -42,10 +44,14 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= @@ -56,6 +62,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= diff --git a/ilovetui.go b/ilovetui.go index 39d309e..4f3b6d8 100644 --- a/ilovetui.go +++ b/ilovetui.go @@ -1,116 +1 @@ -// Package ilovetui provides a shared Base16 color theme for bubbletea/lipgloss -// applications. The theme is loaded automatically on import from -// ~/.config/ilovetui/config.yaml (falling back to the embedded -// default config). Access colors and styles via the package-level variable S. -// -// import "github.com/anotherhadi/ilovetui" -// -// style := lipgloss.NewStyle().Foreground(ilovetui.S.Primary) -// box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Title", content, w, h) package ilovetui - -import ( - _ "embed" - "fmt" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -//go:embed default.yaml -var DefaultConfig []byte - -// S is the active theme. It is populated automatically at import time and can -// be reloaded at any point by calling Init, InitFrom, or InitFromBytes. -var S Styles - -func init() { - path := DefaultConfigPath() - if data, err := os.ReadFile(path); err == nil { - if s, err := stylesFromBytes(data); err == nil { - S = s - return - } - } - // Silent fallback: embedded default always works. - s, _ := stylesFromBytes(DefaultConfig) - S = s -} - -// Init reloads S from the user config file, falling back to the embedded -// default if the file is missing. Returns an error only on parse failures. -func Init() error { - path := DefaultConfigPath() - data, err := os.ReadFile(path) - if err != nil { - s, e := stylesFromBytes(DefaultConfig) - if e != nil { - return e - } - S = s - return nil - } - return InitFromBytes(data) -} - -// InitFrom reloads S from an explicit file path. -func InitFrom(path string) error { - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("ilovetui: read config: %w", err) - } - return InitFromBytes(data) -} - -// InitFromBytes reloads S from raw YAML. Accepts hex strings with or without -// the leading '#'. -func InitFromBytes(data []byte) error { - s, err := stylesFromBytes(data) - if err != nil { - return err - } - S = s - return nil -} - -// WriteDefaultConfig writes the embedded default config to path, creating -// parent directories as needed. No-op if the file already exists. -func WriteDefaultConfig(path string) error { - if _, err := os.Stat(path); err == nil { - return nil - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("ilovetui: create config dir: %w", err) - } - if err := os.WriteFile(path, DefaultConfig, 0o600); err != nil { - return fmt.Errorf("ilovetui: write config: %w", err) - } - return nil -} - -// DefaultConfigPath returns the canonical user config path, -// respecting $XDG_CONFIG_HOME. -func DefaultConfigPath() string { - return filepath.Join(configDir(), "ilovetui", "config.yaml") -} - -func stylesFromBytes(data []byte) (Styles, error) { - var base configYAML - if err := yaml.Unmarshal(DefaultConfig, &base); err != nil { - return Styles{}, fmt.Errorf("ilovetui: parse default config: %w", err) - } - var user configYAML - if err := yaml.Unmarshal(data, &user); err != nil { - return Styles{}, fmt.Errorf("ilovetui: parse config: %w", err) - } - return newStyles(mergeColors(base.Colors, user.Colors)), nil -} - -func configDir() string { - if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { - return dir - } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config") -} diff --git a/layout/README.md b/layout/README.md new file mode 100644 index 0000000..834b08d --- /dev/null +++ b/layout/README.md @@ -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. diff --git a/layout/geometry.go b/layout/geometry.go new file mode 100644 index 0000000..9ffaca2 --- /dev/null +++ b/layout/geometry.go @@ -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 +} diff --git a/layout/geometry_test.go b/layout/geometry_test.go new file mode 100644 index 0000000..dc684c7 --- /dev/null +++ b/layout/geometry_test.go @@ -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) + } + } +} diff --git a/layout/help.go b/layout/help.go new file mode 100644 index 0000000..4f8a51e --- /dev/null +++ b/layout/help.go @@ -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 +} diff --git a/layout/helpers_test.go b/layout/helpers_test.go new file mode 100644 index 0000000..e470736 --- /dev/null +++ b/layout/helpers_test.go @@ -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] +} diff --git a/layout/keymap.go b/layout/keymap.go new file mode 100644 index 0000000..1bfccb1 --- /dev/null +++ b/layout/keymap.go @@ -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}, + } +} diff --git a/layout/layout.go b/layout/layout.go new file mode 100644 index 0000000..c7eea9d --- /dev/null +++ b/layout/layout.go @@ -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) +} diff --git a/layout/layout_test.go b/layout/layout_test.go new file mode 100644 index 0000000..535e7fa --- /dev/null +++ b/layout/layout_test.go @@ -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") + } +} diff --git a/layout/messages.go b/layout/messages.go new file mode 100644 index 0000000..5ae8a03 --- /dev/null +++ b/layout/messages.go @@ -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 +} diff --git a/layout/navigation.go b/layout/navigation.go new file mode 100644 index 0000000..9aa6c11 --- /dev/null +++ b/layout/navigation.go @@ -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 +} diff --git a/layout/navigation_test.go b/layout/navigation_test.go new file mode 100644 index 0000000..82d435a --- /dev/null +++ b/layout/navigation_test.go @@ -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") + } +} diff --git a/layout/node.go b/layout/node.go new file mode 100644 index 0000000..ac4c82d --- /dev/null +++ b/layout/node.go @@ -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 +} diff --git a/layout/routing.go b/layout/routing.go new file mode 100644 index 0000000..0cbfd4e --- /dev/null +++ b/layout/routing.go @@ -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 +} diff --git a/layout/routing_test.go b/layout/routing_test.go new file mode 100644 index 0000000..4a5dc84 --- /dev/null +++ b/layout/routing_test.go @@ -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) + } +} diff --git a/modal/README.md b/modal/README.md new file mode 100644 index 0000000..e1e4f19 --- /dev/null +++ b/modal/README.md @@ -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. diff --git a/modal/content.go b/modal/content.go new file mode 100644 index 0000000..6585c4b --- /dev/null +++ b/modal/content.go @@ -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{} } +} diff --git a/modal/modal.go b/modal/modal.go new file mode 100644 index 0000000..b3fbf8d --- /dev/null +++ b/modal/modal.go @@ -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 +} diff --git a/modal/render.go b/modal/render.go new file mode 100644 index 0000000..f59ed44 --- /dev/null +++ b/modal/render.go @@ -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") +} diff --git a/modal/styles.go b/modal/styles.go new file mode 100644 index 0000000..1583ec6 --- /dev/null +++ b/modal/styles.go @@ -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, + } +} diff --git a/nix/shell.nix b/nix/shell.nix index f195e2f..c787b15 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -15,6 +15,7 @@ in [ go doctoc + (python3.withPackages (ps: [ps.pyte])) ] ++ hooks.enabledPackages; diff --git a/nix/tui-snapshot.py b/nix/tui-snapshot.py new file mode 100644 index 0000000..69af579 --- /dev/null +++ b/nix/tui-snapshot.py @@ -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,...'] -- [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() diff --git a/notification/README.md b/notification/README.md new file mode 100644 index 0000000..9c89c7d --- /dev/null +++ b/notification/README.md @@ -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. diff --git a/notification/notification.go b/notification/notification.go new file mode 100644 index 0000000..88e78b9 --- /dev/null +++ b/notification/notification.go @@ -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 +} diff --git a/notification/position.go b/notification/position.go new file mode 100644 index 0000000..5db5039 --- /dev/null +++ b/notification/position.go @@ -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 +} diff --git a/notification/render.go b/notification/render.go new file mode 100644 index 0000000..fe77a2e --- /dev/null +++ b/notification/render.go @@ -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") +} diff --git a/notification/styles.go b/notification/styles.go new file mode 100644 index 0000000..4967cd0 --- /dev/null +++ b/notification/styles.go @@ -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 + } +} diff --git a/notification/toast.go b/notification/toast.go new file mode 100644 index 0000000..53994d1 --- /dev/null +++ b/notification/toast.go @@ -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 } diff --git a/style/border.go b/style/border.go new file mode 100644 index 0000000..2f3c14f --- /dev/null +++ b/style/border.go @@ -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) +} diff --git a/style/config.go b/style/config.go new file mode 100644 index 0000000..3d1d824 --- /dev/null +++ b/style/config.go @@ -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), + } +} diff --git a/default.yaml b/style/default.yaml similarity index 73% rename from default.yaml rename to style/default.yaml index e265927..cbfa8ea 100644 --- a/default.yaml +++ b/style/default.yaml @@ -1,5 +1,14 @@ -# ilovetui default theme +# ilovetui default config # Copy to ~/.config/ilovetui/config.yaml and edit to customize. + +# Whether components may use Nerd Font glyphs (requires a patched font). +# Leave false for maximum terminal/font compatibility. +nerd_fonts: false + +# Border style used by panels across all ilovetui-based TUIs. +# One of: rounded, normal, thick, double, hidden, ascii. +border: rounded + colors: base00: "#110F12" # Background base01: "#1C1920" # Lighter Background / Status Bars diff --git a/glamour.go b/style/glamour.go similarity index 99% rename from glamour.go rename to style/glamour.go index e0520c8..3388b7f 100644 --- a/glamour.go +++ b/style/glamour.go @@ -1,4 +1,4 @@ -package ilovetui +package style import ( "fmt" diff --git a/style.go b/style/style.go similarity index 86% rename from style.go rename to style/style.go index 8fe5461..280cdfc 100644 --- a/style.go +++ b/style/style.go @@ -1,4 +1,4 @@ -package ilovetui +package style import ( "image/color" @@ -44,7 +44,11 @@ type Styles struct { Bold lipgloss.Style Faint lipgloss.Style - // Pre-built panel styles (rounded border) + // User preferences, read from config + NerdFonts bool + BorderType lipgloss.Border + + // Pre-built panel styles, bordered with BorderType Panel lipgloss.Style PanelFocused lipgloss.Style @@ -53,7 +57,7 @@ type Styles struct { PagerDotInactive string } -func newStyles(c colorsYAML) Styles { +func newStyles(c colorsYAML, nerdFonts bool, borderName string) Styles { lc := func(s string) color.Color { s = strings.TrimSpace(s) if s != "" && s[0] != '#' { @@ -79,6 +83,8 @@ func newStyles(c colorsYAML) Styles { b0E := lc(c.Base0E) b0F := lc(c.Base0F) + borderType := resolveBorderType(borderName) + return Styles{ Base00: b00, Base01: b01, Base02: b02, Base03: b03, Base04: b04, Base05: b05, Base06: b06, Base07: b07, @@ -99,12 +105,15 @@ func newStyles(c colorsYAML) Styles { Bold: lipgloss.NewStyle().Bold(true), Faint: lipgloss.NewStyle().Foreground(b03).Faint(true), + NerdFonts: nerdFonts, + BorderType: borderType, + Panel: lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). + Border(borderType). BorderForeground(b03), PanelFocused: lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). + Border(borderType). BorderForeground(b0D), PagerDotActive: lipgloss.NewStyle().Foreground(b0D).SetString("•").String(), diff --git a/style/theme.go b/style/theme.go new file mode 100644 index 0000000..7cde275 --- /dev/null +++ b/style/theme.go @@ -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") +} diff --git a/tabs/README.md b/tabs/README.md new file mode 100644 index 0000000..77fb7e3 --- /dev/null +++ b/tabs/README.md @@ -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. diff --git a/tabs/tabs.go b/tabs/tabs.go new file mode 100644 index 0000000..7a7f411 --- /dev/null +++ b/tabs/tabs.go @@ -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 +}