5 Commits
Author SHA1 Message Date
Hadi 2fd1861339 Change overlay display to allow bubblezone to work
Signed-off-by: Hadi <[email protected]>
2026-08-23 17:42:33 +02:00
Hadi b22ce7cbf7 minimum window size package
Signed-off-by: Hadi <[email protected]>
2026-08-23 10:50:15 +02:00
Hadi 0648e7a761 app msgs
Signed-off-by: Hadi <[email protected]>
2026-08-20 20:23:15 +02:00
Hadi 55cb4be01d v0.2.1 2026-08-20 15:43:56 +02:00
Hadi 21b574eb6f Change style package, new components, ...
Signed-off-by: Hadi <[email protected]>
2026-08-18 20:10:32 +02:00
58 changed files with 486 additions and 2400 deletions
+15 -118
View File
@@ -1,136 +1,33 @@
# Ilovetui # I Love TUI
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. A shared [Base16](https://github.com/tinted-theming/home) theme, a themed wrapper around every official component, and a small set of custom Bubble Tea components, in one Go module, so every TUI built with it shares one config file and looks consistent.
The idea is simple: instead of every TUI app managing its own colors, they all share one theme file so the user customizes once and every app looks consistent. ## Install
## 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`, `.../helpbar`, `.../modal`, `.../drawer`, `.../notification` — custom components not found in the official `bubbles` library, styled from the same theme.
## How it works
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/style"
// Use colors directly
s := lipgloss.NewStyle().Foreground(style.S.Primary)
// Use pre-built panel styles
box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h)
```
No setup required — just import and use.
## Installation
```sh ```sh
go get github.com/anotherhadi/ilovetui go get github.com/anotherhadi/ilovetui
``` ```
## Theme ## Packages
The theme follows the [Base16](https://github.com/tinted-theming/home) standard (16 colors). The library exposes both the raw palette and semantic aliases: - [`style`](style/README.md): the theme itself. Colors, pre-built panel styles, config loading.
- [`bubbles`](bubbles/README.md): themed constructors for official `bubbles/v2` components (`help`, `textarea`, `textinput`, `viewport`, ...).
- [`tabs`](tabs/README.md), [`modal`](modal/README.md), [`drawer`](drawer/README.md), [`notification`](notification/README.md), [`helpbar`](helpbar/README.md): custom components not found in the official `bubbles` library, styled from the same theme.
| Alias | Base16 | Meaning | Each package has its own README and a runnable example under `examples/<package>`.
| ------------ | ------ | --------------------------------------- |
| `Background` | Base00 | Background |
| `SubtleBg` | Base01 | Lighter Background / Status Bars |
| `Selection` | Base02 | Selection Background |
| `Subtle` | Base03 | Comments / Invisibles |
| `Muted` | Base04 | Dark Foreground / Status Bars |
| `Text` | Base05 | Default Foreground |
| `Primary` | Base0D | Functions / Methods / Headings / Accent |
| `Success` | Base0B | Strings / Success / Diff Inserted |
| `Warning` | Base09 | Integers / Constants / Booleans |
| `Error` | Base08 | Variables / Errors / Diff Deleted |
The default theme is `style/default.yaml`. Copy it and edit to customize: ## Quick start
```sh On import, `style` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (embedded default as fallback), exposed as the package-level `S`:
mkdir -p ~/.config/ilovetui
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 ```go
style.WriteDefaultConfig(style.DefaultConfigPath()) import "github.com/anotherhadi/ilovetui/style"
s := lipgloss.NewStyle().Foreground(style.S.Primary)
box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h)
``` ```
## Pre-built styles No setup required. See [`style/README.md`](style/README.md) for config details.
`S` ships with a few ready-to-use lipgloss styles:
| Field | Description |
| ---------------- | ---------------------------------------- |
| `S.Bold` | Bold text |
| `S.Faint` | Muted / dimmed text |
| `S.Panel` | Rounded border, unfocused (Subtle color) |
| `S.PanelFocused` | Rounded border, focused (Primary color) |
## Helpers
```go
// Inner usable height of a bordered panel with outer height h
inner := style.ContentHeight(h)
// Render a box with a title embedded in the top border
box := style.RenderWithTitle(style.S.PanelFocused, "Header", content, w, h)
```
## API
```go
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: [`helpbar`](helpbar/README.md) (responsive
help bar that reflows into as many columns as fit), [`modal`](modal/README.md) (centered popup
dialogs), [`drawer`](drawer/README.md) (left/right sidebar panels, mirroring `modal`) and
[`notification`](notification/README.md) (toast notifications).
## Projects using ilovetui ## Projects using ilovetui
+33
View File
@@ -0,0 +1,33 @@
package app
import tea "charm.land/bubbletea/v2"
type QuitMsg struct{}
func Quit() tea.Cmd {
return func() tea.Msg { return QuitMsg{} }
}
type FocusMsg struct{}
func Focus() tea.Cmd {
return func() tea.Msg { return FocusMsg{} }
}
type BlurMsg struct{}
func Blur() tea.Cmd {
return func() tea.Msg { return BlurMsg{} }
}
type RequestFocusMsg struct{}
func RequestFocus() tea.Cmd {
return func() tea.Msg { return RequestFocusMsg{} }
}
type SetTitleMsg struct{ Title string }
func SetTitle(title string) tea.Cmd {
return func() tea.Msg { return SetTitleMsg{Title: title} }
}
+17
View File
@@ -0,0 +1,17 @@
# bubbles
Themed constructors for every official `bubbles/v2` component: `help`, `textarea`, `textinput`,
`list`, `table`, `filepicker`, `spinner`, `progress`, `paginator`, `viewport`. Each wraps the
official `New`, then applies `style.S` colors on top.
- `spinner`, `table`, `progress` forward any `opts` to the upstream `New` before the theme is
applied, so the theme's colors always win over anything conflicting in `opts`.
- `ViewportView` renders a `viewport.Model` with a themed scrollbar thumb in the left gutter,
shown only when content overflows.
- `NewList`/`NewDefaultDelegate` theme both the list chrome and the selection/filter colors of its
default delegate.
Custom components in this repo (`tabs`, `modal`, `drawer`, `notification`, `helpbar`) build any
official component they need through here, never through `charm.land/bubbles/v2` directly.
See `examples/bubbles`.
-5
View File
@@ -1,6 +1 @@
// 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 package bubbles
-1
View File
@@ -6,7 +6,6 @@ import (
"github.com/anotherhadi/ilovetui/style" "github.com/anotherhadi/ilovetui/style"
) )
// NewFilePicker returns a filepicker.Model styled with the active theme.
func NewFilePicker() filepicker.Model { func NewFilePicker() filepicker.Model {
f := filepicker.New() f := filepicker.New()
s := filepicker.DefaultStyles() s := filepicker.DefaultStyles()
-1
View File
@@ -7,7 +7,6 @@ import (
"github.com/anotherhadi/ilovetui/style" "github.com/anotherhadi/ilovetui/style"
) )
// NewHelp returns a help.Model styled with the active theme.
func NewHelp() help.Model { func NewHelp() help.Model {
h := help.New() h := help.New()
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(style.S.Primary) h.Styles.ShortKey = lipgloss.NewStyle().Foreground(style.S.Primary)
-13
View File
@@ -1,13 +0,0 @@
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
}
-7
View File
@@ -6,18 +6,13 @@ import (
"github.com/anotherhadi/ilovetui/style" "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 { func NewList(items []list.Item, width, height int) list.Model {
m := list.New(items, NewDefaultDelegate(), width, height) m := list.New(items, NewDefaultDelegate(), width, height)
m.Styles = themedListStyles() m.Styles = themedListStyles()
return m return m
} }
// themedListStyles builds list.Styles from the active theme.
func themedListStyles() list.Styles { func themedListStyles() list.Styles {
// isDark only affects a couple of fallback colors below, all of which
// are overridden regardless.
s := list.DefaultStyles(true) s := list.DefaultStyles(true)
s.Title = s.Title.Background(style.S.Primary).Foreground(style.S.Background) s.Title = s.Title.Background(style.S.Primary).Foreground(style.S.Background)
s.Spinner = s.Spinner.Foreground(style.S.Primary) s.Spinner = s.Spinner.Foreground(style.S.Primary)
@@ -35,8 +30,6 @@ func themedListStyles() list.Styles {
return s 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 { func NewDefaultDelegate() list.DefaultDelegate {
d := list.NewDefaultDelegate() d := list.NewDefaultDelegate()
d.Styles.NormalTitle = d.Styles.NormalTitle.Foreground(style.S.Text) d.Styles.NormalTitle = d.Styles.NormalTitle.Foreground(style.S.Text)
-1
View File
@@ -7,7 +7,6 @@ import (
"github.com/anotherhadi/ilovetui/style" "github.com/anotherhadi/ilovetui/style"
) )
// NewPaginator returns a dot-style paginator.Model styled with the active theme.
func NewPaginator() paginator.Model { func NewPaginator() paginator.Model {
p := paginator.New() p := paginator.New()
p.Type = paginator.Dots p.Type = paginator.Dots
-3
View File
@@ -6,9 +6,6 @@ import (
"github.com/anotherhadi/ilovetui/style" "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 { func NewProgress(opts ...progress.Option) progress.Model {
allOpts := append([]progress.Option{ allOpts := append([]progress.Option{
progress.WithColors(style.S.Subtle, style.S.Primary), progress.WithColors(style.S.Subtle, style.S.Primary),
-2
View File
@@ -7,8 +7,6 @@ import (
"github.com/anotherhadi/ilovetui/style" "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 { func NewSpinner(opts ...spinner.Option) spinner.Model {
s := spinner.New(opts...) s := spinner.New(opts...)
s.Style = lipgloss.NewStyle().Foreground(style.S.Primary) s.Style = lipgloss.NewStyle().Foreground(style.S.Primary)
-2
View File
@@ -6,8 +6,6 @@ import (
"github.com/anotherhadi/ilovetui/style" "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 { func NewTable(opts ...table.Option) table.Model {
t := table.New(opts...) t := table.New(opts...)
s := table.DefaultStyles() s := table.DefaultStyles()
-2
View File
@@ -7,8 +7,6 @@ import (
"github.com/anotherhadi/ilovetui/style" "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 { func NewTextarea(showLineNumbers bool) textarea.Model {
ta := textarea.New() ta := textarea.New()
ta.Prompt = "" ta.Prompt = ""
-5
View File
@@ -6,18 +6,13 @@ import (
"github.com/anotherhadi/ilovetui/style" "github.com/anotherhadi/ilovetui/style"
) )
// NewTextInput returns a textinput.Model styled with the active theme.
func NewTextInput() textinput.Model { func NewTextInput() textinput.Model {
t := textinput.New() t := textinput.New()
t.SetStyles(themedTextInputStyles()) t.SetStyles(themedTextInputStyles())
return t 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 { func themedTextInputStyles() textinput.Styles {
// isDark only affects textinput.DefaultStyles' Blurred.Text color, which
// we override below regardless.
s := textinput.DefaultStyles(true) s := textinput.DefaultStyles(true)
s.Focused.Text = s.Focused.Text.Foreground(style.S.Text) s.Focused.Text = s.Focused.Text.Foreground(style.S.Text)
s.Focused.Placeholder = s.Focused.Placeholder.Foreground(style.S.Subtle) s.Focused.Placeholder = s.Focused.Placeholder.Foreground(style.S.Subtle)
+27 -18
View File
@@ -1,38 +1,47 @@
package bubbles package bubbles
import ( import (
"strings"
"charm.land/bubbles/v2/viewport" "charm.land/bubbles/v2/viewport"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style" "github.com/anotherhadi/ilovetui/style"
) )
// NewViewport returns a viewport.Model with mouse wheel disabled. const ViewportGutterWidth = 2
func NewViewport() viewport.Model { func NewViewport() viewport.Model {
vp := viewport.New() vp := viewport.New()
vp.MouseWheelEnabled = false vp.MouseWheelEnabled = false
return vp 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 { func ViewportView(vp *viewport.Model) string {
v := vp.View() height := vp.Height()
if vp.AtBottom() { total := vp.TotalLineCount()
return v blank := lipgloss.NewStyle().Width(ViewportGutterWidth).Render("")
if height <= 0 || total <= height {
vp.LeftGutterFunc = func(viewport.GutterContext) string { return blank }
return vp.View()
} }
lines := strings.Split(v, "\n")
if len(lines) == 0 { yOffset := vp.YOffset()
return v thumbSize := max(1, height*height/total)
thumbStart := 0
if maxOffset := total - height; maxOffset > 0 {
thumbStart = yOffset * (height - thumbSize) / maxOffset
} }
arrow := lipgloss.NewStyle().Foreground(style.S.Subtle).Render("↓")
arrowW := lipgloss.Width(arrow) trackStyle := lipgloss.NewStyle().Foreground(style.S.Subtle)
inner := vp.Width() - 2*arrowW thumbStyle := lipgloss.NewStyle().Foreground(style.S.Primary)
if inner < 0 {
inner = 0 vp.LeftGutterFunc = func(ctx viewport.GutterContext) string {
pos := ctx.Index - yOffset
if pos >= thumbStart && pos < thumbStart+thumbSize {
return thumbStyle.Render("█") + " "
} }
lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow return trackStyle.Render("│") + " "
return strings.Join(lines, "\n") }
return vp.View()
} }
+8 -135
View File
@@ -1,140 +1,13 @@
# drawer # drawer
A full-height panel flush against the left or right edge of an already-rendered background, on top A full-height panel flush against the left or right edge of an already-rendered background, on top
of it dimmed - the sidebar/drawer equivalent of [`modal`](../modal/README.md), which this package of it dimmed. The sidebar/drawer equivalent of [`modal`](../modal/README.md), which it otherwise
otherwise mirrors closely: same stack of panels triggered from anywhere via an exported `tea.Msg` mirrors closely: same `tea.Msg`-triggered stack (`ShowMsg`/`Show`), same composite-over-a-string
(`ShowMsg`/`Show`) rather than a direct reference to the `Model` that ends up rendering it, same `Render`, same rule that content is a `tea.Model`.
composite-over-an-already-rendered-string `Render`, no assumption about how the host builds that
string.
## Quick start - `WithSide(Left|Right)` and `WithWidth` are per-drawer `Show` options; width otherwise shrinks to
fit content, capped by the `Model`'s `WithMaxWidth`.
- The stack is a plain LIFO. Only the topmost drawer is updated; `Close()` closes it.
- `View(width, height)` renders on a blank background of that size, for use as a standalone pane.
```go See `examples/drawer`.
import (
"github.com/anotherhadi/ilovetui/drawer"
)
type model struct {
d drawer.Model
width, height int
}
func newModel() model {
return model{d: drawer.New()}
}
func (m model) Init() tea.Cmd { return m.d.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "l" {
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
}
if msg.String() == "esc" && m.d.Open() {
return m, drawer.Close()
}
}
var cmd tea.Cmd
m.d, cmd = m.d.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
background := renderYourUI(m.width, m.height)
view := tea.NewView(m.d.Render(background))
view.AltScreen = true
return view
}
```
Any component in the same bubbletea program can trigger a drawer via `drawer.Show`, without
holding a reference to the `drawer.Model` that will actually render it - that `Model` just needs
to see every `tea.Msg` the program produces (i.e. get its `Update` called from the top-level
`Update`), same as any other child model.
## Content is a model
A drawer's body is a `tea.Model`, not a string. While a drawer is on top of the stack it gets
every message the `drawer.Model` receives, its `Init` runs when it opens, and its commands come
back out - so it can hold a file list, a filter form, or a picker that reports its choice with a
`tea.Msg` of its own, which the component that opened it listens for:
```go
type pickedMsg struct{ file string }
// somewhere else
return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
```
Only the topmost drawer is updated: everything beneath it is dimmed and frozen until the drawers
above it close.
For a drawer with nothing to interact with, `drawer.Text` wraps a plain string:
```go
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
```
The box shrinks to fit whatever the content draws (unless `WithWidth` fixes it), so a content
that wants a specific size sets it on itself - the drawer only ever sees the rendered result.
## Side and width
```go
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"),
drawer.WithSide(drawer.Right), drawer.WithWidth(24))
```
`WithSide` anchors the drawer to `drawer.Left` (the default) or `drawer.Right`. `WithWidth` fixes
the drawer's total width instead of shrinking to fit its content, still capped by whatever
actually fits the background - same unit as the `Model`-level `WithMaxWidth`. Either way, the
drawer always spans the background's full height, flush top to bottom.
## Showing and closing
```go
return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
return m, drawer.Close() // close the topmost drawer
```
The stack is a plain LIFO, with no identity: a drawer is closed by being on top, never by being
named. There is nothing to tag a drawer with, and nothing that can target one in the middle of the
stack - the topmost is both the only one that receives messages and the only one `Close` can
reach, so the two rules never disagree.
- `drawer.Open()` reports whether at least one drawer is currently shown - handy for a host that
wants to route key presses to the drawer instead of its normal UI while one is open. Note that a
host doing this also makes it impossible for a key to open a second drawer, which is what keeps
the stack shallow without any bookkeeping.
- A content model closes its own drawer by returning `drawer.Close()`, since it only ever runs
while it is the topmost one.
## Stacking
Drawers stack: showing a second one while the first is still open pushes it on top, dimming both
the background and the first drawer to the same flat color, same as `modal`. Opening a left drawer
and a right drawer at once still stacks - if you want both visible at full color simultaneously,
render them as two ordinary panels via `layout` instead; this package is for transient
sidebars, not permanent chrome.
## Styling
```go
d := drawer.New(drawer.WithMaxWidth(30), drawer.WithStyles(myStyles))
return m, drawer.Show("Nav", drawer.Text("content"), drawer.WithDrawerStyle(oneOffStyles))
```
`WithMaxWidth` caps how wide a drawer can grow (border and padding included) before wrapping; a
drawer narrower than the cap shrinks to fit its content instead of padding out to it, unless shown
with `WithWidth`. A drawer can also never overflow past the edge of whatever background it's
rendered on. `WithStyles` sets the default look for every drawer shown by this `Model`;
`WithDrawerStyle` (a `Show` option) overrides it for one drawer alone. `DefaultStyles()` builds
from `style.S`, same palette roles as `modal.DefaultStyles()`.
## Examples
- `examples/drawer` - a left nav drawer and a right inspector drawer, either one at a time.
-48
View File
@@ -2,51 +2,31 @@ package drawer
import tea "charm.land/bubbletea/v2" import tea "charm.land/bubbletea/v2"
// Side is which edge of the background a Drawer is anchored to.
type Side int type Side int
const ( const (
// Left anchors the drawer to the left edge (the zero value, so a Drawer
// built without WithSide opens on the left).
Left Side = iota Left Side = iota
// Right anchors the drawer to the right edge.
Right Right
) )
// Drawer is one sidebar panel on the stack.
type Drawer struct { type Drawer struct {
Title string Title string
// Content is the drawer's body: a full model, updated and rendered by
// the drawer.Model while it's on top of the stack. Wrap a plain string
// with Text for a drawer with nothing to interact with.
Content tea.Model Content tea.Model
Side Side Side Side
// Width, if non-zero, fixes the drawer's total width (border and
// padding included, same unit as the Model's WithMaxWidth) instead of
// shrinking to fit what Content draws, and Title. Still capped by whatever actually
// fits the background.
Width int Width int
// Style, if non-nil, overrides the Model's default Styles for this
// drawer alone.
Style *Styles Style *Styles
} }
// DrawerOption configures a Drawer built by Show.
type DrawerOption func(*Drawer) type DrawerOption func(*Drawer)
// WithSide anchors the drawer to the given Side (default Left).
func WithSide(s Side) DrawerOption { func WithSide(s Side) DrawerOption {
return func(d *Drawer) { d.Side = s } return func(d *Drawer) { d.Side = s }
} }
// WithWidth fixes the drawer's total width instead of shrinking to fit its
// content, still capped by whatever actually fits the background.
func WithWidth(w int) DrawerOption { func WithWidth(w int) DrawerOption {
return func(d *Drawer) { d.Width = w } return func(d *Drawer) { d.Width = w }
} }
// WithDrawerStyle overrides the Model's default Styles for this drawer
// alone, for a one-off custom look instead of the shared theme.
func WithDrawerStyle(s Styles) DrawerOption { func WithDrawerStyle(s Styles) DrawerOption {
return func(d *Drawer) { d.Style = &s } return func(d *Drawer) { d.Style = &s }
} }
@@ -59,51 +39,23 @@ func newDrawer(title string, content tea.Model, opts ...DrawerOption) Drawer {
return d return d
} }
// ShowMsg tells a drawer.Model to display Drawer, pushing it on top of the
// stack. Any component in the same bubbletea program can trigger one via
// Show, without holding a reference to the drawer.Model that will actually
// render it - that Model just needs to see every tea.Msg the program
// produces, same as any other child model.
type ShowMsg struct{ Drawer Drawer } type ShowMsg struct{ Drawer Drawer }
// Show returns a tea.Cmd that opens a new drawer on top of the stack,
// anchored to the left edge by default. content is a model, so a drawer can
// hold anything a pane can - a file list, a filter form, a picker that
// reports its choice with its own tea.Msg:
//
// return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
// return m, drawer.Show("Nav", drawer.Text("Home\nProjects"))
//
// The content's Init runs when the drawer opens, and it receives every
// message while it's the topmost drawer (see Model.Update).
func Show(title string, content tea.Model, opts ...DrawerOption) tea.Cmd { func Show(title string, content tea.Model, opts ...DrawerOption) tea.Cmd {
d := newDrawer(title, content, opts...) d := newDrawer(title, content, opts...)
return func() tea.Msg { return ShowMsg{Drawer: d} } return func() tea.Msg { return ShowMsg{Drawer: d} }
} }
// DismissMsg closes the topmost drawer. The stack is a plain LIFO: a drawer
// is closed by being on top, never by being named.
type DismissMsg struct{} type DismissMsg struct{}
// Close returns a tea.Cmd that closes the topmost drawer - which is also the
// only one that can act (see Model.Update), so a content model closes itself
// by returning it:
//
// return c, drawer.Close()
func Close() tea.Cmd { func Close() tea.Cmd {
return func() tea.Msg { return DismissMsg{} } return func() tea.Msg { return DismissMsg{} }
} }
// text is a model wrapping a fixed string: a drawer body with nothing to
// update.
type text string type text string
func (t text) Init() tea.Cmd { return nil } func (t text) Init() tea.Cmd { return nil }
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil } func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
func (t text) View() tea.View { return tea.NewView(string(t)) } func (t text) View() tea.View { return tea.NewView(string(t)) }
// Text wraps a plain string as drawer content, for the common drawer that has
// nothing to interact with:
//
// drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
func Text(s string) tea.Model { return text(s) } func Text(s string) tea.Model { return text(s) }
-39
View File
@@ -1,24 +1,7 @@
// Package drawer renders a full-height panel flush against the left or
// right edge of an already-rendered background, on top of it dimmed -
// the sidebar/drawer equivalent of github.com/anotherhadi/ilovetui/modal,
// which this package otherwise mirrors closely: same stack of panels
// triggered from anywhere via an exported tea.Msg (see ShowMsg/Show)
// rather than a direct reference to the Model that ends up rendering it,
// same composite-over-an-already-rendered-string Render, same absence of
// any assumption about how the host builds that string.
//
// A drawer's content is a model, not a string: it is updated while it's on
// top of the stack, so it can hold anything a pane can - a file list, a
// filter form, a picker reporting its choice back with its own tea.Msg. See
// Show and Text.
package drawer package drawer
import tea "charm.land/bubbletea/v2" import tea "charm.land/bubbletea/v2"
// Model holds the currently open drawers (a stack: the most recently shown
// is drawn on top, everything beneath it - the background and any earlier
// drawer - dimmed, see Render) and the rendering config (max width, styles)
// they share. Build one with New.
type Model struct { type Model struct {
drawers []Drawer drawers []Drawer
nextID int nextID int
@@ -26,24 +9,16 @@ type Model struct {
styles Styles styles Styles
} }
// Option configures a Model at construction. See WithMaxWidth, WithStyles.
type Option func(*Model) type Option func(*Model)
// WithMaxWidth caps a drawer's total width (border and padding included). A
// drawer shown without WithWidth shrinks to fit its content instead of
// padding out to the cap; a drawer shown with WithWidth uses that width
// instead, still capped by this. 0 means only the background's own width
// caps it.
func WithMaxWidth(w int) Option { func WithMaxWidth(w int) Option {
return func(m *Model) { m.maxWidth = w } return func(m *Model) { m.maxWidth = w }
} }
// WithStyles overrides the default styles (see DefaultStyles).
func WithStyles(s Styles) Option { func WithStyles(s Styles) Option {
return func(m *Model) { m.styles = s } return func(m *Model) { m.styles = s }
} }
// New builds a Model. Defaults: a 30-column max width, DefaultStyles.
func New(opts ...Option) Model { func New(opts ...Option) Model {
m := Model{ m := Model{
maxWidth: 30, maxWidth: 30,
@@ -67,13 +42,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m.updateTop(msg) return m.updateTop(msg)
} }
// updateTop forwards msg to the topmost drawer's content - the only one the
// user can interact with, everything beneath it being dimmed (see Render). A
// drawer deeper in the stack is frozen until the ones above it close.
//
// This is what lets drawer content be a real model: it gets the key presses,
// the ticks and the results of its own commands, and can report back to the
// rest of the program with a tea.Msg of its own.
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) { func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
i := len(m.drawers) - 1 i := len(m.drawers) - 1
if i < 0 || m.drawers[i].Content == nil { if i < 0 || m.drawers[i].Content == nil {
@@ -84,19 +52,13 @@ func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
return m, cmd return m, cmd
} }
// Open reports whether at least one drawer is currently shown - handy for a
// host that wants to route key presses to the drawer (e.g. esc to dismiss)
// instead of its normal UI while one is open.
func (m Model) Open() bool { return len(m.drawers) > 0 } func (m Model) Open() bool { return len(m.drawers) > 0 }
// show pushes a drawer on top of the stack and returns its content's Init - a
// drawer's body starts the same way any other model does.
func (m Model) show(d Drawer) (Model, tea.Cmd) { func (m Model) show(d Drawer) (Model, tea.Cmd) {
m.drawers = append(m.drawers, d) m.drawers = append(m.drawers, d)
return m, initContent(d) return m, initContent(d)
} }
// initContent is d's content's Init, or nil for a drawer without content.
func initContent(d Drawer) tea.Cmd { func initContent(d Drawer) tea.Cmd {
if d.Content == nil { if d.Content == nil {
return nil return nil
@@ -104,7 +66,6 @@ func initContent(d Drawer) tea.Cmd {
return d.Content.Init() return d.Content.Init()
} }
// pop closes the topmost drawer (see Close).
func (m Model) pop() Model { func (m Model) pop() Model {
if len(m.drawers) == 0 { if len(m.drawers) == 0 {
return m return m
+2 -42
View File
@@ -10,14 +10,6 @@ import (
"github.com/anotherhadi/ilovetui/style" "github.com/anotherhadi/ilovetui/style"
) )
// Render draws every open drawer (see Model.Update/Show) on top of
// background (already rendered, e.g. layout.Model.View() or any other
// component's View()) and returns the result. Each drawer in the stack
// first flattens whatever came before it - background plus any earlier
// drawer - to a single flat DimColor (see dim), then draws its own
// full-height box flush against its Side on top, so nesting a second
// drawer on top of a first dims the first one too. background is returned
// unchanged whenever there's nothing to draw.
func (m Model) Render(background string) string { func (m Model) Render(background string) string {
if len(m.drawers) == 0 { if len(m.drawers) == 0 {
return background return background
@@ -34,15 +26,10 @@ func (m Model) Render(background string) string {
return result return result
} }
// View is a convenience for a pane whose sole purpose is showing drawers
// (e.g. a dedicated layout.Leaf): it draws the stack over a blank
// width x height area instead of an existing background.
func (m Model) View(width, height int) string { func (m Model) View(width, height int) string {
return m.Render(blank(width, height)) return m.Render(blank(width, height))
} }
// renderOne dims background flat and draws d's box, spanning its full
// height, flush against d.Side.
func (m Model) renderOne(d Drawer, background string, w, h int) string { func (m Model) renderOne(d Drawer, background string, w, h int) string {
s := m.styles s := m.styles
if d.Style != nil { if d.Style != nil {
@@ -56,26 +43,13 @@ func (m Model) renderOne(d Drawer, background string, w, h int) string {
x = max(w-bw, 0) x = max(w-bw, 0)
} }
compositor := lipgloss.NewCompositor( return style.Overlay(dim(background, s.DimColor), box, x, 0)
lipgloss.NewLayer(dim(background, s.DimColor)),
lipgloss.NewLayer(box).X(x).Y(0).Z(1),
)
return compositor.Render()
} }
// dim flattens s to a single flat color: every existing style (colors,
// bold, underline...) is stripped, then every character - including
// whitespace, so highlighted/selected backgrounds vanish too - is
// repainted in c. Applying a Foreground style to a multi-line string styles
// each line independently (see lipgloss.Style.Render), so this keeps s's
// line structure intact.
func dim(s string, c color.Color) string { func dim(s string, c color.Color) string {
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s)) return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
} }
// renderBox draws d as a bordered, title-embedded box (style.RenderWithTitle)
// spanning the background's full height, capped by the Model's configured
// max width and by whatever actually fits inside a bgW-wide background.
func (m Model) renderBox(d Drawer, s Styles, bgW, bgH int) string { func (m Model) renderBox(d Drawer, s Styles, bgW, bgH int) string {
widthCap := m.maxWidth widthCap := m.maxWidth
if d.Width > 0 { if d.Width > 0 {
@@ -87,15 +61,11 @@ func (m Model) renderBox(d Drawer, s Styles, bgW, bgH int) string {
inner := contentWidth(d, body, maxW) inner := contentWidth(d, body, maxW)
content := s.Content.Width(inner).Render(body) content := s.Content.Width(inner).Render(body)
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2) boxWidth := inner + 4
return style.RenderWithTitle(s.Border, s.Title.Render(d.Title), content, boxWidth, bgH) return style.RenderWithTitle(s.Border, s.Title.Render(d.Title), content, boxWidth, bgH)
} }
// contentView is the drawer body's rendered string, or "" for a drawer
// without content. The box shrinks to fit whatever the content model draws,
// so a content that wants a specific size sets it on itself - the drawer only
// ever sees the result.
func contentView(d Drawer) string { func contentView(d Drawer) string {
if d.Content == nil { if d.Content == nil {
return "" return ""
@@ -103,10 +73,6 @@ func contentView(d Drawer) string {
return d.Content.View().Content return d.Content.View().Content
} }
// contentWidth is the drawer's inner (border/padding excluded) width, given
// the resolved total-width cap maxWidth (see renderBox): maxWidth-4 if
// d.Width is set (a fixed total width), otherwise its natural size (long
// enough for the widest line of title/content), capped at maxWidth-4.
func contentWidth(d Drawer, body string, maxWidth int) int { func contentWidth(d Drawer, body string, maxWidth int) int {
capped := max(maxWidth-4, 1) capped := max(maxWidth-4, 1)
if d.Width > 0 { if d.Width > 0 {
@@ -116,7 +82,6 @@ func contentWidth(d Drawer, body string, maxWidth int) int {
return min(natural, capped) return min(natural, capped)
} }
// naturalWidth is the width of content's widest line.
func naturalWidth(content string) int { func naturalWidth(content string) int {
w := 0 w := 0
for _, line := range strings.Split(content, "\n") { for _, line := range strings.Split(content, "\n") {
@@ -127,11 +92,6 @@ func naturalWidth(content string) int {
return w return w
} }
// effectiveMax resolves the cap actually used along the width axis:
// configured (0 = unlimited) narrowed down to fits, whatever actually fits
// the background - a drawer can never overflow past the edge of the
// background, or the terminal, when the background is a full-screen View(),
// regardless of how WithMaxWidth/WithWidth was set.
func effectiveMax(configured, fits int) int { func effectiveMax(configured, fits int) int {
if fits < 1 { if fits < 1 {
fits = 1 fits = 1
+2 -2
View File
@@ -23,7 +23,7 @@ func TestRenderLeftFlushToLeftEdge(t *testing.T) {
if lipgloss.Width(out) == 0 { if lipgloss.Width(out) == 0 {
t.Fatalf("expected non-empty render") t.Fatalf("expected non-empty render")
} }
// The border's top-left corner glyph should be the very first rune.
if len([]rune(lines[0])) == 0 { if len([]rune(lines[0])) == 0 {
t.Fatalf("expected a rendered top border line") t.Fatalf("expected a rendered top border line")
} }
@@ -54,7 +54,7 @@ func TestFixedWidthHonored(t *testing.T) {
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("x"), WithWidth(20))}) m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("x"), WithWidth(20))})
box := m.renderBox(m.drawers[0], m.styles, 80, 10) box := m.renderBox(m.drawers[0], m.styles, 80, 10)
if got := lipgloss.Width(box); got != 20 { // WithWidth is the total box width if got := lipgloss.Width(box); got != 20 {
t.Fatalf("expected fixed box width 20, got %d", got) t.Fatalf("expected fixed box width 20, got %d", got)
} }
} }
-12
View File
@@ -8,11 +8,6 @@ import (
"github.com/anotherhadi/ilovetui/style" "github.com/anotherhadi/ilovetui/style"
) )
// Styles is the set of lipgloss styles, plus the dim color, used to render a
// drawer and the background behind it. Border carries the box's border
// (shape + color, no size), Title and Content color the two pieces of text
// drawn inside it, DimColor is the single flat color every character of the
// background gets overwritten with while the drawer is open.
type Styles struct { type Styles struct {
Border lipgloss.Style Border lipgloss.Style
Title lipgloss.Style Title lipgloss.Style
@@ -20,13 +15,6 @@ type Styles struct {
DimColor color.Color DimColor color.Color
} }
// DefaultStyles builds a Styles from style.S: the box borrows
// PanelFocused's border (the drawer is what has focus while it's open).
// DimColor reuses Subtle - the base16 "comments/invisibles" role, already
// used across this repo for de-emphasized text (borders, placeholders,
// separators, see bubbles/*.go and modal.DefaultStyles) - darker than
// Muted, which reads too bright once it's covering an entire screen instead
// of a single blurred field.
func DefaultStyles() Styles { func DefaultStyles() Styles {
return Styles{ return Styles{
Border: style.S.PanelFocused.Padding(0, 1), Border: style.S.PanelFocused.Padding(0, 1),
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
)
type model struct {
input textinput.Model
spinner spinner.Model
}
func newModel() model {
ti := bubbles.NewTextInput()
ti.Placeholder = "type something"
ti.Focus()
return model{input: ti, spinner: bubbles.NewSpinner()}
}
func (m model) Init() tea.Cmd { return m.spinner.Tick }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "ctrl+c" {
return m, tea.Quit
}
var inputCmd, spinnerCmd tea.Cmd
m.input, inputCmd = m.input.Update(msg)
m.spinner, spinnerCmd = m.spinner.Update(msg)
return m, tea.Batch(inputCmd, spinnerCmd)
}
func (m model) View() tea.View {
return tea.NewView(lipgloss.JoinHorizontal(lipgloss.Center, m.spinner.View(), " ", m.input.View()))
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+4 -25
View File
@@ -8,7 +8,6 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/drawer" "github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/style"
) )
type model struct { type model struct {
@@ -16,9 +15,7 @@ type model struct {
width, height int width, height int
} }
func newModel() model { func newModel() model { return model{d: drawer.New()} }
return model{d: drawer.New()}
}
func (m model) Init() tea.Cmd { return m.d.Init() } func (m model) Init() tea.Cmd { return m.d.Init() }
@@ -26,43 +23,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyPressMsg: case tea.KeyPressMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c", "q": case "q":
return m, tea.Quit return m, tea.Quit
case "l": case "l":
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"), return m, drawer.Show("Nav", drawer.Text("Home\nSettings"), drawer.WithSide(drawer.Left))
drawer.WithSide(drawer.Left), drawer.WithWidth(20))
case "r":
return m, drawer.Show("Inspector", drawer.Text("id: 42\nstatus: ok"),
drawer.WithSide(drawer.Right), drawer.WithWidth(20))
case "esc": case "esc":
if m.d.Open() { if m.d.Open() {
return m, drawer.Close() return m, drawer.Close()
} }
} }
} }
var cmd tea.Cmd var cmd tea.Cmd
m.d, cmd = m.d.Update(msg) m.d, cmd = m.d.Update(msg)
return m, cmd return m, cmd
} }
func (m model) View() tea.View { func (m model) View() tea.View {
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App") background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "l: open q: quit")
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(
"Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the drawer.")
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"l: open left drawer r: open right drawer esc: close the top one q: quit")
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", body, "", help))
view := tea.NewView(m.d.Render(background)) view := tea.NewView(m.d.Render(background))
view.AltScreen = true view.AltScreen = true
return view return view
-231
View File
@@ -1,231 +0,0 @@
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/examples/fullapp/metrics"
"github.com/anotherhadi/ilovetui/examples/fullapp/overview"
"github.com/anotherhadi/ilovetui/examples/fullapp/settings"
"github.com/anotherhadi/ilovetui/examples/fullapp/sidebar"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/ilovetui/style"
)
var pages = []struct {
item sidebar.NavItem
new func() tea.Model
}{
{sidebar.NavItem{Icon: "󰋜", Name: "Overview"}, func() tea.Model { return overview.New() }},
{sidebar.NavItem{Icon: "󰄨", Name: "Metrics"}, func() tea.Model { return metrics.New() }},
{sidebar.NavItem{Icon: "󰒓", Name: "Settings"}, func() tea.Model { return settings.New() }},
}
type HelpProvider interface {
HelpBindings() []key.Binding
}
func navItems() []sidebar.NavItem {
items := make([]sidebar.NavItem, len(pages))
for i, p := range pages {
items[i] = p.item
}
return items
}
type keyMap struct {
FocusSidebar key.Binding
Close key.Binding
Help key.Binding
Quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
FocusSidebar: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "focus sidebar")),
Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q / ctrl+c", "quit")),
}
}
type Model struct {
sidebar sidebar.Model
page tea.Model
help helpbar.Model
notif notification.Model
modal modal.Model
drawer drawer.Model
keys keyMap
sidebarWidth int
hideNav bool
contentFocused bool
width, height int
}
func NewModel() Model {
keys := defaultKeyMap()
return Model{
sidebar: sidebar.New(navItems()...),
sidebarWidth: 24,
page: pages[0].new(),
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.FocusSidebar, keys.Quit)),
notif: notification.New(),
modal: modal.New(),
drawer: drawer.New(),
keys: keys,
hideNav: true,
}
}
func (m Model) Init() tea.Cmd {
return m.page.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m.resize()
case sidebar.SelectMsg:
m.page = pages[msg.Index].new()
var cmd tea.Cmd
m, cmd = m.resize()
return m, tea.Batch(m.page.Init(), cmd)
case sidebar.BlurMsg:
m.contentFocused = true
return m.resize()
case tea.KeyPressMsg:
if m.modal.Open() {
if key.Matches(msg, m.keys.Close) {
return m, modal.Close()
}
var cmd tea.Cmd
m.modal, cmd = m.modal.Update(msg)
return m, cmd
}
if m.drawer.Open() {
if key.Matches(msg, m.keys.Close) {
return m, drawer.Close()
}
var cmd tea.Cmd
m.drawer, cmd = m.drawer.Update(msg)
return m, cmd
}
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.help, _ = m.help.Update(msg)
return m.resize()
case key.Matches(msg, m.keys.FocusSidebar):
m.contentFocused = !m.contentFocused
return m.resize()
}
if m.contentFocused {
var cmd tea.Cmd
m.page, cmd = m.page.Update(msg)
return m, cmd
}
var cmd tea.Cmd
m.sidebar, cmd = m.sidebar.Update(msg)
return m, cmd
}
var notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd tea.Cmd
m.notif, notifCmd = m.notif.Update(msg)
m.modal, modalCmd = m.modal.Update(msg)
m.drawer, drawerCmd = m.drawer.Update(msg)
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
m.page, pageCmd = m.page.Update(msg)
return m, tea.Batch(notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd)
}
func (m Model) navWidth() int {
if m.hideNav && m.contentFocused {
return 0
}
return m.sidebarWidth
}
func (m Model) resize() (Model, tea.Cmd) {
if m.width <= 0 || m.height <= 0 {
return m, nil
}
m.help.SetWidth(m.width)
inner := style.ContentHeight(m.height - m.help.Height(m.focusedHelp()...))
navW := m.navWidth()
m.sidebar.SetSize(max(navW-2, 0), inner)
var cmd tea.Cmd
m.page, cmd = m.page.Update(tea.WindowSizeMsg{
Width: max(m.width-navW-2, 0),
Height: inner,
})
return m, cmd
}
func (m Model) focusedHelp() []key.Binding {
if !m.contentFocused {
return m.sidebar.HelpBindings()
}
if hp, ok := m.page.(HelpProvider); ok {
return hp.HelpBindings()
}
return nil
}
func (m Model) View() tea.View {
if m.width <= 0 || m.height <= 0 {
return tea.NewView("")
}
helpBar := m.help.View(m.focusedHelp()...)
bodyH := max(m.height-lipgloss.Height(helpBar), 0)
navW := m.navWidth()
body := style.RenderWithTitle(
panel(m.contentFocused), m.sidebar.Selected().Name, m.page.View().Content, m.width-navW, bodyH)
if navW > 0 {
left := style.RenderWithTitle(
panel(!m.contentFocused), "Menu", m.sidebar.View(), navW, bodyH)
body = lipgloss.JoinHorizontal(lipgloss.Top, left, body)
}
appView := lipgloss.JoinVertical(lipgloss.Left,
body,
helpBar,
)
view := tea.NewView(m.notif.Render(m.modal.Render(m.drawer.Render(appView))))
view.AltScreen = true
return view
}
func panel(focused bool) lipgloss.Style {
if focused {
return style.S.PanelFocused
}
return style.S.Panel
}
func main() {
if _, err := tea.NewProgram(NewModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-81
View File
@@ -1,81 +0,0 @@
// Package metrics is the fullapp example's second page: a spinner and a few
// gauges. It has no keys either, but unlike overview it runs a command, so
// it's what proves the shell keeps feeding ticks to a pane that doesn't have
// focus.
package metrics
import (
"fmt"
"charm.land/bubbles/v2/progress"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/style"
)
// gaugeWidth is how wide a bar renders, before the label in front of it.
const gaugeWidth = 30
type gauge struct {
name string
percent float64
}
// Model is the page. Values are hardcoded - this is a layout test, not a
// monitoring tool.
type Model struct {
spinner spinner.Model
bar progress.Model
gauges []gauge
width, height int
}
func New() Model {
bar := bubbles.NewProgress()
bar.SetWidth(gaugeWidth)
return Model{
spinner: bubbles.NewSpinner(spinner.WithSpinner(spinner.MiniDot)),
bar: bar,
gauges: []gauge{
{"cpu", 0.42},
{"memory", 0.71},
{"disk", 0.13},
},
}
}
// Init starts the spinner ticking. The shell returns it from its own Init,
// or the spinner never starts.
func (m Model) Init() tea.Cmd { return m.spinner.Tick }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = size.Width, size.Height
return m, nil
}
// Every other message goes to the spinner, whose own tick keeps it
// turning - including while another pane has focus.
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m Model) View() tea.View {
rows := []string{style.S.Bold.Render(m.spinner.View() + " collecting")}
for _, g := range m.gauges {
// ViewAs renders a given percentage without animating toward it,
// which is all a fixed value needs.
rows = append(rows, fmt.Sprintf("%-8s %s", g.name, m.bar.ViewAs(g.percent)))
}
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
}
-45
View File
@@ -1,45 +0,0 @@
// Package overview is the fullapp example's first page: the simplest pane
// there is - no keys, no commands, just text sized to whatever room the
// shell gives it.
package overview
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// Model is the page. It's an ordinary tea.Model: nothing about it knows it
// lives in a shell, and it never implements HelpBindings because it has no
// keys of its own to advertise.
type Model struct {
width, height int
}
func New() Model { return Model{} }
func (m Model) Init() tea.Cmd { return nil }
// Update only tracks the size the shell hands down as a tea.WindowSizeMsg,
// same as if the page were the whole program.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = size.Width, size.Height
}
return m, nil
}
func (m Model) View() tea.View {
body := lipgloss.JoinVertical(lipgloss.Center,
style.S.Bold.Render("Overview"),
"",
style.S.Faint.Render("tab focuses this pane, but there's"),
style.S.Faint.Render("nothing here to focus on."),
)
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(body))
}
-102
View File
@@ -1,102 +0,0 @@
// Package settings is the fullapp example's third page: a short list of
// toggles. It's the one page with keys of its own, so it's what proves the
// shell routes presses to the focused pane and lists that pane's bindings in
// the help bar.
package settings
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
type toggle struct {
name string
on bool
}
type keyMap struct {
Up key.Binding
Down key.Binding
Toggle key.Binding
}
// Model is the page. The cursor is a plain int: three lines don't need a
// list.Model behind them.
type Model struct {
keys keyMap
toggles []toggle
cursor int
width, height int
}
func New() Model {
return Model{
keys: keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Toggle: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")),
},
toggles: []toggle{
{name: "Nerd fonts", on: style.S.NerdFonts},
{name: "Notifications", on: true},
{name: "Telemetry", on: false},
},
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
// The shell only sends these while this pane has focus, so there's
// no focused check to make here.
switch {
case key.Matches(msg, m.keys.Up):
m.cursor = max(m.cursor-1, 0)
case key.Matches(msg, m.keys.Down):
m.cursor = min(m.cursor+1, len(m.toggles)-1)
case key.Matches(msg, m.keys.Toggle):
m.toggles[m.cursor].on = !m.toggles[m.cursor].on
}
}
return m, nil
}
func (m Model) View() tea.View {
rows := make([]string, len(m.toggles))
for i, t := range m.toggles {
cursor, box := " ", "[ ]"
if i == m.cursor {
cursor = "> "
}
if t.on {
box = "[x]"
}
line := cursor + box + " " + t.name
if i == m.cursor {
line = lipgloss.NewStyle().Foreground(style.S.Primary).Render(line)
}
rows[i] = line
}
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
}
// HelpBindings makes the page's keys show up in the shell's help bar while
// it has focus. It's the optional half of the contract: overview and metrics
// have no keys and don't implement it.
func (m Model) HelpBindings() []key.Binding {
return []key.Binding{m.keys.Up, m.keys.Down, m.keys.Toggle}
}
-134
View File
@@ -1,134 +0,0 @@
package sidebar
import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
)
type NavItem struct {
Icon string
Name string
}
func (n NavItem) Title() string {
if n.Icon == "" {
return n.Name
}
return n.Icon + " " + n.Name
}
func (n NavItem) Description() string { return "" }
func (n NavItem) FilterValue() string { return n.Name }
type SelectMsg struct {
Index int
Item NavItem
}
// BlurMsg is the sidebar asking to be given up: it goes out with the
// SelectMsg, on the grounds that picking an entry means you're done with the
// menu. Where focus lands instead is the host's call - the sidebar has no
// idea what else is on screen.
type BlurMsg struct{}
// blur is BlurMsg's command form.
func blur() tea.Msg { return BlurMsg{} }
type KeyMap struct {
Select key.Binding
}
func DefaultKeyMap() KeyMap {
return KeyMap{
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
}
}
type Model struct {
KeyMap KeyMap
list list.Model
items []NavItem
selected int
}
func New(items ...NavItem) Model {
d := bubbles.NewDefaultDelegate()
d.ShowDescription = false
d.SetSpacing(0)
l := bubbles.NewList(listItems(items), 0, 0)
l.SetDelegate(d)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetShowPagination(false)
l.SetFilteringEnabled(false)
l.DisableQuitKeybindings()
return Model{
KeyMap: DefaultKeyMap(),
list: l,
items: items,
}
}
func listItems(items []NavItem) []list.Item {
out := make([]list.Item, len(items))
for i, item := range items {
out[i] = item
}
return out
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if press, ok := msg.(tea.KeyPressMsg); ok && key.Matches(press, m.KeyMap.Select) {
// Only the interactive path asks for focus to move on. Select on its
// own doesn't, because the host also calls it to set the starting
// entry - which must not steal focus from anything.
return m, tea.Batch(m.Select(m.list.Index()), blur)
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m *Model) Select(index int) tea.Cmd {
if index < 0 || index >= len(m.items) {
return nil
}
m.selected = index
m.list.Select(index)
item := m.items[index]
return func() tea.Msg { return SelectMsg{Index: index, Item: item} }
}
func (m *Model) SetSize(width, height int) { m.list.SetSize(width, height) }
func (m Model) Selected() NavItem {
if m.selected < 0 || m.selected >= len(m.items) {
return NavItem{}
}
return m.items[m.selected]
}
func (m Model) SelectedIndex() int { return m.selected }
func (m Model) Cursor() int { return m.list.Index() }
func (m Model) View() string { return m.list.View() }
func (m Model) HelpBindings() []key.Binding {
return []key.Binding{
m.list.KeyMap.CursorUp,
m.list.KeyMap.CursorDown,
m.KeyMap.Select,
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/helpbar"
)
type keyMap struct {
Inc, Help, Quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
Inc: key.NewBinding(key.WithKeys("+"), key.WithHelp("+", "increment")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
}
}
type model struct {
help helpbar.Model
keys keyMap
n, w int
}
func newModel() model {
keys := defaultKeyMap()
return model{
keys: keys,
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.Quit)),
}
}
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.w = msg.Width
m.help.SetWidth(m.w)
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Inc):
m.n++
default:
m.help, _ = m.help.Update(msg)
}
}
return m, nil
}
func (m model) View() tea.View {
bar := m.help.View(m.keys.Inc)
body := lipgloss.Place(m.w, 1, lipgloss.Center, lipgloss.Top, fmt.Sprintf("count: %d", m.n))
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, bar))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+5 -61
View File
@@ -8,45 +8,14 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/modal" "github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/style"
) )
// confirmedMsg is what the confirmation modal reports back with. The app
// listens for it like any other message - it never holds a reference to the
// modal, and the modal never knows what confirming means.
type confirmedMsg struct{}
// confirm is the modal's content: a model, so it owns its own keys. The host
// no longer has to ask which modal is on top to know where "y" should go.
type confirm struct{}
func (c confirm) Init() tea.Cmd { return nil }
func (c confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "y" {
// Report back and close itself: Close() is a package-level command,
// so the content needs no reference to the modal.Model either.
return c, tea.Batch(
func() tea.Msg { return confirmedMsg{} },
modal.Close(),
)
}
return c, nil
}
func (c confirm) View() tea.View {
return tea.NewView("This can't be undone.\n\ny: confirm esc: cancel")
}
type model struct { type model struct {
m modal.Model m modal.Model
deleted bool
width, height int width, height int
} }
func newModel() model { func newModel() model { return model{m: modal.New()} }
return model{m: modal.New()}
}
func (m model) Init() tea.Cmd { return m.m.Init() } func (m model) Init() tea.Cmd { return m.m.Init() }
@@ -54,50 +23,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height m.width, m.height = msg.Width, msg.Height
return m, nil
case confirmedMsg:
m.deleted = true
return m, nil
case tea.KeyPressMsg: case tea.KeyPressMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c", "q": case "q":
return m, tea.Quit return m, tea.Quit
case "o":
case "m": return m, modal.Show("Hello", modal.Text("This is a modal.\n\nesc: close"))
return m, modal.Show("Delete file?", confirm{})
case "n":
if m.m.Open() {
return m, modal.Show("Really sure?", modal.Text("There's no undo for this one either."))
}
case "esc": case "esc":
if m.m.Open() { if m.m.Open() {
return m, modal.Close() return m, modal.Close()
} }
} }
} }
var cmd tea.Cmd var cmd tea.Cmd
m.m, cmd = m.m.Update(msg) m.m, cmd = m.m.Update(msg)
return m, cmd return m, cmd
} }
func (m model) View() tea.View { func (m model) View() tea.View {
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App") background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "o: open q: quit")
text := "Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the modal."
if m.deleted {
text = "File deleted - the modal's content reported back\nwith its own message, and closed itself."
}
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(text)
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"m: open modal n: open nested modal y: confirm esc: cancel q: quit")
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 := tea.NewView(m.m.Render(background))
view.AltScreen = true view.AltScreen = true
return view return view
+5 -51
View File
@@ -8,82 +8,36 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/notification" "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 { type model struct {
notif notification.Model notif notification.Model
posIdx int
width, height int width, height int
} }
func newModel() model { func newModel() model { return model{notif: notification.New()} }
return model{notif: notification.New(notification.WithPosition(positions[0].pos))}
}
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd { return m.notif.Init() }
return m.notif.Init()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyPressMsg: case tea.KeyPressMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c", "q": case "q":
return m, tea.Quit 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": case "s":
return m, notification.Show("Sticky", "Stays until you press d.", return m, notification.Show("Saved", "Config written to disk.", notification.Success)
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 var cmd tea.Cmd
m.notif, cmd = m.notif.Update(msg) m.notif, cmd = m.notif.Update(msg)
return m, cmd return m, cmd
} }
func (m model) View() tea.View { func (m model) View() tea.View {
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render( background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "s: show q: quit")
"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 := tea.NewView(m.notif.Render(background))
view.AltScreen = true view.AltScreen = true
return view return view
-235
View File
@@ -1,235 +0,0 @@
// Command sidebar is the whole app-shell pattern in one file: a sidebar on
// the left, an ordinary tea.Model on the right, a global help bar at the
// bottom, tab to move focus between the two. No layout package involved -
// lipgloss.JoinHorizontal/JoinVertical and style.RenderWithTitle already do
// all of it, and the panes stay plain tea.Models.
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/style"
)
// sidebarWidth is the sidebar's total width, border included.
const sidebarWidth = 24
// HelpProvider is the only contract in this pattern, and it's optional: a
// pane that implements it gets its own bindings listed in the global help
// bar while it's focused. A pane that doesn't just contributes nothing.
type HelpProvider interface {
HelpBindings() []key.Binding
}
// ---------------------------------------------------------------- shell keys
type keyMap struct {
Focus key.Binding
Help key.Binding
Quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
Focus: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch pane")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q", "quit")),
}
}
// ---------------------------------------------------------------- the shell
type model struct {
sidebar list.Model
content tea.Model
help helpbar.Model
keys keyMap
contentFocused bool
w, h int
}
func newModel() model {
items := []list.Item{page("Overview"), page("Metrics"), page("Settings")}
sidebar := bubbles.NewList(items, sidebarWidth-2, 0)
sidebar.SetShowTitle(false)
sidebar.SetShowStatusBar(false)
sidebar.SetShowHelp(false)
keys := defaultKeyMap()
return model{
sidebar: sidebar,
content: newCounter(),
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.Focus, keys.Quit)),
keys: keys,
}
}
func (m model) Init() tea.Cmd { return m.content.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height
return m.resize()
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
// Expanding the bar takes rows away from the panes.
m.help, _ = m.help.Update(msg)
return m.resize()
case key.Matches(msg, m.keys.Focus):
m.contentFocused = !m.contentFocused
return m, nil
}
// Only the focused pane sees key presses.
if m.contentFocused {
var cmd tea.Cmd
m.content, cmd = m.content.Update(msg)
return m, cmd
}
var cmd tea.Cmd
m.sidebar, cmd = m.sidebar.Update(msg)
return m, cmd
}
// Everything else (ticks, HTTP responses...) goes to both, so a blurred
// pane keeps working.
var sidebarCmd, contentCmd tea.Cmd
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
m.content, contentCmd = m.content.Update(msg)
return m, tea.Batch(sidebarCmd, contentCmd)
}
// bodyHeight is the height left for the two panes once the help bar has
// taken its share. resize and View both go through it so they can't disagree.
func (m model) bodyHeight() int {
return max(m.h-m.help.Height(m.focusedHelp()...), 0)
}
func (m model) resize() (model, tea.Cmd) {
if m.w <= 0 || m.h <= 0 {
return m, nil
}
m.help.SetWidth(m.w)
inner := style.ContentHeight(m.bodyHeight())
m.sidebar.SetSize(sidebarWidth-2, inner)
var cmd tea.Cmd
m.content, cmd = m.content.Update(tea.WindowSizeMsg{
Width: max(m.w-sidebarWidth-2, 0), Height: inner,
})
return m, cmd
}
// focusedHelp is the focused pane's own bindings, if it offers any.
func (m model) focusedHelp() []key.Binding {
if m.contentFocused {
if hp, ok := m.content.(HelpProvider); ok {
return hp.HelpBindings()
}
return nil
}
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
}
func (m model) View() tea.View {
if m.w <= 0 || m.h <= 0 {
return tea.NewView("")
}
helpBar := m.help.View(m.focusedHelp()...)
bodyH := m.bodyHeight()
left := style.RenderWithTitle(
panel(!m.contentFocused), "Menu", m.sidebar.View(), sidebarWidth, bodyH)
right := style.RenderWithTitle(
panel(m.contentFocused), "Content", m.content.View().Content, m.w-sidebarWidth, bodyH)
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
lipgloss.JoinHorizontal(lipgloss.Top, left, right),
helpBar,
))
view.AltScreen = true
return view
}
// panel picks the bordered panel style matching a pane's focus state.
func panel(focused bool) lipgloss.Style {
if focused {
return style.S.PanelFocused
}
return style.S.Panel
}
// ------------------------------------------------------- the right-hand pane
// counter is an ordinary tea.Model - nothing about it knows it's living in a
// shell. It implements HelpProvider purely to appear in the help bar.
type counter struct {
n int
w, h int
keys struct{ Inc, Dec key.Binding }
}
func newCounter() *counter {
c := &counter{}
c.keys.Inc = key.NewBinding(key.WithKeys("+", "k"), key.WithHelp("+/k", "increment"))
c.keys.Dec = key.NewBinding(key.WithKeys("-", "j"), key.WithHelp("-/j", "decrement"))
return c
}
func (c *counter) Init() tea.Cmd { return nil }
func (c *counter) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.w, c.h = msg.Width, msg.Height
case tea.KeyPressMsg:
switch {
case key.Matches(msg, c.keys.Inc):
c.n++
case key.Matches(msg, c.keys.Dec):
c.n--
}
}
return c, nil
}
func (c *counter) View() tea.View {
return tea.NewView(lipgloss.NewStyle().
Width(c.w).Height(c.h).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(fmt.Sprintf("count: %d", c.n)))
}
func (c *counter) HelpBindings() []key.Binding {
return []key.Binding{c.keys.Inc, c.keys.Dec}
}
// ------------------------------------------------------------- sidebar items
type page string
func (p page) Title() string { return string(p) }
func (p page) Description() string { return "" }
func (p page) FilterValue() string { return string(p) }
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/style"
)
type model struct{ focused bool }
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "q":
return m, tea.Quit
case "tab":
m.focused = !m.focused
}
}
return m, nil
}
func (m model) View() tea.View {
box := style.S.Panel
if m.focused {
box = style.S.PanelFocused
}
return tea.NewView(style.RenderWithTitle(box, "Panel", "tab: toggle focus q: quit", 30, 5))
}
func main() {
if _, err := tea.NewProgram(model{}).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+14 -70
View File
@@ -5,100 +5,44 @@ import (
"os" "os"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/tabs" "github.com/anotherhadi/ilovetui/tabs"
) )
// pane is a minimal tabs.Tab implementation. It keeps its own counter to type pane string
// 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 { func (p pane) Init() tea.Cmd { return nil }
return pane{name: name} func (p pane) Update(tea.Msg) (tabs.Tab, tea.Cmd) { return p, nil }
} func (p pane) View() string { return string(p) }
func (p pane) Init() tea.Cmd { type model struct{ tabs tabs.Model }
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 { func newModel() model {
items := []tabs.Item{ items := []tabs.Item{
{Title: "Lip Gloss", Model: newPane("Lip Gloss")}, {Title: "First", Model: pane("first content")},
{Title: "Blush", Model: newPane("Blush")}, {Title: "Second", Model: pane("second content")},
{Title: "Eye Shadow", Model: newPane("Eye Shadow")}, {Title: "Third", Model: pane("third content")},
{Title: "Mascara", Model: newPane("Mascara")},
{Title: "Foundation", Model: newPane("Foundation")},
} }
return model{tabs: tabs.New(items)} return model{tabs: tabs.New(items)}
} }
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd { return m.tabs.Init() }
return m.tabs.Init()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "q" {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" || msg.String() == "q" {
return m, tea.Quit return m, tea.Quit
} }
case tea.WindowSizeMsg: if s, ok := msg.(tea.WindowSizeMsg); ok {
// Size the tabs component to fill the terminal, net of docStyle's own m.tabs.SetSize(s.Width, s.Height)
// frame. The tab bar itself keeps its natural width; Content stretches. msg = tea.WindowSizeMsg{Width: m.tabs.ContentWidth(), Height: m.tabs.ContentHeight()}
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 var cmd tea.Cmd
m.tabs, cmd = m.tabs.Update(msg) m.tabs, cmd = m.tabs.Update(msg)
return m, cmd return m, cmd
} }
func (m model) View() tea.View { func (m model) View() tea.View {
view := tea.NewView(docStyle.Render(m.tabs.View())) view := tea.NewView(m.tabs.View())
view.AltScreen = true view.AltScreen = true
return view return view
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
description = "A minimal Go library that provides a shared Base16color theme for terminal UIs built with bubbletea and lipgloss."; description = "A shared Base16 theme, a themed wrapper around every official component, and a small set of custom Bubble Tea components, in one Go module, so every TUI built with it shares one config file and looks consistent.";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+10 -88
View File
@@ -1,91 +1,13 @@
# Helpbar # helpbar
A responsive help bar: one line of key bindings that expands, on demand, into a multi-column view reflowed to use as many columns as the available width allows. A responsive help bar: one line of key bindings that expands, on toggle, into a multi-column view
reflowed to use as many columns as the available width allows.
## Quick start - Bindings render in order: the `WithToggle` binding first, then `WithGlobal` bindings (set once),
then contextual bindings passed to `View` at render time.
- Disabled bindings (`key.Binding.SetEnabled(false)`) are dropped before layout, so the reflow never
budgets width for something that won't be drawn.
- `Height(contextual...)` always matches `lipgloss.Height` of `View` for the same arguments, so a
host can reserve exactly the right amount of space. An empty bar renders `""` and takes 0 rows.
```go See `examples/helpbar`.
import "github.com/anotherhadi/ilovetui/helpbar"
type model struct {
help helpbar.Model
keys keyMap
h int
}
func newModel() model {
keys := defaultKeyMap()
return model{
help: helpbar.New(
helpbar.WithToggle(keys.Help), // '?' expands/collapses
helpbar.WithGlobal(keys.Focus, keys.Quit), // always shown
),
keys: keys,
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.h = msg.Height
m.help.SetWidth(msg.Width)
case tea.KeyPressMsg:
m.help, _ = m.help.Update(msg) // flips ShowAll on the toggle binding
}
return m, nil
}
func (m model) View() tea.View {
bar := m.help.View(m.focused().HelpBindings()...)
body := renderBody(m.h - lipgloss.Height(bar))
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, bar))
}
```
## Global vs. contextual bindings
Bindings come from two places, and they render in this order:
1. The `WithToggle` binding, so the way to expand the bar always leads.
2. `WithGlobal` bindings - what your app reserves for itself (quit, switch pane...), set once.
3. Contextual bindings, passed to `View` at render time.
Contextual bindings are meant to change from render to render, so the bar can track whatever component currently has focus. Nothing in the package knows what "focus" means for your app - you just hand it a different slice:
```go
func (m model) focusedHelp() []key.Binding {
if m.contentFocused {
return m.content.HelpBindings()
}
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
}
```
Disabled bindings (`key.Binding.SetEnabled(false)`) are dropped before layout, so the reflow never
budgets width for something that won't be drawn.
## Reserving room for the bar
The bar's height depends on its content and on whether it's expanded, so ask it rather than assuming
a fixed number of rows:
```go
body := m.height - m.help.Height(contextual...)
```
`Height` is exactly `lipgloss.Height` of what `View` returns for the same arguments, so the two can
never disagree about where the bar begins. An empty bar (no bindings, or no width set) renders `""`
and takes zero rows.
## Styling
Defaults come from the shared `style` theme. Override with `WithStyles`:
```go
helpbar.New(helpbar.WithStyles(myStyles)) // help.Styles from charm.land/bubbles/v2/help
```
## Examples
- `examples/sidebar` uses it as an app-wide bar tracking the focused pane.
- `examples/app` full app with a single help bar
+17 -70
View File
@@ -1,34 +1,18 @@
// Package helpbar is a responsive help bar: a single line of key bindings
// that expands, on demand, into a multi-column view reflowed to use as many
// columns as the available width allows.
//
// It has no dependency on any particular layout or container - it's just a
// component that takes a width and some bindings and returns a string, so it
// works as well under a plain lipgloss.JoinVertical as anywhere else.
//
// Bindings come from two places. Global ones (quit, toggle help, whatever
// your app reserves for itself) are set once via WithGlobal and always shown
// first. Contextual ones are passed to View at render time, so the bar can
// track whatever component currently has focus:
//
// bar := m.help.View(m.focused().HelpBindings()...)
// body := m.height - lipgloss.Height(bar)
package helpbar package helpbar
import ( import (
"strings"
"charm.land/bubbles/v2/help" "charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/anotherhadi/ilovetui/bubbles" "github.com/anotherhadi/ilovetui/bubbles"
) )
// Model is a help bar. The zero value isn't usable - build one with New.
type Model struct { type Model struct {
// ShowAll switches between the one-line short view and the full
// multi-column view. Set it directly, or let WithToggle bind a key to
// it and have Update flip it for you.
ShowAll bool ShowAll bool
help help.Model help help.Model
@@ -37,30 +21,20 @@ type Model struct {
width int width int
} }
// Option configures a Model at construction.
type Option func(*Model) type Option func(*Model)
// WithGlobal sets the bindings shown before the contextual ones on every
// render - the keys your app reserves for itself regardless of what's
// focused.
func WithGlobal(bindings ...key.Binding) Option { func WithGlobal(bindings ...key.Binding) Option {
return func(m *Model) { m.global = bindings } return func(m *Model) { m.global = bindings }
} }
// WithToggle makes Update flip ShowAll when b matches, and lists b ahead of
// every other binding (including WithGlobal's) so the way to expand the bar
// is always the first thing shown. Without it, Update ignores key presses
// and toggling ShowAll is entirely up to the caller.
func WithToggle(b key.Binding) Option { func WithToggle(b key.Binding) Option {
return func(m *Model) { m.toggle = b } return func(m *Model) { m.toggle = b }
} }
// WithStyles overrides the themed default styles.
func WithStyles(s help.Styles) Option { func WithStyles(s help.Styles) Option {
return func(m *Model) { m.help.Styles = s } return func(m *Model) { m.help.Styles = s }
} }
// New builds a help bar themed from the shared style package.
func New(opts ...Option) Model { func New(opts ...Option) Model {
m := Model{help: bubbles.NewHelp()} m := Model{help: bubbles.NewHelp()}
for _, opt := range opts { for _, opt := range opts {
@@ -69,20 +43,13 @@ func New(opts ...Option) Model {
return m return m
} }
// SetWidth sets the width the bar renders within. Nothing is shown until
// this is called with a positive value - typically from your
// tea.WindowSizeMsg handler.
func (m *Model) SetWidth(w int) { func (m *Model) SetWidth(w int) {
m.width = w m.width = w
m.help.SetWidth(w) m.help.SetWidth(w)
} }
// Width returns the width last given to SetWidth.
func (m Model) Width() int { return m.width } func (m Model) Width() int { return m.width }
// Update flips ShowAll when a key press matches WithToggle's binding. It's
// optional: a Model built without WithToggle ignores every message, and you
// can always set ShowAll yourself instead.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyPressMsg); ok && key.Matches(keyMsg, m.toggle) { if keyMsg, ok := msg.(tea.KeyPressMsg); ok && key.Matches(keyMsg, m.toggle) {
m.ShowAll = !m.ShowAll m.ShowAll = !m.ShowAll
@@ -90,27 +57,28 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// View renders the bar: one line while ShowAll is false, otherwise the full
// multi-column view. contextual bindings follow the toggle and global ones,
// and are meant to change from render to render as focus moves.
//
// Returns "" when no width has been set or nothing is left to show, in which
// case the bar occupies no rows at all.
func (m Model) View(contextual ...key.Binding) string { func (m Model) View(contextual ...key.Binding) string {
bindings := m.bindings(contextual) bindings := m.bindings(contextual)
if len(bindings) == 0 || m.width <= 0 { if len(bindings) == 0 || m.width <= 0 {
return "" return ""
} }
var view string
if !m.ShowAll { if !m.ShowAll {
return m.help.ShortHelpView(bindings) view = m.help.ShortHelpView(bindings)
} else {
view = m.help.FullHelpView(m.columns(bindings))
} }
return m.help.FullHelpView(m.columns(bindings)) return m.clampWidth(view)
}
func (m Model) clampWidth(view string) string {
lines := strings.Split(view, "\n")
for i, line := range lines {
lines[i] = ansi.Truncate(line, m.width, "")
}
return strings.Join(lines, "\n")
} }
// Height is the number of rows View would take for the same bindings. Use it
// to work out how much room is left for the rest of your UI. View is the
// source of truth: this is exactly lipgloss.Height of its output, so the two
// can't disagree about where the bar begins.
func (m Model) Height(contextual ...key.Binding) int { func (m Model) Height(contextual ...key.Binding) int {
view := m.View(contextual...) view := m.View(contextual...)
if view == "" { if view == "" {
@@ -119,10 +87,6 @@ func (m Model) Height(contextual ...key.Binding) int {
return lipgloss.Height(view) return lipgloss.Height(view)
} }
// bindings is the full ordered list: the toggle first (so the way to expand
// the bar always leads), then global, then contextual. Disabled bindings are
// dropped here so column reflow never budgets width for something
// help.FullHelpView will skip anyway.
func (m Model) bindings(contextual []key.Binding) []key.Binding { func (m Model) bindings(contextual []key.Binding) []key.Binding {
all := make([]key.Binding, 0, 1+len(m.global)+len(contextual)) all := make([]key.Binding, 0, 1+len(m.global)+len(contextual))
if m.toggle.Enabled() { if m.toggle.Enabled() {
@@ -136,13 +100,6 @@ func (m Model) bindings(contextual []key.Binding) []key.Binding {
return all return all
} }
// columns reflows bindings into as many columns as fit within the bar's
// width, which is the same as using as few rows as possible. It walks row
// counts upward and returns the first arrangement that fits, so the result is
// the widest (fewest-rows) layout the width allows.
//
// help.FullHelpView fills each column top to bottom, so a group is a column,
// not a row.
func (m Model) columns(bindings []key.Binding) [][]key.Binding { func (m Model) columns(bindings []key.Binding) [][]key.Binding {
if m.width <= 0 { if m.width <= 0 {
return [][]key.Binding{bindings} return [][]key.Binding{bindings}
@@ -153,26 +110,16 @@ func (m Model) columns(bindings []key.Binding) [][]key.Binding {
return groups return groups
} }
} }
// Everything in one column: the narrowest arrangement possible. It may
// still overflow, in which case help.FullHelpView truncates as usual.
return chunkColumns(bindings, len(bindings)) return chunkColumns(bindings, len(bindings))
} }
// renderedWidth measures what FullHelpView would actually produce for
// groups, by asking it - rather than reimplementing its column and separator
// arithmetic here, which would silently drift the moment upstream changes a
// style or a separator.
//
// The width is zeroed first because that's what disables FullHelpView's own
// truncation (see its shouldAddItem): at width 0 it lays every column out in
// full, which is the untruncated width this needs to measure.
func (m Model) renderedWidth(groups [][]key.Binding) int { func (m Model) renderedWidth(groups [][]key.Binding) int {
unbounded := m.help unbounded := m.help
unbounded.SetWidth(0) unbounded.SetWidth(0)
return lipgloss.Width(unbounded.FullHelpView(groups)) return lipgloss.Width(unbounded.FullHelpView(groups))
} }
// chunkColumns slices bindings into consecutive groups of at most rows each.
func chunkColumns(bindings []key.Binding, rows int) [][]key.Binding { func chunkColumns(bindings []key.Binding, rows int) [][]key.Binding {
if rows < 1 { if rows < 1 {
rows = 1 rows = 1
+60
View File
@@ -0,0 +1,60 @@
package minsize
import (
"fmt"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
type Model struct {
MinWidth int
MinHeight int
Style lipgloss.Style
}
type Option func(*Model)
func WithStyle(s lipgloss.Style) Option {
return func(m *Model) { m.Style = s }
}
func New(minWidth, minHeight int, opts ...Option) Model {
m := Model{
MinWidth: minWidth,
MinHeight: minHeight,
Style: lipgloss.NewStyle().Foreground(style.S.Muted),
}
for _, opt := range opts {
opt(&m)
}
return m
}
func (m Model) Fits(width, height int) bool {
return width >= m.MinWidth && height >= m.MinHeight
}
func (m Model) View(width, height int) string {
msg := m.Style.Render(m.message(width, height))
if width < 1 || height < 1 {
return msg
}
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, msg)
}
func (m Model) message(width, height int) string {
tooNarrow := width < m.MinWidth
tooShort := height < m.MinHeight
switch {
case tooNarrow && tooShort:
return fmt.Sprintf("minimum is %dx%d", m.MinWidth, m.MinHeight)
case tooNarrow:
return fmt.Sprintf("minimum width: %d", m.MinWidth)
case tooShort:
return fmt.Sprintf("minimum height: %d", m.MinHeight)
default:
return ""
}
}
+11 -133
View File
@@ -1,137 +1,15 @@
# modal # modal
A centered popup box on top of a dimmed background, triggered from anywhere in a bubbletea A centered popup box on top of a dimmed background, triggered from anywhere in a bubbletea program
program via an exported `tea.Msg` (`ShowMsg`/`Show`) rather than a direct reference to the via an exported `tea.Msg` (`ShowMsg`/`Show`), not a direct reference to the `Model` that renders it.
`Model` that ends up rendering it - standard Elm architecture, no IPC between processes. Composites over an already-rendered string, so it makes no assumption about how the host builds
that string.
It composites over an already-rendered string, so it makes no assumption about how the host - A modal's content is a `tea.Model`, not a string: it gets `Init`/`Update` while it's on top, and
builds that string: the same `Model` works whatever the host uses to lay out its main content. can report back with its own `tea.Msg` (see `modal.Text` for a plain, non-interactive content).
- The stack is a plain LIFO with no identity. Only the topmost modal is updated; `Close()` always
closes it.
- Showing a second modal while one is open pushes it on top, nesting confirmations naturally.
- `WithMaxWidth`/`WithMaxHeight` cap growth; a modal narrower than the cap shrinks to fit instead.
## Quick start See `examples/modal`.
```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?", modal.Text("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.
## Content is a model
A modal's body is a `tea.Model`, not a string. While a modal is on top of the stack it gets every
message the `modal.Model` receives, its `Init` runs when it opens, and its commands come back out
- so it can hold a form, a list, or a confirmation that reports its answer with a `tea.Msg` of
its own, which the component that opened it listens for:
```go
type confirmedMsg struct{ path string }
func (c confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "y" {
path := c.path
return c, func() tea.Msg { return confirmedMsg{path} }
}
return c, nil
}
// somewhere else
return m, modal.Show("Delete file?", confirm{path: p})
```
Only the topmost modal is updated: everything beneath it is dimmed and frozen until the modals
above it close.
For a modal with nothing to interact with, `modal.Text` wraps a plain string:
```go
return m, modal.Show("About", modal.Text("v1.0\n\nesc to close"))
```
The box shrinks to fit whatever the content draws, so a content that wants a specific size sets
it on itself - the modal only ever sees the rendered result.
## Showing and closing
```go
return m, modal.Show("Delete file?", confirm{})
return m, modal.Close() // close the topmost modal
```
The stack is a plain LIFO, with no identity: a modal is closed by being on top, never by being
named. There is nothing to tag a modal with, and nothing that can target one in the middle of the
stack - the topmost is both the only one that receives messages and the only one `Close` can
reach, so the two rules never disagree.
- `modal.Open()` reports whether at least one modal is currently shown - handy for a host that
wants to route key presses to the modal instead of its normal UI while one is open. Note that a
host doing this also makes it impossible for a key to open a second modal, which is what keeps
the stack shallow without any bookkeeping.
- A content model closes its own modal by returning `modal.Close()`, since it only ever runs while
it is the topmost one.
## Stacking
Modals stack: showing a second one while the first is still open pushes it on top, dimming both
the background and the first modal to the same flat color. Closing the top one reveals the one
beneath, still in full color. This is what lets a "delete?" confirmation open a nested "really
sure?" modal without any special-casing - the nested one is pushed by the content on top, the only
one able to act.
## Styling
```go
m := modal.New(modal.WithMaxWidth(60), modal.WithMaxHeight(20), modal.WithStyles(myStyles))
return m, modal.Show("Title", modal.Text("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.
+2 -36
View File
@@ -2,23 +2,16 @@ package modal
import tea "charm.land/bubbletea/v2" import tea "charm.land/bubbletea/v2"
// Modal is one popup on the stack.
type Modal struct { type Modal struct {
Title string Title string
// Content is the modal's body: a full model, updated and rendered by
// the modal.Model while it's on top of the stack. Wrap a plain string
// with Text for a modal with nothing to interact with.
Content tea.Model Content tea.Model
// Style, if non-nil, overrides the Model's default Styles for this
// modal alone.
Style *Styles Style *Styles
} }
// ModalOption configures a Modal built by Show.
type ModalOption func(*Modal) type ModalOption func(*Modal)
// 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 { func WithModalStyle(s Styles) ModalOption {
return func(mo *Modal) { mo.Style = &s } return func(mo *Modal) { mo.Style = &s }
} }
@@ -31,50 +24,23 @@ func newModal(title string, content tea.Model, opts ...ModalOption) Modal {
return 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 } type ShowMsg struct{ Modal Modal }
// Show returns a tea.Cmd that opens a new modal on top of the stack. content
// is a model, so a modal can hold anything a pane can - a form, a list, a
// confirmation that reports back with its own tea.Msg:
//
// return m, modal.Show("Delete file?", modal.Text("This can't be undone."))
// return m, modal.Show("Rename", newRenameForm(path))
//
// The content's Init runs when the modal opens, and it receives every message
// while it's the topmost modal (see Model.Update).
func Show(title string, content tea.Model, opts ...ModalOption) tea.Cmd { func Show(title string, content tea.Model, opts ...ModalOption) tea.Cmd {
mo := newModal(title, content, opts...) mo := newModal(title, content, opts...)
return func() tea.Msg { return ShowMsg{Modal: mo} } return func() tea.Msg { return ShowMsg{Modal: mo} }
} }
// DismissMsg closes the topmost modal. The stack is a plain LIFO: a modal is
// closed by being on top, never by being named.
type DismissMsg struct{} type DismissMsg struct{}
// Close returns a tea.Cmd that closes the topmost modal - which is also the
// only one that can act (see Model.Update), so a content model closes itself
// by returning it:
//
// return c, modal.Close()
func Close() tea.Cmd { func Close() tea.Cmd {
return func() tea.Msg { return DismissMsg{} } return func() tea.Msg { return DismissMsg{} }
} }
// text is a model wrapping a fixed string: a modal body with nothing to
// update.
type text string type text string
func (t text) Init() tea.Cmd { return nil } func (t text) Init() tea.Cmd { return nil }
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil } func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
func (t text) View() tea.View { return tea.NewView(string(t)) } func (t text) View() tea.View { return tea.NewView(string(t)) }
// Text wraps a plain string as modal content, for the common modal that has
// nothing to interact with:
//
// modal.Show("About", modal.Text("v1.0\n\nesc to close"))
func Text(s string) tea.Model { return text(s) } func Text(s string) tea.Model { return text(s) }
-42
View File
@@ -1,25 +1,7 @@
// 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
// makes no assumption at all about how the host builds that string: the same
// Model works whatever the host uses to lay out its main content (see
// Model.Render and Model.View).
//
// A modal's content is a model, not a string: it is updated while it's on
// top of the stack, so it can hold anything a pane can - a form, a list, a
// confirmation reporting its answer back with its own tea.Msg. See Show and
// Text.
package modal package modal
import tea "charm.land/bubbletea/v2" import tea "charm.land/bubbletea/v2"
// Model holds the currently open modals (a stack: the most recently shown
// is drawn on top, everything beneath it - the background and any earlier
// modal - dimmed, see Render) and the rendering config (max size, styles)
// they share. Build one with New.
type Model struct { type Model struct {
modals []Modal modals []Modal
maxWidth int maxWidth int
@@ -27,30 +9,20 @@ type Model struct {
styles Styles styles Styles
} }
// Option configures a Model at construction. See WithMaxWidth,
// WithMaxHeight, WithStyles.
type Option func(*Model) 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 { func WithMaxWidth(w int) Option {
return func(m *Model) { m.maxWidth = w } 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 { func WithMaxHeight(h int) Option {
return func(m *Model) { m.maxHeight = h } return func(m *Model) { m.maxHeight = h }
} }
// WithStyles overrides the default styles (see DefaultStyles).
func WithStyles(s Styles) Option { func WithStyles(s Styles) Option {
return func(m *Model) { m.styles = s } return func(m *Model) { m.styles = s }
} }
// New builds a Model. Defaults: a 60x20 max size, DefaultStyles.
func New(opts ...Option) Model { func New(opts ...Option) Model {
m := Model{ m := Model{
maxWidth: 60, maxWidth: 60,
@@ -75,13 +47,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m.updateTop(msg) return m.updateTop(msg)
} }
// updateTop forwards msg to the topmost modal's content - the only one the
// user can interact with, everything beneath it being dimmed (see Render). A
// modal deeper in the stack is frozen until the ones above it close.
//
// This is what lets modal content be a real model: it gets the key presses,
// the ticks and the results of its own commands, and can report back to the
// rest of the program with a tea.Msg of its own.
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) { func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
i := len(m.modals) - 1 i := len(m.modals) - 1
if i < 0 || m.modals[i].Content == nil { if i < 0 || m.modals[i].Content == nil {
@@ -92,19 +57,13 @@ func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
return m, cmd return m, cmd
} }
// Open reports whether at least one modal is currently shown - handy for a
// host that wants to route key presses to the modal (e.g. esc to dismiss,
// enter to confirm) instead of its normal UI while one is open.
func (m Model) Open() bool { return len(m.modals) > 0 } func (m Model) Open() bool { return len(m.modals) > 0 }
// show pushes a modal on top of the stack and returns its content's Init - a
// modal's body starts the same way any other model does.
func (m Model) show(mo Modal) (Model, tea.Cmd) { func (m Model) show(mo Modal) (Model, tea.Cmd) {
m.modals = append(m.modals, mo) m.modals = append(m.modals, mo)
return m, initContent(mo) return m, initContent(mo)
} }
// initContent is mo's content's Init, or nil for a modal without content.
func initContent(mo Modal) tea.Cmd { func initContent(mo Modal) tea.Cmd {
if mo.Content == nil { if mo.Content == nil {
return nil return nil
@@ -112,7 +71,6 @@ func initContent(mo Modal) tea.Cmd {
return mo.Content.Init() return mo.Content.Init()
} }
// pop closes the topmost modal (see Close).
func (m Model) pop() Model { func (m Model) pop() Model {
if len(m.modals) == 0 { if len(m.modals) == 0 {
return m return m
+2 -41
View File
@@ -10,17 +10,8 @@ import (
"github.com/anotherhadi/ilovetui/style" "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 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 { func (m Model) Render(background string) string {
if len(m.modals) == 0 { if len(m.modals) == 0 {
return background return background
@@ -37,14 +28,10 @@ func (m Model) Render(background string) string {
return result 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 { func (m Model) View(width, height int) string {
return m.Render(blank(width, height)) 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 { func (m Model) renderOne(mo Modal, background string, w, h int) string {
s := m.styles s := m.styles
if mo.Style != nil { if mo.Style != nil {
@@ -55,26 +42,13 @@ func (m Model) renderOne(mo Modal, background string, w, h int) string {
bw, bh := lipgloss.Width(box), lipgloss.Height(box) bw, bh := lipgloss.Width(box), lipgloss.Height(box)
x, y := max((w-bw)/2, 0), max((h-bh)/2, 0) x, y := max((w-bw)/2, 0), max((h-bh)/2, 0)
compositor := lipgloss.NewCompositor( return style.Overlay(dim(background, s.DimColor), box, x, y)
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 { func dim(s string, c color.Color) string {
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s)) 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 { func (m Model) renderBox(mo Modal, s Styles, bgW, bgH int) string {
maxW := effectiveMax(m.maxWidth, bgW-2*margin) maxW := effectiveMax(m.maxWidth, bgW-2*margin)
maxH := effectiveMax(m.maxHeight, bgH-2*margin) maxH := effectiveMax(m.maxHeight, bgH-2*margin)
@@ -83,16 +57,12 @@ func (m Model) renderBox(mo Modal, s Styles, bgW, bgH int) string {
inner := contentWidth(body, mo.Title, maxW) inner := contentWidth(body, mo.Title, maxW)
content := s.Content.Width(inner).Render(body) content := s.Content.Width(inner).Render(body)
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2) boxWidth := inner + 4
boxHeight := min(lipgloss.Height(content)+2, maxH) boxHeight := min(lipgloss.Height(content)+2, maxH)
return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight) return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight)
} }
// contentView is the modal body's rendered string, or "" for a modal without
// content. The box shrinks to fit whatever the content model draws, so a
// content that wants a specific size sets it on itself - the modal only ever
// sees the result.
func contentView(mo Modal) string { func contentView(mo Modal) string {
if mo.Content == nil { if mo.Content == nil {
return "" return ""
@@ -100,16 +70,12 @@ func contentView(mo Modal) string {
return mo.Content.View().Content return mo.Content.View().Content
} }
// contentWidth is the modal's inner (border/padding excluded) width: its
// natural size (long enough for the widest line of title/content), capped
// at maxWidth.
func contentWidth(body, title string, maxWidth int) int { func contentWidth(body, title string, maxWidth int) int {
natural := max(naturalWidth(body), lipgloss.Width(title), 1) natural := max(naturalWidth(body), lipgloss.Width(title), 1)
capped := max(maxWidth-4, 1) capped := max(maxWidth-4, 1)
return min(natural, capped) return min(natural, capped)
} }
// naturalWidth is the width of content's widest line.
func naturalWidth(content string) int { func naturalWidth(content string) int {
w := 0 w := 0
for _, line := range strings.Split(content, "\n") { for _, line := range strings.Split(content, "\n") {
@@ -120,11 +86,6 @@ func naturalWidth(content string) int {
return w 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 { func effectiveMax(configured, fits int) int {
if fits < 1 { if fits < 1 {
fits = 1 fits = 1
-11
View File
@@ -8,11 +8,6 @@ import (
"github.com/anotherhadi/ilovetui/style" "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 { type Styles struct {
Border lipgloss.Style Border lipgloss.Style
Title lipgloss.Style Title lipgloss.Style
@@ -20,12 +15,6 @@ type Styles struct {
DimColor color.Color 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 { func DefaultStyles() Styles {
return Styles{ return Styles{
Border: style.S.PanelFocused.Padding(0, 1), Border: style.S.PanelFocused.Padding(0, 1),
+10 -99
View File
@@ -1,103 +1,14 @@
# notification # notification
Toast-style notifications, triggered from anywhere in a bubbletea program via an exported Toast notifications, triggered from anywhere in a bubbletea program via an exported `tea.Msg`
`tea.Msg` (`ShowMsg`/`Show`) rather than a direct reference to the `Model` that ends up rendering (`ShowMsg`/`Show`), not a direct reference to the `Model` that renders them. Composites over an
them - standard Elm architecture, no IPC between processes. already-rendered string, so it makes no assumption about how the host builds that string.
It composites over an already-rendered string, so it makes no assumption about how the host - Four kinds: `Info`, `Success`, `Warning`, `Error`, each with its own `style.S` color preset.
builds that string: the same `Model` works whatever the host uses to lay out its main content. - Auto-dismisses after `DefaultDuration` (3s) unless shown with `WithDuration(0)`, which makes it
sticky; a sticky toast needs `WithID` so `Dismiss(id)` can remove it later.
- Six anchors (`Top`, `TopLeft`, `TopRight`, `Bottom`, `BottomLeft`, `BottomRight`). Toasts stack
along the anchored edge, newest closest to it.
- `WithMaxWidth` caps growth; a toast narrower than the cap shrinks to fit instead.
## Quick start See `examples/notification`.
```go
import (
tea "charm.land/bubbletea/v2"
"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.
-22
View File
@@ -1,11 +1,3 @@
// 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
// makes no assumption at all about how the host builds that string: the same
// Model works whatever the host uses to lay out its main content (see
// Model.Render and Model.View).
package notification package notification
import ( import (
@@ -15,8 +7,6 @@ import (
tea "charm.land/bubbletea/v2" 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 { type Model struct {
toasts []Toast toasts []Toast
nextID int nextID int
@@ -25,30 +15,20 @@ type Model struct {
styles Styles styles Styles
} }
// Option configures a Model at construction. See WithPosition, WithMaxWidth,
// WithStyles.
type Option func(*Model) type Option func(*Model)
// WithPosition sets which edge/corner the toast stack anchors to. TopRight
// by default.
func WithPosition(p Position) Option { func WithPosition(p Position) Option {
return func(m *Model) { m.position = p } 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 { func WithMaxWidth(w int) Option {
return func(m *Model) { m.maxWidth = w } return func(m *Model) { m.maxWidth = w }
} }
// WithStyles overrides the default per-Kind styles (see DefaultStyles).
func WithStyles(s Styles) Option { func WithStyles(s Styles) Option {
return func(m *Model) { m.styles = s } return func(m *Model) { m.styles = s }
} }
// New builds a Model. Defaults: TopRight, a 40-cell max width, DefaultStyles.
func New(opts ...Option) Model { func New(opts ...Option) Model {
m := Model{ m := Model{
position: TopRight, position: TopRight,
@@ -75,8 +55,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, 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) { func (m Model) show(t Toast) (Model, tea.Cmd) {
if t.ID == "" { if t.ID == "" {
t.ID = fmt.Sprintf("toast-%d", m.nextID) t.ID = fmt.Sprintf("toast-%d", m.nextID)
-10
View File
@@ -1,8 +1,5 @@
package notification 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 type Position int
const ( const (
@@ -14,12 +11,8 @@ const (
BottomRight 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 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) { func placement(pos Position, w, h, sw, sh int) (x, y int) {
switch pos { switch pos {
case Top: case Top:
@@ -50,9 +43,6 @@ func placement(pos Position, w, h, sw, sh int) (x, y int) {
return x, y 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 { func (pos Position) anchoredTop() bool {
return pos == Top || pos == TopLeft || pos == TopRight return pos == Top || pos == TopLeft || pos == TopRight
} }
+2 -46
View File
@@ -8,14 +8,6 @@ import (
"github.com/anotherhadi/ilovetui/style" "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 { func (m Model) Render(background string) string {
if len(m.toasts) == 0 { if len(m.toasts) == 0 {
return background return background
@@ -32,30 +24,13 @@ func (m Model) Render(background string) string {
sw, sh := lipgloss.Width(stack), lipgloss.Height(stack) sw, sh := lipgloss.Width(stack), lipgloss.Height(stack)
x, y := placement(m.position, w, h, sw, sh) x, y := placement(m.position, w, h, sw, sh)
// Canvas.Compose(layer) alone ignores the layer's X/Y and draws it across return style.Overlay(background, stack, x, y)
// 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 { func (m Model) View(width, height int) string {
return m.Render(blank(width, height)) 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 { func (m Model) renderStack(maxWidth int) string {
ordered := m.orderedToasts() ordered := m.orderedToasts()
parts := make([]string, 0, len(ordered)*2-1) parts := make([]string, 0, len(ordered)*2-1)
@@ -68,9 +43,6 @@ func (m Model) renderStack(maxWidth int) string {
return lipgloss.JoinVertical(stackAlign(m.position), parts...) 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 { func (m Model) orderedToasts() []Toast {
if !m.position.anchoredTop() { if !m.position.anchoredTop() {
return m.toasts return m.toasts
@@ -93,24 +65,18 @@ func stackAlign(pos Position) lipgloss.Position {
} }
} }
// 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 { func (m Model) renderToast(t Toast, maxWidth int) string {
k := m.styles.forKind(t) k := m.styles.forKind(t)
inner := contentWidth(t, maxWidth) inner := contentWidth(t, maxWidth)
message := k.Message.Width(inner).Render(t.Message) message := k.Message.Width(inner).Render(t.Message)
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2) boxWidth := inner + 4
boxHeight := lipgloss.Height(message) + 2 boxHeight := lipgloss.Height(message) + 2
return style.RenderWithTitle(k.Border, k.Title.Render(t.Title), message, boxWidth, boxHeight) 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 { func contentWidth(t Toast, maxWidth int) int {
natural := max(lipgloss.Width(t.Title), lipgloss.Width(t.Message), 1) natural := max(lipgloss.Width(t.Title), lipgloss.Width(t.Message), 1)
if maxWidth <= 0 { if maxWidth <= 0 {
@@ -120,12 +86,6 @@ func contentWidth(t Toast, maxWidth int) int {
return min(natural, capped) 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 { func effectiveMaxWidth(configured, bgWidth int) int {
fits := max(bgWidth-2*margin, 1) fits := max(bgWidth-2*margin, 1)
if configured > 0 && configured < fits { if configured > 0 && configured < fits {
@@ -134,10 +94,6 @@ func effectiveMaxWidth(configured, bgWidth int) int {
return fits 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 { func clipToHeight(stack string, maxHeight int, anchoredTop bool) string {
lines := strings.Split(stack, "\n") lines := strings.Split(stack, "\n")
if len(lines) <= maxHeight { if len(lines) <= maxHeight {
-15
View File
@@ -8,20 +8,12 @@ import (
"github.com/anotherhadi/ilovetui/style" "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 { type KindStyle struct {
Border lipgloss.Style Border lipgloss.Style
Title lipgloss.Style Title lipgloss.Style
Message 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 { type Styles struct {
Info KindStyle Info KindStyle
Success KindStyle Success KindStyle
@@ -29,10 +21,6 @@ type Styles struct {
Error 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 { func DefaultStyles() Styles {
return Styles{ return Styles{
Info: kindStyle(style.S.Primary), Info: kindStyle(style.S.Primary),
@@ -53,9 +41,6 @@ func kindStyle(c color.Color) KindStyle {
} }
} }
// 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 { func (s Styles) forKind(t Toast) KindStyle {
if t.Style != nil { if t.Style != nil {
return *t.Style return *t.Style
+2 -38
View File
@@ -6,8 +6,6 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
) )
// Kind picks which of Styles' presets a toast renders with, unless
// overridden per-toast via WithToastStyle.
type Kind int type Kind int
const ( const (
@@ -17,45 +15,29 @@ const (
Error 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 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 { type Toast struct {
ID string ID string
Title string Title string
Message string Message string
Kind Kind 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 Duration time.Duration
// Style, if non-nil, overrides the Model's Kind-based preset for this
// toast alone.
Style *KindStyle Style *KindStyle
} }
// ToastOption configures a Toast built by Show.
type ToastOption func(*Toast) 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 { func WithID(id string) ToastOption {
return func(t *Toast) { t.ID = id } 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 { func WithDuration(d time.Duration) ToastOption {
return func(t *Toast) { t.Duration = d } 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 { func WithToastStyle(s KindStyle) ToastOption {
return func(t *Toast) { t.Style = &s } return func(t *Toast) { t.Style = &s }
} }
@@ -73,35 +55,17 @@ func newToast(title, message string, kind Kind, opts ...ToastOption) Toast {
return 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 } 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 { func Show(title, message string, kind Kind, opts ...ToastOption) tea.Cmd {
t := newToast(title, message, kind, opts...) t := newToast(title, message, kind, opts...)
return func() tea.Msg { return ShowMsg{Toast: t} } 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 } 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 { func Dismiss(id string) tea.Cmd {
return func() tea.Msg { return DismissMsg{ID: id} } 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 } type expireMsg struct{ id string }
+18
View File
@@ -0,0 +1,18 @@
# style
Shared Base16 theming for bubbletea/lipgloss TUIs. Loaded automatically on import from
`~/.config/ilovetui/config.yaml` (embedded default as fallback), exposed as the package-level
`style.S`.
- `S.NerdFonts` and `S.BorderType` come from the same config as the colors. This package has no
icon registry: components that want icons read `NerdFonts` and pick their own glyphs.
- `RenderWithTitle` renders a bordered box with a title embedded in the top border, following
`S.BorderType`.
- Never imports `charm.land/bubbles/v2/*`. Anything that needs to know about a specific component
belongs in `bubbles/` instead.
## Config
Copy [`default.yaml`](default.yaml) to `~/.config/ilovetui/config.yaml` and edit it.
See `examples/style`.
+9 -17
View File
@@ -4,12 +4,9 @@ import (
"strings" "strings"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
) )
// 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{ var borderTypes = map[string]lipgloss.Border{
"rounded": lipgloss.RoundedBorder(), "rounded": lipgloss.RoundedBorder(),
"normal": lipgloss.NormalBorder(), "normal": lipgloss.NormalBorder(),
@@ -19,8 +16,6 @@ var borderTypes = map[string]lipgloss.Border{
"ascii": lipgloss.ASCIIBorder(), "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 { func resolveBorderType(name string) lipgloss.Border {
if b, ok := borderTypes[strings.ToLower(strings.TrimSpace(name))]; ok { if b, ok := borderTypes[strings.ToLower(strings.TrimSpace(name))]; ok {
return b return b
@@ -28,7 +23,6 @@ func resolveBorderType(name string) lipgloss.Border {
return lipgloss.RoundedBorder() return lipgloss.RoundedBorder()
} }
// ContentHeight returns the usable inner height for a bordered panel of totalH rows.
func ContentHeight(totalH int) int { func ContentHeight(totalH int) int {
h := totalH - 2 h := totalH - 2
if h < 0 { if h < 0 {
@@ -37,12 +31,6 @@ func ContentHeight(totalH int) int {
return h 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 { func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
boxH := height - 1 boxH := height - 1
if contentH := boxH - 1; contentH > 0 { if contentH := boxH - 1; contentH > 0 {
@@ -54,14 +42,18 @@ func RenderWithTitle(border lipgloss.Style, title, content string, width, height
box := border.BorderTop(false).Width(width).Height(boxH).Render(content) box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0]) 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() b, _, _, _, _ := border.GetBorder()
topLeft, top, topRight := b.TopLeft, b.Top, b.TopRight topLeft, top, topRight := b.TopLeft, b.Top, b.TopRight
overhead := lipgloss.Width(topLeft) + lipgloss.Width(topRight) + 2
fillW := boxWidth - titleW - lipgloss.Width(topLeft) - lipgloss.Width(topRight) - 2 // 2 = the spaces around the title maxTitleW := max(boxWidth-overhead, 0)
if titleW := lipgloss.Width(title); titleW > maxTitleW {
title = ansi.Truncate(title, maxTitleW, "")
}
titleW := lipgloss.Width(title)
fillW := boxWidth - titleW - overhead
if fillW < 0 { if fillW < 0 {
fillW = 0 fillW = 0
} }
+1 -2
View File
@@ -35,8 +35,7 @@ func pickString(base, user string) string {
func mergeConfig(base, user configYAML) configYAML { func mergeConfig(base, user configYAML) configYAML {
return configYAML{ return configYAML{
Colors: mergeColors(base.Colors, user.Colors), 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, NerdFonts: base.NerdFonts || user.NerdFonts,
Border: pickString(base.Border, user.Border), Border: pickString(base.Border, user.Border),
} }
-1
View File
@@ -13,7 +13,6 @@ func hexColor(c color.Color) *string {
return &s return &s
} }
// GlamourStyleConfig returns a glamour ansi.StyleConfig using the active theme.
func GlamourStyleConfig() ansi.StyleConfig { func GlamourStyleConfig() ansi.StyleConfig {
str := func(s string) *string { return &s } str := func(s string) *string { return &s }
boolPtr := func(b bool) *bool { return &b } boolPtr := func(b bool) *bool { return &b }
+26
View File
@@ -0,0 +1,26 @@
package style
import (
"strings"
"github.com/charmbracelet/x/ansi"
)
func Overlay(base, top string, x, y int) string {
baseLines := strings.Split(base, "\n")
topLines := strings.Split(top, "\n")
for i, tl := range topLines {
row := y + i
if row < 0 || row >= len(baseLines) {
continue
}
bl := baseLines[row]
tw := ansi.StringWidth(tl)
left := ansi.Cut(bl, 0, x)
right := ansi.Cut(bl, x+tw, ansi.StringWidth(bl))
baseLines[row] = left + tl + right
}
return strings.Join(baseLines, "\n")
}
-8
View File
@@ -7,10 +7,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
) )
// Styles holds both the raw Base16 palette and ready-to-use semantic colors
// and lipgloss styles. Access via the package-level variable S.
type Styles struct { type Styles struct {
// Raw Base16 palette
Base00 color.Color Base00 color.Color
Base01 color.Color Base01 color.Color
Base02 color.Color Base02 color.Color
@@ -28,7 +25,6 @@ type Styles struct {
Base0E color.Color Base0E color.Color
Base0F color.Color Base0F color.Color
// Semantic color aliases
Background color.Color Background color.Color
SubtleBg color.Color SubtleBg color.Color
Selection color.Color Selection color.Color
@@ -40,19 +36,15 @@ type Styles struct {
Warning color.Color Warning color.Color
Error color.Color Error color.Color
// Pre-built text styles
Bold lipgloss.Style Bold lipgloss.Style
Faint lipgloss.Style Faint lipgloss.Style
// User preferences, read from config
NerdFonts bool NerdFonts bool
BorderType lipgloss.Border BorderType lipgloss.Border
// Pre-built panel styles, bordered with BorderType
Panel lipgloss.Style Panel lipgloss.Style
PanelFocused lipgloss.Style PanelFocused lipgloss.Style
// Pre-rendered pager dot strings
PagerDotActive string PagerDotActive string
PagerDotInactive string PagerDotInactive string
} }
+1 -34
View File
@@ -1,12 +1,3 @@
// 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 package style
import ( import (
@@ -21,8 +12,6 @@ import (
//go:embed default.yaml //go:embed default.yaml
var DefaultConfig []byte 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 var S Styles
func init() { func init() {
@@ -33,13 +22,11 @@ func init() {
return return
} }
} }
// Silent fallback: embedded default always works.
s, _ := stylesFromBytes(DefaultConfig) s, _ := stylesFromBytes(DefaultConfig)
S = s 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 { func Init() error {
path := DefaultConfigPath() path := DefaultConfigPath()
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
@@ -54,7 +41,6 @@ func Init() error {
return InitFromBytes(data) return InitFromBytes(data)
} }
// InitFrom reloads S from an explicit file path.
func InitFrom(path string) error { func InitFrom(path string) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -63,8 +49,6 @@ func InitFrom(path string) error {
return InitFromBytes(data) return InitFromBytes(data)
} }
// InitFromBytes reloads S from raw YAML. Accepts hex strings with or without
// the leading '#'.
func InitFromBytes(data []byte) error { func InitFromBytes(data []byte) error {
s, err := stylesFromBytes(data) s, err := stylesFromBytes(data)
if err != nil { if err != nil {
@@ -74,23 +58,6 @@ func InitFromBytes(data []byte) error {
return nil 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 { func DefaultConfigPath() string {
return filepath.Join(configDir(), "ilovetui", "config.yaml") return filepath.Join(configDir(), "ilovetui", "config.yaml")
} }
+11 -108
View File
@@ -1,112 +1,15 @@
# tabs # tabs
A horizontal tab bar, styled from `style.S`. Switches between a set of items with A horizontal tab bar styled from `style.S`. Renders the bar and the `Content` frame around the
`left`/`right`/`h`/`l`/`tab`/`shift+tab`, wrapping around at either end by default. Draws its own active item; the content itself runs and renders through the host, same as any other custom
frame (tab bar + a `Content` box below it) that follows the theme's configured border family component in this repo.
(`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 - `Focused()` controls the frame's border color, independent of which tab is active (that's shown
content itself; that's the host's job, same as any other custom component in this repo. only by the title style).
- Tabs collapse into a trailing `+N` badge when they don't fit `Width`, keeping the active one
always visible.
- `WithLoop(false)` clamps navigation at either end instead of wrapping.
- The frame follows `style.S.BorderType`: corners and junctions are derived from the border's own
glyphs, not hardcoded.
## Concepts See `examples/tabs`.
- **`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.
+3 -82
View File
@@ -42,24 +42,14 @@ func DefaultKeyMap() KeyMap {
} }
type Styles struct { 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 ActiveTab lipgloss.Style
InactiveTab lipgloss.Style InactiveTab lipgloss.Style
// Title text styles: this is what actually distinguishes the active tab
// from the others.
ActiveTitle lipgloss.Style ActiveTitle lipgloss.Style
InactiveTitle lipgloss.Style InactiveTitle lipgloss.Style
// Border shape/padding for the content pane, same color-less rule as
// above.
Content lipgloss.Style 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 BorderType lipgloss.Border
FocusedBorder color.Color FocusedBorder color.Color
@@ -93,16 +83,6 @@ func DefaultStyles() Styles {
} }
} }
// 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) { func tabBorders(bt lipgloss.Border) (inactive, active lipgloss.Border) {
inactive = bt inactive = bt
inactive.BottomLeft = bt.MiddleBottom inactive.BottomLeft = bt.MiddleBottom
@@ -148,18 +128,12 @@ func WithActive(i int) Option {
} }
} }
// 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 { func WithLoop(l bool) Option {
return func(m *Model) { return func(m *Model) {
m.loop = l 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 { func WithFocus(f bool) Option {
return func(m *Model) { return func(m *Model) {
m.focused = f m.focused = f
@@ -232,11 +206,6 @@ func (m Model) Width() int {
return m.width 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) { func (m *Model) SetWidth(w int) {
m.width = w m.width = w
} }
@@ -245,28 +214,15 @@ func (m Model) Height() int {
return m.height 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) { func (m *Model) SetHeight(h int) {
m.height = h m.height = h
} }
// SetSize is a shorthand for SetWidth followed by SetHeight.
func (m *Model) SetSize(w, h int) { func (m *Model) SetSize(w, h int) {
m.width = w m.width = w
m.height = h 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 { func (m Model) ContentWidth() int {
if len(m.items) == 0 { if len(m.items) == 0 {
return 0 return 0
@@ -313,9 +269,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, cmd 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 { type tabSegment struct {
title string title string
isActive bool isActive bool
@@ -330,8 +283,6 @@ func (m Model) segments() []tabSegment {
return segs 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 { func (m Model) segmentWidth(seg tabSegment) int {
tabStyle := m.styles.InactiveTab tabStyle := m.styles.InactiveTab
titleStyle := m.styles.InactiveTitle titleStyle := m.styles.InactiveTitle
@@ -342,11 +293,6 @@ func (m Model) segmentWidth(seg tabSegment) int {
return lipgloss.Width(tabStyle.Render(titleStyle.Render(seg.title))) 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 { func (m Model) collapsedSegments(budget int) []tabSegment {
segs := m.segments() segs := m.segments()
if budget <= 0 { if budget <= 0 {
@@ -366,8 +312,6 @@ func (m Model) collapsedSegments(budget int) []tabSegment {
moreWidth := m.segmentWidth(tabSegment{title: fmt.Sprintf("+%d", len(segs)-1), isMore: true}) moreWidth := m.segmentWidth(tabSegment{title: fmt.Sprintf("+%d", len(segs)-1), isMore: true})
fitBudget := budget - moreWidth fitBudget := budget - moreWidth
if fitBudget < widths[m.active] { 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] fitBudget = widths[m.active]
} }
@@ -416,9 +360,6 @@ func (m Model) View() string {
contentWidth := lipgloss.Width(bar) contentWidth := lipgloss.Width(bar)
if m.width > contentWidth { 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) bar = m.extendBarCap(m.renderBar(segs, borderColor, true), m.width, borderColor)
contentWidth = m.width contentWidth = m.width
} }
@@ -436,11 +377,6 @@ func (m Model) View() string {
return lipgloss.JoinVertical(lipgloss.Left, bar, content) 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 { func (m Model) renderBar(segs []tabSegment, borderColor color.Color, extendCap bool) string {
rendered := make([]string, len(segs)) rendered := make([]string, len(segs))
@@ -467,13 +403,7 @@ func (m Model) renderBar(segs []tabSegment, borderColor color.Color, extendCap b
case isLast && !seg.isActive && !extendCap: case isLast && !seg.isActive && !extendCap:
border.BottomRight = bt.MiddleRight 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) tabStyle = tabStyle.Border(border)
rendered[i] = tabStyle.Render(titleStyle.Render(seg.title)) rendered[i] = tabStyle.Render(titleStyle.Render(seg.title))
@@ -482,12 +412,6 @@ func (m Model) renderBar(segs []tabSegment, borderColor color.Color, extendCap b
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...) 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 { func (m Model) extendBarCap(bar string, width int, borderColor color.Color) string {
gap := width - lipgloss.Width(bar) gap := width - lipgloss.Width(bar)
if gap <= 0 { if gap <= 0 {
@@ -505,9 +429,6 @@ func (m Model) extendBarCap(bar string, width int, borderColor color.Color) stri
return strings.Join(lines, "\n") 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 { func (m Model) step(delta int) int {
n := len(m.items) n := len(m.items)
if n == 0 { if n == 0 {