From 21b574eb6f8e49a627d72853f39d8ad0ccb689cf Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:10:32 +0200 Subject: [PATCH] Change style package, new components, ... Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- README.md | 89 +----- bubbles/README.md | 17 ++ bubbles/bubbles.go | 1 + bubbles/filepicker.go | 25 ++ bubbles/help.go | 20 ++ bubbles/list.go | 47 +++ bubbles/paginator.go | 16 + bubbles/progress.go | 16 + bubbles/spinner.go | 14 + bubbles/table.go | 17 ++ bubbles/textarea.go | 31 ++ bubbles/textinput.go | 27 ++ bubbles/viewport.go | 47 +++ components.go | 96 ------ config.go | 51 ---- drawer/README.md | 13 + drawer/content.go | 61 ++++ drawer/drawer.go | 75 +++++ drawer/render.go | 119 ++++++++ drawer/render_test.go | 68 +++++ drawer/styles.go | 25 ++ examples/bubbles/main.go | 48 +++ examples/drawer/main.go | 55 ++++ examples/helpbar/main.go | 73 +++++ examples/modal/main.go | 55 ++++ examples/notification/main.go | 51 ++++ examples/style/main.go | 42 +++ examples/tabs/main.go | 55 ++++ flake.nix | 2 +- go.mod | 7 +- go.sum | 8 + helpbar/README.md | 13 + helpbar/helpbar.go | 118 ++++++++ ilovetui.go | 115 -------- modal/README.md | 15 + modal/content.go | 46 +++ modal/modal.go | 80 +++++ modal/render.go | 113 +++++++ modal/styles.go | 25 ++ nix/shell.nix | 1 + nix/tui-snapshot.py | 192 ++++++++++++ notification/README.md | 14 + notification/notification.go | 93 ++++++ notification/position.go | 48 +++ notification/render.go | 127 ++++++++ notification/styles.go | 58 ++++ notification/toast.go | 71 +++++ style/README.md | 18 ++ border.go => style/border.go | 33 ++- style/config.go | 63 ++++ default.yaml => style/default.yaml | 11 +- glamour.go => style/glamour.go | 3 +- style.go => style/style.go | 23 +- style/theme.go | 84 ++++++ tabs/README.md | 15 + tabs/tabs.go | 454 +++++++++++++++++++++++++++++ 56 files changed, 2741 insertions(+), 363 deletions(-) create mode 100644 bubbles/README.md create mode 100644 bubbles/bubbles.go create mode 100644 bubbles/filepicker.go create mode 100644 bubbles/help.go create mode 100644 bubbles/list.go create mode 100644 bubbles/paginator.go create mode 100644 bubbles/progress.go create mode 100644 bubbles/spinner.go create mode 100644 bubbles/table.go create mode 100644 bubbles/textarea.go create mode 100644 bubbles/textinput.go create mode 100644 bubbles/viewport.go delete mode 100644 components.go delete mode 100644 config.go create mode 100644 drawer/README.md create mode 100644 drawer/content.go create mode 100644 drawer/drawer.go create mode 100644 drawer/render.go create mode 100644 drawer/render_test.go create mode 100644 drawer/styles.go create mode 100644 examples/bubbles/main.go create mode 100644 examples/drawer/main.go create mode 100644 examples/helpbar/main.go create mode 100644 examples/modal/main.go create mode 100644 examples/notification/main.go create mode 100644 examples/style/main.go create mode 100644 examples/tabs/main.go create mode 100644 helpbar/README.md create mode 100644 helpbar/helpbar.go create mode 100644 modal/README.md create mode 100644 modal/content.go create mode 100644 modal/modal.go create mode 100644 modal/render.go create mode 100644 modal/styles.go create mode 100644 nix/tui-snapshot.py create mode 100644 notification/README.md create mode 100644 notification/notification.go create mode 100644 notification/position.go create mode 100644 notification/render.go create mode 100644 notification/styles.go create mode 100644 notification/toast.go create mode 100644 style/README.md rename border.go => style/border.go (50%) create mode 100644 style/config.go rename default.yaml => style/default.yaml (73%) rename glamour.go => style/glamour.go (98%) rename style.go => style/style.go (83%) create mode 100644 style/theme.go create mode 100644 tabs/README.md create mode 100644 tabs/tabs.go diff --git a/README.md b/README.md index 9ae2bfa..0dc36c7 100644 --- a/README.md +++ b/README.md @@ -1,92 +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). +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. - -## How it works - -On import, `ilovetui` 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" - -// Use colors directly -style := lipgloss.NewStyle().Foreground(ilovetui.S.Primary) - -// Use pre-built panel styles -box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Title", content, w, h) -``` - -No setup required — just import and use. - -## Installation +## Install ```sh 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 | -| ------------ | ------ | --------------------------------------- | -| `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 | +Each package has its own README and a runnable example under `examples/`. -The default theme is `./default.yaml`. Copy it and edit to customize: +## Quick start -```sh -mkdir -p ~/.config/ilovetui -cp $(go env GOPATH)/pkg/mod/github.com/anotherhadi/ilovetui*/default.yaml ~/.config/ilovetui/config.yaml -``` - -Or let your app write it on first run: +On import, `style` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (embedded default as fallback), exposed as the package-level `S`: ```go -ilovetui.WriteDefaultConfig(ilovetui.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 - -`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 := ilovetui.ContentHeight(h) - -// Render a box with a title embedded in the top border -box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Header", content, w, h) -``` - -## API - -```go -ilovetui.Init() // Reload from default config path -ilovetui.InitFrom(path string) // Reload from a custom path -ilovetui.InitFromBytes(data []byte) // Parse raw YAML -ilovetui.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml -ilovetui.WriteDefaultConfig(path) // Write default config if missing -``` +No setup required. See [`style/README.md`](style/README.md) for config details. ## Projects using ilovetui diff --git a/bubbles/README.md b/bubbles/README.md new file mode 100644 index 0000000..29487e2 --- /dev/null +++ b/bubbles/README.md @@ -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`. diff --git a/bubbles/bubbles.go b/bubbles/bubbles.go new file mode 100644 index 0000000..9b9e8d4 --- /dev/null +++ b/bubbles/bubbles.go @@ -0,0 +1 @@ +package bubbles diff --git a/bubbles/filepicker.go b/bubbles/filepicker.go new file mode 100644 index 0000000..63af9ce --- /dev/null +++ b/bubbles/filepicker.go @@ -0,0 +1,25 @@ +package bubbles + +import ( + "charm.land/bubbles/v2/filepicker" + + "github.com/anotherhadi/ilovetui/style" +) + +func NewFilePicker() filepicker.Model { + f := filepicker.New() + s := filepicker.DefaultStyles() + s.Cursor = s.Cursor.Foreground(style.S.Primary) + s.DisabledCursor = s.DisabledCursor.Foreground(style.S.Subtle) + s.Symlink = s.Symlink.Foreground(style.S.Warning) + s.Directory = s.Directory.Foreground(style.S.Primary) + s.File = s.File.Foreground(style.S.Text) + s.DisabledFile = s.DisabledFile.Foreground(style.S.Subtle) + s.Permission = s.Permission.Foreground(style.S.Muted) + s.Selected = s.Selected.Foreground(style.S.Primary) + s.DisabledSelected = s.DisabledSelected.Foreground(style.S.Subtle) + s.FileSize = s.FileSize.Foreground(style.S.Muted) + s.EmptyDirectory = s.EmptyDirectory.Foreground(style.S.Subtle) + f.Styles = s + return f +} diff --git a/bubbles/help.go b/bubbles/help.go new file mode 100644 index 0000000..7c1bac1 --- /dev/null +++ b/bubbles/help.go @@ -0,0 +1,20 @@ +package bubbles + +import ( + "charm.land/bubbles/v2/help" + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +func NewHelp() help.Model { + h := help.New() + h.Styles.ShortKey = lipgloss.NewStyle().Foreground(style.S.Primary) + h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(style.S.Muted) + h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle) + h.Styles.FullKey = lipgloss.NewStyle().Foreground(style.S.Primary) + h.Styles.FullDesc = lipgloss.NewStyle().Foreground(style.S.Muted) + h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle) + h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(style.S.Subtle) + return h +} diff --git a/bubbles/list.go b/bubbles/list.go new file mode 100644 index 0000000..1cb71a0 --- /dev/null +++ b/bubbles/list.go @@ -0,0 +1,47 @@ +package bubbles + +import ( + "charm.land/bubbles/v2/list" + + "github.com/anotherhadi/ilovetui/style" +) + +func NewList(items []list.Item, width, height int) list.Model { + m := list.New(items, NewDefaultDelegate(), width, height) + m.Styles = themedListStyles() + return m +} + +func themedListStyles() list.Styles { + s := list.DefaultStyles(true) + s.Title = s.Title.Background(style.S.Primary).Foreground(style.S.Background) + s.Spinner = s.Spinner.Foreground(style.S.Primary) + s.Filter = themedTextInputStyles() + s.DefaultFilterCharacterMatch = s.DefaultFilterCharacterMatch.Foreground(style.S.Primary) + s.StatusBar = s.StatusBar.Foreground(style.S.Muted) + s.StatusEmpty = s.StatusEmpty.Foreground(style.S.Subtle) + s.StatusBarActiveFilter = s.StatusBarActiveFilter.Foreground(style.S.Text) + s.StatusBarFilterCount = s.StatusBarFilterCount.Foreground(style.S.Subtle) + s.NoItems = s.NoItems.Foreground(style.S.Subtle) + s.ArabicPagination = s.ArabicPagination.Foreground(style.S.Subtle) + s.ActivePaginationDot = s.ActivePaginationDot.Foreground(style.S.Primary) + s.InactivePaginationDot = s.InactivePaginationDot.Foreground(style.S.Subtle) + s.DividerDot = s.DividerDot.Foreground(style.S.Subtle) + return s +} + +func NewDefaultDelegate() list.DefaultDelegate { + d := list.NewDefaultDelegate() + d.Styles.NormalTitle = d.Styles.NormalTitle.Foreground(style.S.Text) + d.Styles.NormalDesc = d.Styles.NormalDesc.Foreground(style.S.Muted) + d.Styles.SelectedTitle = d.Styles.SelectedTitle. + BorderForeground(style.S.Primary). + Foreground(style.S.Primary) + d.Styles.SelectedDesc = d.Styles.SelectedDesc. + BorderForeground(style.S.Primary). + Foreground(style.S.Primary) + d.Styles.DimmedTitle = d.Styles.DimmedTitle.Foreground(style.S.Subtle) + d.Styles.DimmedDesc = d.Styles.DimmedDesc.Foreground(style.S.SubtleBg) + d.Styles.FilterMatch = d.Styles.FilterMatch.Foreground(style.S.Primary) + return d +} diff --git a/bubbles/paginator.go b/bubbles/paginator.go new file mode 100644 index 0000000..71e276a --- /dev/null +++ b/bubbles/paginator.go @@ -0,0 +1,16 @@ +package bubbles + +import ( + "charm.land/bubbles/v2/paginator" + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +func NewPaginator() paginator.Model { + p := paginator.New() + p.Type = paginator.Dots + p.ActiveDot = lipgloss.NewStyle().Foreground(style.S.Primary).Render("•") + p.InactiveDot = lipgloss.NewStyle().Foreground(style.S.Subtle).Render("•") + return p +} diff --git a/bubbles/progress.go b/bubbles/progress.go new file mode 100644 index 0000000..52f7946 --- /dev/null +++ b/bubbles/progress.go @@ -0,0 +1,16 @@ +package bubbles + +import ( + "charm.land/bubbles/v2/progress" + + "github.com/anotherhadi/ilovetui/style" +) + +func NewProgress(opts ...progress.Option) progress.Model { + allOpts := append([]progress.Option{ + progress.WithColors(style.S.Subtle, style.S.Primary), + }, opts...) + p := progress.New(allOpts...) + p.EmptyColor = style.S.SubtleBg + return p +} diff --git a/bubbles/spinner.go b/bubbles/spinner.go new file mode 100644 index 0000000..ec8349d --- /dev/null +++ b/bubbles/spinner.go @@ -0,0 +1,14 @@ +package bubbles + +import ( + "charm.land/bubbles/v2/spinner" + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +func NewSpinner(opts ...spinner.Option) spinner.Model { + s := spinner.New(opts...) + s.Style = lipgloss.NewStyle().Foreground(style.S.Primary) + return s +} diff --git a/bubbles/table.go b/bubbles/table.go new file mode 100644 index 0000000..5ba0122 --- /dev/null +++ b/bubbles/table.go @@ -0,0 +1,17 @@ +package bubbles + +import ( + "charm.land/bubbles/v2/table" + + "github.com/anotherhadi/ilovetui/style" +) + +func NewTable(opts ...table.Option) table.Model { + t := table.New(opts...) + s := table.DefaultStyles() + s.Header = s.Header.Foreground(style.S.Primary) + s.Cell = s.Cell.Foreground(style.S.Text) + s.Selected = s.Selected.Foreground(style.S.Primary) + t.SetStyles(s) + return t +} diff --git a/bubbles/textarea.go b/bubbles/textarea.go new file mode 100644 index 0000000..b81a202 --- /dev/null +++ b/bubbles/textarea.go @@ -0,0 +1,31 @@ +package bubbles + +import ( + "charm.land/bubbles/v2/textarea" + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +func NewTextarea(showLineNumbers bool) textarea.Model { + ta := textarea.New() + ta.Prompt = "" + ta.ShowLineNumbers = showLineNumbers + ta.CharLimit = 0 + ta.EndOfBufferCharacter = '~' + ts := ta.Styles() + ts.Focused.Base = lipgloss.NewStyle() + ts.Blurred.Base = lipgloss.NewStyle() + ts.Focused.Text = lipgloss.NewStyle().Foreground(style.S.Text) + ts.Focused.CursorLine = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Text) + ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Primary).Bold(true) + ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(style.S.Subtle) + ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle) + ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg) + ts.Blurred.Text = lipgloss.NewStyle().Foreground(style.S.Muted) + ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(style.S.SubtleBg) + ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle) + ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg) + ta.SetStyles(ts) + return ta +} diff --git a/bubbles/textinput.go b/bubbles/textinput.go new file mode 100644 index 0000000..0bae5f4 --- /dev/null +++ b/bubbles/textinput.go @@ -0,0 +1,27 @@ +package bubbles + +import ( + "charm.land/bubbles/v2/textinput" + + "github.com/anotherhadi/ilovetui/style" +) + +func NewTextInput() textinput.Model { + t := textinput.New() + t.SetStyles(themedTextInputStyles()) + return t +} + +func themedTextInputStyles() textinput.Styles { + s := textinput.DefaultStyles(true) + s.Focused.Text = s.Focused.Text.Foreground(style.S.Text) + s.Focused.Placeholder = s.Focused.Placeholder.Foreground(style.S.Subtle) + s.Focused.Suggestion = s.Focused.Suggestion.Foreground(style.S.Subtle) + s.Focused.Prompt = s.Focused.Prompt.Foreground(style.S.Primary) + s.Blurred.Text = s.Blurred.Text.Foreground(style.S.Muted) + s.Blurred.Placeholder = s.Blurred.Placeholder.Foreground(style.S.Subtle) + s.Blurred.Suggestion = s.Blurred.Suggestion.Foreground(style.S.Subtle) + s.Blurred.Prompt = s.Blurred.Prompt.Foreground(style.S.Subtle) + s.Cursor.Color = style.S.Primary + return s +} diff --git a/bubbles/viewport.go b/bubbles/viewport.go new file mode 100644 index 0000000..8fa8892 --- /dev/null +++ b/bubbles/viewport.go @@ -0,0 +1,47 @@ +package bubbles + +import ( + "charm.land/bubbles/v2/viewport" + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +const ViewportGutterWidth = 2 + +func NewViewport() viewport.Model { + vp := viewport.New() + vp.MouseWheelEnabled = false + return vp +} + +func ViewportView(vp *viewport.Model) string { + height := vp.Height() + total := vp.TotalLineCount() + blank := lipgloss.NewStyle().Width(ViewportGutterWidth).Render("") + + if height <= 0 || total <= height { + vp.LeftGutterFunc = func(viewport.GutterContext) string { return blank } + return vp.View() + } + + yOffset := vp.YOffset() + thumbSize := max(1, height*height/total) + thumbStart := 0 + if maxOffset := total - height; maxOffset > 0 { + thumbStart = yOffset * (height - thumbSize) / maxOffset + } + + trackStyle := lipgloss.NewStyle().Foreground(style.S.Subtle) + thumbStyle := lipgloss.NewStyle().Foreground(style.S.Primary) + + vp.LeftGutterFunc = func(ctx viewport.GutterContext) string { + pos := ctx.Index - yOffset + if pos >= thumbStart && pos < thumbStart+thumbSize { + return thumbStyle.Render("█") + " " + } + return trackStyle.Render("│") + " " + } + + return vp.View() +} diff --git a/components.go b/components.go deleted file mode 100644 index 8c9e179..0000000 --- a/components.go +++ /dev/null @@ -1,96 +0,0 @@ -package ilovetui - -import ( - "strings" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/paginator" - "charm.land/bubbles/v2/textarea" - "charm.land/bubbles/v2/viewport" - "charm.land/lipgloss/v2" -) - -// NewHelp returns a help.Model styled with the active theme. -func NewHelp() help.Model { - h := help.New() - h.Styles.ShortKey = lipgloss.NewStyle().Foreground(S.Primary) - h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(S.Muted) - h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(S.Subtle) - h.Styles.FullKey = lipgloss.NewStyle().Foreground(S.Primary) - h.Styles.FullDesc = lipgloss.NewStyle().Foreground(S.Muted) - h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(S.Subtle) - h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(S.Subtle) - return h -} - -// NewTextarea returns a textarea.Model styled with the active theme. -// Set showLineNumbers to true to display line numbers in the gutter. -func NewTextarea(showLineNumbers bool) textarea.Model { - ta := textarea.New() - ta.Prompt = "" - ta.ShowLineNumbers = showLineNumbers - ta.CharLimit = 0 - ta.EndOfBufferCharacter = '~' - ts := ta.Styles() - ts.Focused.Base = lipgloss.NewStyle() - ts.Blurred.Base = lipgloss.NewStyle() - ts.Focused.Text = lipgloss.NewStyle().Foreground(S.Text) - ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text) - ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Primary).Bold(true) - ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(S.Subtle) - ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle) - ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg) - ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.Muted) - ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(S.SubtleBg) - ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle) - ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg) - ta.SetStyles(ts) - return ta -} - -// NewPaginator returns a dot-style paginator.Model styled with the active theme. -func NewPaginator() paginator.Model { - p := paginator.New() - p.Type = paginator.Dots - p.ActiveDot = S.PagerDotActive - p.InactiveDot = S.PagerDotInactive - return p -} - -// SplitH splits totalHeight into top and bottom sections, accounting for the -// height of statusBar (measured by newline count). -func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) { - statusH := strings.Count(statusBar, "\n") + 1 - available := totalHeight - statusH - top = int(float64(available) * ratio) - bottom = available - top - return -} - -// NewViewport returns a viewport.Model with mouse wheel disabled. -func NewViewport() viewport.Model { - vp := viewport.New() - vp.MouseWheelEnabled = false - return vp -} - -// ViewportView renders the viewport and appends a subtle scroll indicator -// on the last visible line when the user has not reached the bottom. -func ViewportView(vp *viewport.Model) string { - v := vp.View() - if vp.AtBottom() { - return v - } - lines := strings.Split(v, "\n") - if len(lines) == 0 { - return v - } - arrow := lipgloss.NewStyle().Foreground(S.Subtle).Render("↓") - arrowW := lipgloss.Width(arrow) - inner := vp.Width() - 2*arrowW - if inner < 0 { - inner = 0 - } - lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow - return strings.Join(lines, "\n") -} diff --git a/config.go b/config.go deleted file mode 100644 index c20905a..0000000 --- a/config.go +++ /dev/null @@ -1,51 +0,0 @@ -package ilovetui - -type colorsYAML struct { - Base00 string `yaml:"base00"` - Base01 string `yaml:"base01"` - Base02 string `yaml:"base02"` - Base03 string `yaml:"base03"` - Base04 string `yaml:"base04"` - Base05 string `yaml:"base05"` - Base06 string `yaml:"base06"` - Base07 string `yaml:"base07"` - Base08 string `yaml:"base08"` - Base09 string `yaml:"base09"` - Base0A string `yaml:"base0a"` - Base0B string `yaml:"base0b"` - Base0C string `yaml:"base0c"` - Base0D string `yaml:"base0d"` - Base0E string `yaml:"base0e"` - Base0F string `yaml:"base0f"` -} - -type configYAML struct { - Colors colorsYAML `yaml:"colors"` -} - -func mergeColors(base, user colorsYAML) colorsYAML { - pick := func(b, u string) string { - if u != "" { - return u - } - return b - } - return colorsYAML{ - Base00: pick(base.Base00, user.Base00), - Base01: pick(base.Base01, user.Base01), - Base02: pick(base.Base02, user.Base02), - Base03: pick(base.Base03, user.Base03), - Base04: pick(base.Base04, user.Base04), - Base05: pick(base.Base05, user.Base05), - Base06: pick(base.Base06, user.Base06), - Base07: pick(base.Base07, user.Base07), - Base08: pick(base.Base08, user.Base08), - Base09: pick(base.Base09, user.Base09), - Base0A: pick(base.Base0A, user.Base0A), - Base0B: pick(base.Base0B, user.Base0B), - Base0C: pick(base.Base0C, user.Base0C), - Base0D: pick(base.Base0D, user.Base0D), - Base0E: pick(base.Base0E, user.Base0E), - Base0F: pick(base.Base0F, user.Base0F), - } -} diff --git a/drawer/README.md b/drawer/README.md new file mode 100644 index 0000000..58a3652 --- /dev/null +++ b/drawer/README.md @@ -0,0 +1,13 @@ +# drawer + +A full-height panel flush against the left or right edge of an already-rendered background, on top +of it dimmed. The sidebar/drawer equivalent of [`modal`](../modal/README.md), which it otherwise +mirrors closely: same `tea.Msg`-triggered stack (`ShowMsg`/`Show`), same composite-over-a-string +`Render`, same rule that content is a `tea.Model`. + +- `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. + +See `examples/drawer`. diff --git a/drawer/content.go b/drawer/content.go new file mode 100644 index 0000000..739cf38 --- /dev/null +++ b/drawer/content.go @@ -0,0 +1,61 @@ +package drawer + +import tea "charm.land/bubbletea/v2" + +type Side int + +const ( + Left Side = iota + Right +) + +type Drawer struct { + Title string + Content tea.Model + Side Side + Width int + Style *Styles +} + +type DrawerOption func(*Drawer) + +func WithSide(s Side) DrawerOption { + return func(d *Drawer) { d.Side = s } +} + +func WithWidth(w int) DrawerOption { + return func(d *Drawer) { d.Width = w } +} + +func WithDrawerStyle(s Styles) DrawerOption { + return func(d *Drawer) { d.Style = &s } +} + +func newDrawer(title string, content tea.Model, opts ...DrawerOption) Drawer { + d := Drawer{Title: title, Content: content} + for _, opt := range opts { + opt(&d) + } + return d +} + +type ShowMsg struct{ Drawer Drawer } + +func Show(title string, content tea.Model, opts ...DrawerOption) tea.Cmd { + d := newDrawer(title, content, opts...) + return func() tea.Msg { return ShowMsg{Drawer: d} } +} + +type DismissMsg struct{} + +func Close() tea.Cmd { + return func() tea.Msg { return DismissMsg{} } +} + +type text string + +func (t text) Init() tea.Cmd { return nil } +func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil } +func (t text) View() tea.View { return tea.NewView(string(t)) } + +func Text(s string) tea.Model { return text(s) } diff --git a/drawer/drawer.go b/drawer/drawer.go new file mode 100644 index 0000000..f442712 --- /dev/null +++ b/drawer/drawer.go @@ -0,0 +1,75 @@ +package drawer + +import tea "charm.land/bubbletea/v2" + +type Model struct { + drawers []Drawer + nextID int + maxWidth int + styles Styles +} + +type Option func(*Model) + +func WithMaxWidth(w int) Option { + return func(m *Model) { m.maxWidth = w } +} + +func WithStyles(s Styles) Option { + return func(m *Model) { m.styles = s } +} + +func New(opts ...Option) Model { + m := Model{ + maxWidth: 30, + styles: DefaultStyles(), + } + for _, opt := range opts { + opt(&m) + } + return m +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case ShowMsg: + return m.show(msg.Drawer) + case DismissMsg: + return m.pop(), nil + } + return m.updateTop(msg) +} + +func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) { + i := len(m.drawers) - 1 + if i < 0 || m.drawers[i].Content == nil { + return m, nil + } + var cmd tea.Cmd + m.drawers[i].Content, cmd = m.drawers[i].Content.Update(msg) + return m, cmd +} + +func (m Model) Open() bool { return len(m.drawers) > 0 } + +func (m Model) show(d Drawer) (Model, tea.Cmd) { + m.drawers = append(m.drawers, d) + return m, initContent(d) +} + +func initContent(d Drawer) tea.Cmd { + if d.Content == nil { + return nil + } + return d.Content.Init() +} + +func (m Model) pop() Model { + if len(m.drawers) == 0 { + return m + } + m.drawers = m.drawers[:len(m.drawers)-1] + return m +} diff --git a/drawer/render.go b/drawer/render.go new file mode 100644 index 0000000..9d31358 --- /dev/null +++ b/drawer/render.go @@ -0,0 +1,119 @@ +package drawer + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/anotherhadi/ilovetui/style" +) + +func (m Model) Render(background string) string { + if len(m.drawers) == 0 { + return background + } + + result := background + for _, d := range m.drawers { + w, h := lipgloss.Width(result), lipgloss.Height(result) + if w <= 0 || h <= 0 { + return result + } + result = m.renderOne(d, result, w, h) + } + return result +} + +func (m Model) View(width, height int) string { + return m.Render(blank(width, height)) +} + +func (m Model) renderOne(d Drawer, background string, w, h int) string { + s := m.styles + if d.Style != nil { + s = *d.Style + } + + box := m.renderBox(d, s, w, h) + bw := lipgloss.Width(box) + x := 0 + if d.Side == Right { + x = max(w-bw, 0) + } + + compositor := lipgloss.NewCompositor( + lipgloss.NewLayer(dim(background, s.DimColor)), + lipgloss.NewLayer(box).X(x).Y(0).Z(1), + ) + return compositor.Render() +} + +func dim(s string, c color.Color) string { + return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s)) +} + +func (m Model) renderBox(d Drawer, s Styles, bgW, bgH int) string { + widthCap := m.maxWidth + if d.Width > 0 { + widthCap = d.Width + } + maxW := effectiveMax(widthCap, bgW) + + body := contentView(d) + inner := contentWidth(d, body, maxW) + content := s.Content.Width(inner).Render(body) + + boxWidth := inner + 4 + + return style.RenderWithTitle(s.Border, s.Title.Render(d.Title), content, boxWidth, bgH) +} + +func contentView(d Drawer) string { + if d.Content == nil { + return "" + } + return d.Content.View().Content +} + +func contentWidth(d Drawer, body string, maxWidth int) int { + capped := max(maxWidth-4, 1) + if d.Width > 0 { + return capped + } + natural := max(naturalWidth(body), lipgloss.Width(d.Title), 1) + return min(natural, capped) +} + +func naturalWidth(content string) int { + w := 0 + for _, line := range strings.Split(content, "\n") { + if lw := lipgloss.Width(line); lw > w { + w = lw + } + } + return w +} + +func effectiveMax(configured, fits int) int { + if fits < 1 { + fits = 1 + } + if configured > 0 && configured < fits { + return configured + } + return fits +} + +func blank(width, height int) string { + if width <= 0 || height <= 0 { + return "" + } + line := strings.Repeat(" ", width) + lines := make([]string, height) + for i := range lines { + lines[i] = line + } + return strings.Join(lines, "\n") +} diff --git a/drawer/render_test.go b/drawer/render_test.go new file mode 100644 index 0000000..101dd90 --- /dev/null +++ b/drawer/render_test.go @@ -0,0 +1,68 @@ +package drawer + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +func background(w, h int) string { + return blank(w, h) +} + +func TestRenderLeftFlushToLeftEdge(t *testing.T) { + m := New(WithMaxWidth(10)) + m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"), WithSide(Left))}) + + out := m.Render(background(40, 10)) + lines := strings.Split(out, "\n") + if len(lines) != 10 { + t.Fatalf("expected 10 lines, got %d", len(lines)) + } + if lipgloss.Width(out) == 0 { + t.Fatalf("expected non-empty render") + } + + if len([]rune(lines[0])) == 0 { + t.Fatalf("expected a rendered top border line") + } +} + +func TestRenderRightFlushToRightEdge(t *testing.T) { + m := New(WithMaxWidth(10)) + m, _ = m.Update(ShowMsg{Drawer: newDrawer("Inspector", Text("hi"), WithSide(Right))}) + + out := m.Render(background(40, 10)) + if lipgloss.Width(out) != 40 { + t.Fatalf("expected full background width 40, got %d", lipgloss.Width(out)) + } +} + +func TestSpansFullBackgroundHeight(t *testing.T) { + m := New() + m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"))}) + + out := m.Render(background(40, 12)) + if lipgloss.Height(out) != 12 { + t.Fatalf("expected full height 12, got %d", lipgloss.Height(out)) + } +} + +func TestFixedWidthHonored(t *testing.T) { + m := New(WithMaxWidth(50)) + m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("x"), WithWidth(20))}) + + box := m.renderBox(m.drawers[0], m.styles, 80, 10) + if got := lipgloss.Width(box); got != 20 { + t.Fatalf("expected fixed box width 20, got %d", got) + } +} + +func TestNoDrawersReturnsBackgroundUnchanged(t *testing.T) { + m := New() + bg := background(10, 5) + if got := m.Render(bg); got != bg { + t.Fatalf("expected background unchanged when no drawer is open") + } +} diff --git a/drawer/styles.go b/drawer/styles.go new file mode 100644 index 0000000..74eb2f1 --- /dev/null +++ b/drawer/styles.go @@ -0,0 +1,25 @@ +package drawer + +import ( + "image/color" + + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +type Styles struct { + Border lipgloss.Style + Title lipgloss.Style + Content lipgloss.Style + DimColor color.Color +} + +func DefaultStyles() Styles { + return Styles{ + Border: style.S.PanelFocused.Padding(0, 1), + Title: lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary), + Content: lipgloss.NewStyle().Foreground(style.S.Text), + DimColor: style.S.Subtle, + } +} diff --git a/examples/bubbles/main.go b/examples/bubbles/main.go new file mode 100644 index 0000000..274bc2a --- /dev/null +++ b/examples/bubbles/main.go @@ -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) + } +} diff --git a/examples/drawer/main.go b/examples/drawer/main.go new file mode 100644 index 0000000..1acbe14 --- /dev/null +++ b/examples/drawer/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "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.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case tea.KeyPressMsg: + switch msg.String() { + case "q": + return m, tea.Quit + case "l": + return m, drawer.Show("Nav", drawer.Text("Home\nSettings"), drawer.WithSide(drawer.Left)) + case "esc": + if m.d.Open() { + return m, drawer.Close() + } + } + } + var cmd tea.Cmd + m.d, cmd = m.d.Update(msg) + return m, cmd +} + +func (m model) View() tea.View { + background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "l: open q: quit") + view := tea.NewView(m.d.Render(background)) + view.AltScreen = true + return view +} + +func main() { + if _, err := tea.NewProgram(newModel()).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} diff --git a/examples/helpbar/main.go b/examples/helpbar/main.go new file mode 100644 index 0000000..682e0d8 --- /dev/null +++ b/examples/helpbar/main.go @@ -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) + } +} diff --git a/examples/modal/main.go b/examples/modal/main.go new file mode 100644 index 0000000..8b3174a --- /dev/null +++ b/examples/modal/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "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.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case tea.KeyPressMsg: + switch msg.String() { + case "q": + return m, tea.Quit + case "o": + return m, modal.Show("Hello", modal.Text("This is a modal.\n\nesc: close")) + case "esc": + if 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 := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "o: open q: quit") + view := tea.NewView(m.m.Render(background)) + view.AltScreen = true + return view +} + +func main() { + if _, err := tea.NewProgram(newModel()).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} diff --git a/examples/notification/main.go b/examples/notification/main.go new file mode 100644 index 0000000..c490d82 --- /dev/null +++ b/examples/notification/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/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.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case tea.KeyPressMsg: + switch msg.String() { + case "q": + return m, tea.Quit + case "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 := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "s: show q: quit") + view := tea.NewView(m.notif.Render(background)) + view.AltScreen = true + return view +} + +func main() { + if _, err := tea.NewProgram(newModel()).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} diff --git a/examples/style/main.go b/examples/style/main.go new file mode 100644 index 0000000..a4d5241 --- /dev/null +++ b/examples/style/main.go @@ -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) + } +} diff --git a/examples/tabs/main.go b/examples/tabs/main.go new file mode 100644 index 0000000..1bd20b8 --- /dev/null +++ b/examples/tabs/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" + + "github.com/anotherhadi/ilovetui/tabs" +) + +type pane string + +func (p pane) Init() tea.Cmd { return nil } +func (p pane) Update(tea.Msg) (tabs.Tab, tea.Cmd) { return p, nil } +func (p pane) View() string { return string(p) } + +type model struct{ tabs tabs.Model } + +func newModel() model { + items := []tabs.Item{ + {Title: "First", Model: pane("first content")}, + {Title: "Second", Model: pane("second content")}, + {Title: "Third", Model: pane("third content")}, + } + return model{tabs: tabs.New(items)} +} + +func (m model) Init() tea.Cmd { return m.tabs.Init() } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "q" { + return m, tea.Quit + } + if s, ok := msg.(tea.WindowSizeMsg); ok { + m.tabs.SetSize(s.Width, s.Height) + msg = tea.WindowSizeMsg{Width: m.tabs.ContentWidth(), Height: m.tabs.ContentHeight()} + } + var cmd tea.Cmd + m.tabs, cmd = m.tabs.Update(msg) + return m, cmd +} + +func (m model) View() tea.View { + view := tea.NewView(m.tabs.View()) + view.AltScreen = true + return view +} + +func main() { + if _, err := tea.NewProgram(newModel()).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} diff --git a/flake.nix b/flake.nix index bd99aa1..c49b9ee 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = ""; + 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 = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; diff --git a/go.mod b/go.mod index 43e358f..0e5b916 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,20 @@ go 1.25.8 require ( charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.2 charm.land/glamour/v2 v2.0.0 charm.land/lipgloss/v2 v2.0.3 + github.com/charmbracelet/x/ansi v0.11.7 gopkg.in/yaml.v3 v3.0.1 ) require ( - charm.land/bubbletea/v2 v2.0.2 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -24,12 +25,14 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect diff --git a/go.sum b/go.sum index 5b7e997..5b5d7fc 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= @@ -42,10 +44,14 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= @@ -56,6 +62,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= diff --git a/helpbar/README.md b/helpbar/README.md new file mode 100644 index 0000000..c96dda2 --- /dev/null +++ b/helpbar/README.md @@ -0,0 +1,13 @@ +# helpbar + +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. + +- 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. + +See `examples/helpbar`. diff --git a/helpbar/helpbar.go b/helpbar/helpbar.go new file mode 100644 index 0000000..b83e9a6 --- /dev/null +++ b/helpbar/helpbar.go @@ -0,0 +1,118 @@ +package helpbar + +import ( + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/bubbles" +) + +type Model struct { + ShowAll bool + + help help.Model + global []key.Binding + toggle key.Binding + width int +} + +type Option func(*Model) + +func WithGlobal(bindings ...key.Binding) Option { + return func(m *Model) { m.global = bindings } +} + +func WithToggle(b key.Binding) Option { + return func(m *Model) { m.toggle = b } +} + +func WithStyles(s help.Styles) Option { + return func(m *Model) { m.help.Styles = s } +} + +func New(opts ...Option) Model { + m := Model{help: bubbles.NewHelp()} + for _, opt := range opts { + opt(&m) + } + return m +} + +func (m *Model) SetWidth(w int) { + m.width = w + m.help.SetWidth(w) +} + +func (m Model) Width() int { return m.width } + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyPressMsg); ok && key.Matches(keyMsg, m.toggle) { + m.ShowAll = !m.ShowAll + } + return m, nil +} + +func (m Model) View(contextual ...key.Binding) string { + bindings := m.bindings(contextual) + if len(bindings) == 0 || m.width <= 0 { + return "" + } + if !m.ShowAll { + return m.help.ShortHelpView(bindings) + } + return m.help.FullHelpView(m.columns(bindings)) +} + +func (m Model) Height(contextual ...key.Binding) int { + view := m.View(contextual...) + if view == "" { + return 0 + } + return lipgloss.Height(view) +} + +func (m Model) bindings(contextual []key.Binding) []key.Binding { + all := make([]key.Binding, 0, 1+len(m.global)+len(contextual)) + if m.toggle.Enabled() { + all = append(all, m.toggle) + } + for _, b := range append(append([]key.Binding{}, m.global...), contextual...) { + if b.Enabled() { + all = append(all, b) + } + } + return all +} + +func (m Model) columns(bindings []key.Binding) [][]key.Binding { + if m.width <= 0 { + return [][]key.Binding{bindings} + } + for rows := 1; rows < len(bindings); rows++ { + groups := chunkColumns(bindings, rows) + if m.renderedWidth(groups) <= m.width { + return groups + } + } + + return chunkColumns(bindings, len(bindings)) +} + +func (m Model) renderedWidth(groups [][]key.Binding) int { + unbounded := m.help + unbounded.SetWidth(0) + return lipgloss.Width(unbounded.FullHelpView(groups)) +} + +func chunkColumns(bindings []key.Binding, rows int) [][]key.Binding { + if rows < 1 { + rows = 1 + } + groups := make([][]key.Binding, 0, (len(bindings)+rows-1)/rows) + for i := 0; i < len(bindings); i += rows { + groups = append(groups, bindings[i:min(i+rows, len(bindings))]) + } + return groups +} diff --git a/ilovetui.go b/ilovetui.go index 39d309e..4f3b6d8 100644 --- a/ilovetui.go +++ b/ilovetui.go @@ -1,116 +1 @@ -// Package ilovetui provides a shared Base16 color theme for bubbletea/lipgloss -// applications. The theme is loaded automatically on import from -// ~/.config/ilovetui/config.yaml (falling back to the embedded -// default config). Access colors and styles via the package-level variable S. -// -// import "github.com/anotherhadi/ilovetui" -// -// style := lipgloss.NewStyle().Foreground(ilovetui.S.Primary) -// box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Title", content, w, h) package ilovetui - -import ( - _ "embed" - "fmt" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -//go:embed default.yaml -var DefaultConfig []byte - -// S is the active theme. It is populated automatically at import time and can -// be reloaded at any point by calling Init, InitFrom, or InitFromBytes. -var S Styles - -func init() { - path := DefaultConfigPath() - if data, err := os.ReadFile(path); err == nil { - if s, err := stylesFromBytes(data); err == nil { - S = s - return - } - } - // Silent fallback: embedded default always works. - s, _ := stylesFromBytes(DefaultConfig) - S = s -} - -// Init reloads S from the user config file, falling back to the embedded -// default if the file is missing. Returns an error only on parse failures. -func Init() error { - path := DefaultConfigPath() - data, err := os.ReadFile(path) - if err != nil { - s, e := stylesFromBytes(DefaultConfig) - if e != nil { - return e - } - S = s - return nil - } - return InitFromBytes(data) -} - -// InitFrom reloads S from an explicit file path. -func InitFrom(path string) error { - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("ilovetui: read config: %w", err) - } - return InitFromBytes(data) -} - -// InitFromBytes reloads S from raw YAML. Accepts hex strings with or without -// the leading '#'. -func InitFromBytes(data []byte) error { - s, err := stylesFromBytes(data) - if err != nil { - return err - } - S = s - return nil -} - -// WriteDefaultConfig writes the embedded default config to path, creating -// parent directories as needed. No-op if the file already exists. -func WriteDefaultConfig(path string) error { - if _, err := os.Stat(path); err == nil { - return nil - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("ilovetui: create config dir: %w", err) - } - if err := os.WriteFile(path, DefaultConfig, 0o600); err != nil { - return fmt.Errorf("ilovetui: write config: %w", err) - } - return nil -} - -// DefaultConfigPath returns the canonical user config path, -// respecting $XDG_CONFIG_HOME. -func DefaultConfigPath() string { - return filepath.Join(configDir(), "ilovetui", "config.yaml") -} - -func stylesFromBytes(data []byte) (Styles, error) { - var base configYAML - if err := yaml.Unmarshal(DefaultConfig, &base); err != nil { - return Styles{}, fmt.Errorf("ilovetui: parse default config: %w", err) - } - var user configYAML - if err := yaml.Unmarshal(data, &user); err != nil { - return Styles{}, fmt.Errorf("ilovetui: parse config: %w", err) - } - return newStyles(mergeColors(base.Colors, user.Colors)), nil -} - -func configDir() string { - if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { - return dir - } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config") -} diff --git a/modal/README.md b/modal/README.md new file mode 100644 index 0000000..6c7831e --- /dev/null +++ b/modal/README.md @@ -0,0 +1,15 @@ +# modal + +A centered popup box on top of a dimmed background, triggered from anywhere in a bubbletea program +via an exported `tea.Msg` (`ShowMsg`/`Show`), not a direct reference to the `Model` that renders it. +Composites over an already-rendered string, so it makes no assumption about how the host builds +that string. + +- A modal's content is a `tea.Model`, not a string: it gets `Init`/`Update` while it's on top, and + 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. + +See `examples/modal`. diff --git a/modal/content.go b/modal/content.go new file mode 100644 index 0000000..19e4029 --- /dev/null +++ b/modal/content.go @@ -0,0 +1,46 @@ +package modal + +import tea "charm.land/bubbletea/v2" + +type Modal struct { + Title string + + Content tea.Model + + Style *Styles +} + +type ModalOption func(*Modal) + +func WithModalStyle(s Styles) ModalOption { + return func(mo *Modal) { mo.Style = &s } +} + +func newModal(title string, content tea.Model, opts ...ModalOption) Modal { + mo := Modal{Title: title, Content: content} + for _, opt := range opts { + opt(&mo) + } + return mo +} + +type ShowMsg struct{ Modal Modal } + +func Show(title string, content tea.Model, opts ...ModalOption) tea.Cmd { + mo := newModal(title, content, opts...) + return func() tea.Msg { return ShowMsg{Modal: mo} } +} + +type DismissMsg struct{} + +func Close() tea.Cmd { + return func() tea.Msg { return DismissMsg{} } +} + +type text string + +func (t text) Init() tea.Cmd { return nil } +func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil } +func (t text) View() tea.View { return tea.NewView(string(t)) } + +func Text(s string) tea.Model { return text(s) } diff --git a/modal/modal.go b/modal/modal.go new file mode 100644 index 0000000..572327e --- /dev/null +++ b/modal/modal.go @@ -0,0 +1,80 @@ +package modal + +import tea "charm.land/bubbletea/v2" + +type Model struct { + modals []Modal + maxWidth int + maxHeight int + styles Styles +} + +type Option func(*Model) + +func WithMaxWidth(w int) Option { + return func(m *Model) { m.maxWidth = w } +} + +func WithMaxHeight(h int) Option { + return func(m *Model) { m.maxHeight = h } +} + +func WithStyles(s Styles) Option { + return func(m *Model) { m.styles = s } +} + +func New(opts ...Option) Model { + m := Model{ + maxWidth: 60, + maxHeight: 20, + styles: DefaultStyles(), + } + for _, opt := range opts { + opt(&m) + } + return m +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case ShowMsg: + return m.show(msg.Modal) + case DismissMsg: + return m.pop(), nil + } + return m.updateTop(msg) +} + +func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) { + i := len(m.modals) - 1 + if i < 0 || m.modals[i].Content == nil { + return m, nil + } + var cmd tea.Cmd + m.modals[i].Content, cmd = m.modals[i].Content.Update(msg) + return m, cmd +} + +func (m Model) Open() bool { return len(m.modals) > 0 } + +func (m Model) show(mo Modal) (Model, tea.Cmd) { + m.modals = append(m.modals, mo) + return m, initContent(mo) +} + +func initContent(mo Modal) tea.Cmd { + if mo.Content == nil { + return nil + } + return mo.Content.Init() +} + +func (m Model) pop() Model { + if len(m.modals) == 0 { + return m + } + m.modals = m.modals[:len(m.modals)-1] + return m +} diff --git a/modal/render.go b/modal/render.go new file mode 100644 index 0000000..d94ae00 --- /dev/null +++ b/modal/render.go @@ -0,0 +1,113 @@ +package modal + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/anotherhadi/ilovetui/style" +) + +const margin = 2 + +func (m Model) Render(background string) string { + if len(m.modals) == 0 { + return background + } + + result := background + for _, mo := range m.modals { + w, h := lipgloss.Width(result), lipgloss.Height(result) + if w <= 0 || h <= 0 { + return result + } + result = m.renderOne(mo, result, w, h) + } + return result +} + +func (m Model) View(width, height int) string { + return m.Render(blank(width, height)) +} + +func (m Model) renderOne(mo Modal, background string, w, h int) string { + s := m.styles + if mo.Style != nil { + s = *mo.Style + } + + box := m.renderBox(mo, s, w, h) + bw, bh := lipgloss.Width(box), lipgloss.Height(box) + x, y := max((w-bw)/2, 0), max((h-bh)/2, 0) + + compositor := lipgloss.NewCompositor( + lipgloss.NewLayer(dim(background, s.DimColor)), + lipgloss.NewLayer(box).X(x).Y(y).Z(1), + ) + return compositor.Render() +} + +func dim(s string, c color.Color) string { + return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s)) +} + +func (m Model) renderBox(mo Modal, s Styles, bgW, bgH int) string { + maxW := effectiveMax(m.maxWidth, bgW-2*margin) + maxH := effectiveMax(m.maxHeight, bgH-2*margin) + + body := contentView(mo) + inner := contentWidth(body, mo.Title, maxW) + content := s.Content.Width(inner).Render(body) + + boxWidth := inner + 4 + boxHeight := min(lipgloss.Height(content)+2, maxH) + + return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight) +} + +func contentView(mo Modal) string { + if mo.Content == nil { + return "" + } + return mo.Content.View().Content +} + +func contentWidth(body, title string, maxWidth int) int { + natural := max(naturalWidth(body), lipgloss.Width(title), 1) + capped := max(maxWidth-4, 1) + return min(natural, capped) +} + +func naturalWidth(content string) int { + w := 0 + for _, line := range strings.Split(content, "\n") { + if lw := lipgloss.Width(line); lw > w { + w = lw + } + } + return w +} + +func effectiveMax(configured, fits int) int { + if fits < 1 { + fits = 1 + } + if configured > 0 && configured < fits { + return configured + } + return fits +} + +func blank(width, height int) string { + if width <= 0 || height <= 0 { + return "" + } + line := strings.Repeat(" ", width) + lines := make([]string, height) + for i := range lines { + lines[i] = line + } + return strings.Join(lines, "\n") +} diff --git a/modal/styles.go b/modal/styles.go new file mode 100644 index 0000000..6530447 --- /dev/null +++ b/modal/styles.go @@ -0,0 +1,25 @@ +package modal + +import ( + "image/color" + + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +type Styles struct { + Border lipgloss.Style + Title lipgloss.Style + Content lipgloss.Style + DimColor color.Color +} + +func DefaultStyles() Styles { + return Styles{ + Border: style.S.PanelFocused.Padding(0, 1), + Title: lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary), + Content: lipgloss.NewStyle().Foreground(style.S.Text), + DimColor: style.S.Subtle, + } +} diff --git a/nix/shell.nix b/nix/shell.nix index f195e2f..c787b15 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -15,6 +15,7 @@ in [ go doctoc + (python3.withPackages (ps: [ps.pyte])) ] ++ hooks.enabledPackages; diff --git a/nix/tui-snapshot.py b/nix/tui-snapshot.py new file mode 100644 index 0000000..69af579 --- /dev/null +++ b/nix/tui-snapshot.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Run a TUI binary in a virtual terminal (via pyte) and print its rendered +screen as plain text, for visual verification of bubbletea/lipgloss output. + +Dev tooling only, provided by the nix-shell. Not part of the Go module. + +Usage: + tui-snapshot.py [--width W] [--height H] [--wait SECONDS] [--keys 'k:delay,...'] -- [args...] + +Example: + tui-snapshot.py --keys 'l:0.2,l:0.2,q:0' -- go run ./examples/tabs +""" + +import argparse +import fcntl +import os +import pty +import select +import struct +import sys +import termios +import time + +import pyte + +class RepeatAwareScreen(pyte.Screen): + """pyte has no support for the ECMA-48 REP sequence (``CSI Ps b``, + "repeat the preceding graphic character Ps times"), which bubbletea's + renderer uses to compress runs of identical styled cells (e.g. long + border/padding runs). Left unhandled, pyte silently drops it, corrupting + the frame. This adds the missing handler.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._last_drawn = " " + + def draw(self, data): + super().draw(data) + if data: + self._last_drawn = data[-1] + + def repeat_last_character(self, count=1): + self.draw(self._last_drawn * (count or 1)) + + +class RepeatAwareStream(pyte.Stream): + """Also works around a pyte bug: it maps ECMA-48 HPA (Character Position + Absolute, "move cursor to column Ps") to "'" (apostrophe, 0x27) instead + of the actual standard character "`" (backtick, 0x60) - see pyte's + escape.py. Real terminal apps send the correct backtick, which pyte then + silently no-ops on since it's not in its dispatch table, leaving the + cursor stuck instead of moving it. bubbletea's renderer uses HPA (mixed + with CUF) to jump the cursor to a border's column after erasing padding + with ECH, so without this the trailing border character gets drawn right + after the leading one instead of at the far edge.""" + + csi = { + **pyte.Stream.csi, + "b": "repeat_last_character", + "`": "cursor_to_column", + } + + +KEYMAP = { + "enter": "\r", + "esc": "\x1b", + "tab": "\t", + "up": "\x1b[A", + "down": "\x1b[B", + "right": "\x1b[C", + "left": "\x1b[D", + "space": " ", +} + + +def encode_key(key): + """Resolve a --keys token to the bytes to write to the pty. Falls back to + the literal string for a plain character (e.g. 'l'), but without this, + something like 'ctrl+l' would be sent as the 6 literal characters + 'c','t','r','l','+','l' instead of the single 0x0C control byte a real + terminal would send for Ctrl-L - silently testing the wrong thing.""" + if key in KEYMAP: + return KEYMAP[key] + if key.startswith("ctrl+") and len(key) == 6 and key[5].isalpha(): + return chr(ord(key[5].lower()) - ord("a") + 1) + return key + + +def set_size(fd, rows, cols): + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--width", type=int, default=80) + parser.add_argument("--height", type=int, default=24) + parser.add_argument( + "--wait", type=float, default=1.0, help="seconds to let the app render before the snapshot" + ) + parser.add_argument( + "--keys", + default="", + help="comma-separated key:delay pairs sent before the final snapshot, e.g. 'l:0.2,l:0.2,q:0'", + ) + parser.add_argument( + "--colors", + action="store_true", + help="also print, per row, the foreground color of each non-blank cell (for border/color bugs " + "that don't show up in the plain-text dump)", + ) + parser.add_argument("cmd", nargs=argparse.REMAINDER) + args = parser.parse_args() + + cmd = args.cmd + if cmd and cmd[0] == "--": + cmd = cmd[1:] + if not cmd: + parser.error("missing command to run") + + screen = RepeatAwareScreen(args.width, args.height) + stream = RepeatAwareStream(screen) + + # Open the pty and set its size *before* forking, so the child never + # observes a stale/default size on its first render (pyte has no way to + # recover from a corrupted initial frame drawn at the wrong width). + master_fd, slave_fd = pty.openpty() + set_size(slave_fd, args.height, args.width) + + pid = os.fork() + if pid == 0: + os.close(master_fd) + os.setsid() + fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) + os.dup2(slave_fd, 0) + os.dup2(slave_fd, 1) + os.dup2(slave_fd, 2) + os.close(slave_fd) + os.execvp(cmd[0], cmd) + os._exit(1) + + os.close(slave_fd) + fd = master_fd + + def pump(duration): + end = time.time() + duration + while True: + remaining = end - time.time() + if remaining <= 0: + break + r, _, _ = select.select([fd], [], [], remaining) + if fd not in r: + continue + try: + data = os.read(fd, 65536) + except OSError: + break + if not data: + break + stream.feed(data.decode(errors="ignore")) + + pump(args.wait) + + for pair in filter(None, args.keys.split(",")): + key, _, delay = pair.partition(":") + try: + os.write(fd, encode_key(key).encode()) + except OSError: + break + pump(float(delay) if delay else 0.3) + + try: + os.kill(pid, 15) + os.waitpid(pid, 0) + except (ProcessLookupError, ChildProcessError): + pass + + for y, line in enumerate(screen.display): + print(line.rstrip()) + if args.colors: + row = screen.buffer[y] + cells = [] + for x in sorted(row): + ch = row[x] + if ch.data.strip(): + fg = ch.fg if ch.fg != "default" else "-" + cells.append(f"{x}:{ch.data!r}:{fg}") + if cells: + print(" " + " ".join(cells)) + + +if __name__ == "__main__": + main() diff --git a/notification/README.md b/notification/README.md new file mode 100644 index 0000000..63b61a0 --- /dev/null +++ b/notification/README.md @@ -0,0 +1,14 @@ +# notification + +Toast notifications, triggered from anywhere in a bubbletea program via an exported `tea.Msg` +(`ShowMsg`/`Show`), not a direct reference to the `Model` that renders them. Composites over an +already-rendered string, so it makes no assumption about how the host builds that string. + +- Four kinds: `Info`, `Success`, `Warning`, `Error`, each with its own `style.S` color preset. +- 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. + +See `examples/notification`. diff --git a/notification/notification.go b/notification/notification.go new file mode 100644 index 0000000..99ba12a --- /dev/null +++ b/notification/notification.go @@ -0,0 +1,93 @@ +package notification + +import ( + "fmt" + "time" + + tea "charm.land/bubbletea/v2" +) + +type Model struct { + toasts []Toast + nextID int + position Position + maxWidth int + styles Styles +} + +type Option func(*Model) + +func WithPosition(p Position) Option { + return func(m *Model) { m.position = p } +} + +func WithMaxWidth(w int) Option { + return func(m *Model) { m.maxWidth = w } +} + +func WithStyles(s Styles) Option { + return func(m *Model) { m.styles = s } +} + +func New(opts ...Option) Model { + m := Model{ + position: TopRight, + maxWidth: 40, + styles: DefaultStyles(), + } + for _, opt := range opts { + opt(&m) + } + return m +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case ShowMsg: + return m.show(msg.Toast) + case DismissMsg: + return m.remove(msg.ID), nil + case expireMsg: + return m.remove(msg.id), nil + } + return m, nil +} + +func (m Model) show(t Toast) (Model, tea.Cmd) { + if t.ID == "" { + t.ID = fmt.Sprintf("toast-%d", m.nextID) + m.nextID++ + } + + replaced := false + for i, existing := range m.toasts { + if existing.ID == t.ID { + m.toasts[i] = t + replaced = true + break + } + } + if !replaced { + m.toasts = append(m.toasts, t) + } + + if t.Duration <= 0 { + return m, nil + } + id := t.ID + return m, tea.Tick(t.Duration, func(time.Time) tea.Msg { + return expireMsg{id: id} + }) +} + +func (m Model) remove(id string) Model { + for i, t := range m.toasts { + if t.ID == id { + m.toasts = append(m.toasts[:i], m.toasts[i+1:]...) + break + } + } + return m +} diff --git a/notification/position.go b/notification/position.go new file mode 100644 index 0000000..b3bcc66 --- /dev/null +++ b/notification/position.go @@ -0,0 +1,48 @@ +package notification + +type Position int + +const ( + Top Position = iota + TopLeft + TopRight + Bottom + BottomLeft + BottomRight +) + +const margin = 1 + +func placement(pos Position, w, h, sw, sh int) (x, y int) { + switch pos { + case Top: + x = (w - sw) / 2 + y = margin + case TopLeft: + x = margin + y = margin + case TopRight: + x = w - sw - margin + y = margin + case Bottom: + x = (w - sw) / 2 + y = h - sh - margin + case BottomLeft: + x = margin + y = h - sh - margin + case BottomRight: + x = w - sw - margin + y = h - sh - margin + } + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + return x, y +} + +func (pos Position) anchoredTop() bool { + return pos == Top || pos == TopLeft || pos == TopRight +} diff --git a/notification/render.go b/notification/render.go new file mode 100644 index 0000000..6b6372d --- /dev/null +++ b/notification/render.go @@ -0,0 +1,127 @@ +package notification + +import ( + "strings" + + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +func (m Model) Render(background string) string { + if len(m.toasts) == 0 { + return background + } + w, h := lipgloss.Width(background), lipgloss.Height(background) + if w <= 0 || h <= 0 { + return background + } + + stack := clipToHeight(m.renderStack(effectiveMaxWidth(m.maxWidth, w)), h-2*margin, m.position.anchoredTop()) + if stack == "" { + return background + } + sw, sh := lipgloss.Width(stack), lipgloss.Height(stack) + x, y := placement(m.position, w, h, sw, sh) + + compositor := lipgloss.NewCompositor( + lipgloss.NewLayer(background), + lipgloss.NewLayer(stack).X(x).Y(y).Z(1), + ) + return compositor.Render() +} + +func (m Model) View(width, height int) string { + return m.Render(blank(width, height)) +} + +func (m Model) renderStack(maxWidth int) string { + ordered := m.orderedToasts() + parts := make([]string, 0, len(ordered)*2-1) + for i, t := range ordered { + if i > 0 { + parts = append(parts, "") + } + parts = append(parts, m.renderToast(t, maxWidth)) + } + return lipgloss.JoinVertical(stackAlign(m.position), parts...) +} + +func (m Model) orderedToasts() []Toast { + if !m.position.anchoredTop() { + return m.toasts + } + ordered := make([]Toast, len(m.toasts)) + for i, t := range m.toasts { + ordered[len(m.toasts)-1-i] = t + } + return ordered +} + +func stackAlign(pos Position) lipgloss.Position { + switch pos { + case TopLeft, BottomLeft: + return lipgloss.Left + case TopRight, BottomRight: + return lipgloss.Right + default: + return lipgloss.Center + } +} + +func (m Model) renderToast(t Toast, maxWidth int) string { + k := m.styles.forKind(t) + + inner := contentWidth(t, maxWidth) + message := k.Message.Width(inner).Render(t.Message) + + boxWidth := inner + 4 + boxHeight := lipgloss.Height(message) + 2 + + return style.RenderWithTitle(k.Border, k.Title.Render(t.Title), message, boxWidth, boxHeight) +} + +func contentWidth(t Toast, maxWidth int) int { + natural := max(lipgloss.Width(t.Title), lipgloss.Width(t.Message), 1) + if maxWidth <= 0 { + return natural + } + capped := max(maxWidth-4, 1) + return min(natural, capped) +} + +func effectiveMaxWidth(configured, bgWidth int) int { + fits := max(bgWidth-2*margin, 1) + if configured > 0 && configured < fits { + return configured + } + return fits +} + +func clipToHeight(stack string, maxHeight int, anchoredTop bool) string { + lines := strings.Split(stack, "\n") + if len(lines) <= maxHeight { + return stack + } + if maxHeight <= 0 { + return "" + } + if anchoredTop { + lines = lines[:maxHeight] + } else { + lines = lines[len(lines)-maxHeight:] + } + return strings.Join(lines, "\n") +} + +func blank(width, height int) string { + if width <= 0 || height <= 0 { + return "" + } + line := strings.Repeat(" ", width) + lines := make([]string, height) + for i := range lines { + lines[i] = line + } + return strings.Join(lines, "\n") +} diff --git a/notification/styles.go b/notification/styles.go new file mode 100644 index 0000000..8ca6ea0 --- /dev/null +++ b/notification/styles.go @@ -0,0 +1,58 @@ +package notification + +import ( + "image/color" + + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +type KindStyle struct { + Border lipgloss.Style + Title lipgloss.Style + Message lipgloss.Style +} + +type Styles struct { + Info KindStyle + Success KindStyle + Warning KindStyle + Error KindStyle +} + +func DefaultStyles() Styles { + return Styles{ + Info: kindStyle(style.S.Primary), + Success: kindStyle(style.S.Success), + Warning: kindStyle(style.S.Warning), + Error: kindStyle(style.S.Error), + } +} + +func kindStyle(c color.Color) KindStyle { + return KindStyle{ + Border: lipgloss.NewStyle(). + Border(style.S.BorderType). + BorderForeground(c). + Padding(0, 1), + Title: lipgloss.NewStyle().Bold(true).Foreground(c), + Message: lipgloss.NewStyle().Foreground(style.S.Text), + } +} + +func (s Styles) forKind(t Toast) KindStyle { + if t.Style != nil { + return *t.Style + } + switch t.Kind { + case Success: + return s.Success + case Warning: + return s.Warning + case Error: + return s.Error + default: + return s.Info + } +} diff --git a/notification/toast.go b/notification/toast.go new file mode 100644 index 0000000..41f5f12 --- /dev/null +++ b/notification/toast.go @@ -0,0 +1,71 @@ +package notification + +import ( + "time" + + tea "charm.land/bubbletea/v2" +) + +type Kind int + +const ( + Info Kind = iota + Success + Warning + Error +) + +const DefaultDuration = 3 * time.Second + +type Toast struct { + ID string + Title string + Message string + Kind Kind + + Duration time.Duration + + Style *KindStyle +} + +type ToastOption func(*Toast) + +func WithID(id string) ToastOption { + return func(t *Toast) { t.ID = id } +} + +func WithDuration(d time.Duration) ToastOption { + return func(t *Toast) { t.Duration = d } +} + +func WithToastStyle(s KindStyle) ToastOption { + return func(t *Toast) { t.Style = &s } +} + +func newToast(title, message string, kind Kind, opts ...ToastOption) Toast { + t := Toast{ + Title: title, + Message: message, + Kind: kind, + Duration: DefaultDuration, + } + for _, opt := range opts { + opt(&t) + } + return t +} + +type ShowMsg struct{ Toast Toast } + +func Show(title, message string, kind Kind, opts ...ToastOption) tea.Cmd { + t := newToast(title, message, kind, opts...) + return func() tea.Msg { return ShowMsg{Toast: t} } +} + +type DismissMsg struct{ ID string } + +func Dismiss(id string) tea.Cmd { + return func() tea.Msg { return DismissMsg{ID: id} } +} + +type expireMsg struct{ id string } diff --git a/style/README.md b/style/README.md new file mode 100644 index 0000000..69f0b79 --- /dev/null +++ b/style/README.md @@ -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`. diff --git a/border.go b/style/border.go similarity index 50% rename from border.go rename to style/border.go index 7245ad1..e57f73d 100644 --- a/border.go +++ b/style/border.go @@ -1,4 +1,4 @@ -package ilovetui +package style import ( "strings" @@ -6,7 +6,22 @@ import ( "charm.land/lipgloss/v2" ) -// ContentHeight returns the usable inner height for a bordered panel of totalH rows. +var borderTypes = map[string]lipgloss.Border{ + "rounded": lipgloss.RoundedBorder(), + "normal": lipgloss.NormalBorder(), + "thick": lipgloss.ThickBorder(), + "double": lipgloss.DoubleBorder(), + "hidden": lipgloss.HiddenBorder(), + "ascii": lipgloss.ASCIIBorder(), +} + +func resolveBorderType(name string) lipgloss.Border { + if b, ok := borderTypes[strings.ToLower(strings.TrimSpace(name))]; ok { + return b + } + return lipgloss.RoundedBorder() +} + func ContentHeight(totalH int) int { h := totalH - 2 if h < 0 { @@ -15,12 +30,6 @@ func ContentHeight(totalH int) int { return h } -// RenderWithTitle renders a bordered box with a title embedded in the top border. -// title may contain ANSI color codes. width and height are the total outer dimensions. -// -// Example: -// -// box := ilovetui.RenderWithTitle(theme.Styles.PanelFocused, "Header", content, w, h) func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string { boxH := height - 1 if contentH := boxH - 1; contentH > 0 { @@ -33,12 +42,16 @@ func RenderWithTitle(border lipgloss.Style, title, content string, width, height boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0]) titleW := lipgloss.Width(title) - fillW := boxWidth - titleW - 4 // 4 = "╭ " + " " + "╮" + + b, _, _, _, _ := border.GetBorder() + topLeft, top, topRight := b.TopLeft, b.Top, b.TopRight + + fillW := boxWidth - titleW - lipgloss.Width(topLeft) - lipgloss.Width(topRight) - 2 if fillW < 0 { fillW = 0 } bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground()) - topLine := bc.Render("╭ ") + bc.Render(title) + bc.Render(" "+strings.Repeat("─", fillW)+"╮") + topLine := bc.Render(topLeft+" ") + bc.Render(title) + bc.Render(" "+strings.Repeat(top, fillW)+topRight) return lipgloss.JoinVertical(lipgloss.Left, topLine, box) } diff --git a/style/config.go b/style/config.go new file mode 100644 index 0000000..0f8cd20 --- /dev/null +++ b/style/config.go @@ -0,0 +1,63 @@ +package style + +type colorsYAML struct { + Base00 string `yaml:"base00"` + Base01 string `yaml:"base01"` + Base02 string `yaml:"base02"` + Base03 string `yaml:"base03"` + Base04 string `yaml:"base04"` + Base05 string `yaml:"base05"` + Base06 string `yaml:"base06"` + Base07 string `yaml:"base07"` + Base08 string `yaml:"base08"` + Base09 string `yaml:"base09"` + Base0A string `yaml:"base0a"` + Base0B string `yaml:"base0b"` + Base0C string `yaml:"base0c"` + Base0D string `yaml:"base0d"` + Base0E string `yaml:"base0e"` + Base0F string `yaml:"base0f"` +} + +type configYAML struct { + Colors colorsYAML `yaml:"colors"` + NerdFonts bool `yaml:"nerd_fonts"` + Border string `yaml:"border"` +} + +func pickString(base, user string) string { + if user != "" { + return user + } + return base +} + +func mergeConfig(base, user configYAML) configYAML { + return configYAML{ + Colors: mergeColors(base.Colors, user.Colors), + + NerdFonts: base.NerdFonts || user.NerdFonts, + Border: pickString(base.Border, user.Border), + } +} + +func mergeColors(base, user colorsYAML) colorsYAML { + return colorsYAML{ + Base00: pickString(base.Base00, user.Base00), + Base01: pickString(base.Base01, user.Base01), + Base02: pickString(base.Base02, user.Base02), + Base03: pickString(base.Base03, user.Base03), + Base04: pickString(base.Base04, user.Base04), + Base05: pickString(base.Base05, user.Base05), + Base06: pickString(base.Base06, user.Base06), + Base07: pickString(base.Base07, user.Base07), + Base08: pickString(base.Base08, user.Base08), + Base09: pickString(base.Base09, user.Base09), + Base0A: pickString(base.Base0A, user.Base0A), + Base0B: pickString(base.Base0B, user.Base0B), + Base0C: pickString(base.Base0C, user.Base0C), + Base0D: pickString(base.Base0D, user.Base0D), + Base0E: pickString(base.Base0E, user.Base0E), + Base0F: pickString(base.Base0F, user.Base0F), + } +} diff --git a/default.yaml b/style/default.yaml similarity index 73% rename from default.yaml rename to style/default.yaml index e265927..cbfa8ea 100644 --- a/default.yaml +++ b/style/default.yaml @@ -1,5 +1,14 @@ -# ilovetui default theme +# ilovetui default config # Copy to ~/.config/ilovetui/config.yaml and edit to customize. + +# Whether components may use Nerd Font glyphs (requires a patched font). +# Leave false for maximum terminal/font compatibility. +nerd_fonts: false + +# Border style used by panels across all ilovetui-based TUIs. +# One of: rounded, normal, thick, double, hidden, ascii. +border: rounded + colors: base00: "#110F12" # Background base01: "#1C1920" # Lighter Background / Status Bars diff --git a/glamour.go b/style/glamour.go similarity index 98% rename from glamour.go rename to style/glamour.go index e0520c8..7d7bb04 100644 --- a/glamour.go +++ b/style/glamour.go @@ -1,4 +1,4 @@ -package ilovetui +package style import ( "fmt" @@ -13,7 +13,6 @@ func hexColor(c color.Color) *string { return &s } -// GlamourStyleConfig returns a glamour ansi.StyleConfig using the active theme. func GlamourStyleConfig() ansi.StyleConfig { str := func(s string) *string { return &s } boolPtr := func(b bool) *bool { return &b } diff --git a/style.go b/style/style.go similarity index 83% rename from style.go rename to style/style.go index 8fe5461..59644fa 100644 --- a/style.go +++ b/style/style.go @@ -1,4 +1,4 @@ -package ilovetui +package style import ( "image/color" @@ -7,10 +7,7 @@ import ( "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 { - // Raw Base16 palette Base00 color.Color Base01 color.Color Base02 color.Color @@ -28,7 +25,6 @@ type Styles struct { Base0E color.Color Base0F color.Color - // Semantic color aliases Background color.Color SubtleBg color.Color Selection color.Color @@ -40,20 +36,20 @@ type Styles struct { Warning color.Color Error color.Color - // Pre-built text styles Bold lipgloss.Style Faint lipgloss.Style - // Pre-built panel styles (rounded border) + NerdFonts bool + BorderType lipgloss.Border + Panel lipgloss.Style PanelFocused lipgloss.Style - // Pre-rendered pager dot strings PagerDotActive string PagerDotInactive string } -func newStyles(c colorsYAML) Styles { +func newStyles(c colorsYAML, nerdFonts bool, borderName string) Styles { lc := func(s string) color.Color { s = strings.TrimSpace(s) if s != "" && s[0] != '#' { @@ -79,6 +75,8 @@ func newStyles(c colorsYAML) Styles { b0E := lc(c.Base0E) b0F := lc(c.Base0F) + borderType := resolveBorderType(borderName) + return Styles{ Base00: b00, Base01: b01, Base02: b02, Base03: b03, Base04: b04, Base05: b05, Base06: b06, Base07: b07, @@ -99,12 +97,15 @@ func newStyles(c colorsYAML) Styles { Bold: lipgloss.NewStyle().Bold(true), Faint: lipgloss.NewStyle().Foreground(b03).Faint(true), + NerdFonts: nerdFonts, + BorderType: borderType, + Panel: lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). + Border(borderType). BorderForeground(b03), PanelFocused: lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). + Border(borderType). BorderForeground(b0D), PagerDotActive: lipgloss.NewStyle().Foreground(b0D).SetString("•").String(), diff --git a/style/theme.go b/style/theme.go new file mode 100644 index 0000000..61851a5 --- /dev/null +++ b/style/theme.go @@ -0,0 +1,84 @@ +package style + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +//go:embed default.yaml +var DefaultConfig []byte + +var S Styles + +func init() { + path := DefaultConfigPath() + if data, err := os.ReadFile(path); err == nil { + if s, err := stylesFromBytes(data); err == nil { + S = s + return + } + } + + s, _ := stylesFromBytes(DefaultConfig) + S = s +} + +func Init() error { + path := DefaultConfigPath() + data, err := os.ReadFile(path) + if err != nil { + s, e := stylesFromBytes(DefaultConfig) + if e != nil { + return e + } + S = s + return nil + } + return InitFromBytes(data) +} + +func InitFrom(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("style: read config: %w", err) + } + return InitFromBytes(data) +} + +func InitFromBytes(data []byte) error { + s, err := stylesFromBytes(data) + if err != nil { + return err + } + S = s + return nil +} + +func DefaultConfigPath() string { + return filepath.Join(configDir(), "ilovetui", "config.yaml") +} + +func stylesFromBytes(data []byte) (Styles, error) { + var base configYAML + if err := yaml.Unmarshal(DefaultConfig, &base); err != nil { + return Styles{}, fmt.Errorf("style: parse default config: %w", err) + } + var user configYAML + if err := yaml.Unmarshal(data, &user); err != nil { + return Styles{}, fmt.Errorf("style: parse config: %w", err) + } + merged := mergeConfig(base, user) + return newStyles(merged.Colors, merged.NerdFonts, merged.Border), nil +} + +func configDir() string { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return dir + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config") +} diff --git a/tabs/README.md b/tabs/README.md new file mode 100644 index 0000000..165b78c --- /dev/null +++ b/tabs/README.md @@ -0,0 +1,15 @@ +# tabs + +A horizontal tab bar styled from `style.S`. Renders the bar and the `Content` frame around the +active item; the content itself runs and renders through the host, same as any other custom +component in this repo. + +- `Focused()` controls the frame's border color, independent of which tab is active (that's shown + 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. + +See `examples/tabs`. diff --git a/tabs/tabs.go b/tabs/tabs.go new file mode 100644 index 0000000..d7799a0 --- /dev/null +++ b/tabs/tabs.go @@ -0,0 +1,454 @@ +package tabs + +import ( + "fmt" + "image/color" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/anotherhadi/ilovetui/style" +) + +type Tab interface { + Init() tea.Cmd + Update(tea.Msg) (Tab, tea.Cmd) + View() string +} + +type Item struct { + Title string + Model Tab +} + +type KeyMap struct { + Next key.Binding + Prev key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Next: key.NewBinding( + key.WithKeys("right", "l", "tab"), + key.WithHelp("→/tab", "next tab"), + ), + Prev: key.NewBinding( + key.WithKeys("left", "h", "shift+tab"), + key.WithHelp("←/shift+tab", "previous tab"), + ), + } +} + +type Styles struct { + ActiveTab lipgloss.Style + InactiveTab lipgloss.Style + + ActiveTitle lipgloss.Style + InactiveTitle lipgloss.Style + + Content lipgloss.Style + + BorderType lipgloss.Border + + FocusedBorder color.Color + BlurredBorder color.Color +} + +func DefaultStyles() Styles { + bt := style.S.BorderType + inactiveBorder, activeBorder := tabBorders(bt) + + return Styles{ + InactiveTab: lipgloss.NewStyle(). + Border(inactiveBorder, true). + Padding(0, 1), + ActiveTab: lipgloss.NewStyle(). + Border(activeBorder, true). + Padding(0, 1), + + ActiveTitle: lipgloss.NewStyle().Foreground(style.S.Primary).Bold(true), + InactiveTitle: lipgloss.NewStyle().Foreground(style.S.Subtle), + + Content: lipgloss.NewStyle(). + Border(bt). + UnsetBorderTop(). + Padding(1, 2), + + BorderType: bt, + + FocusedBorder: style.S.Primary, + BlurredBorder: style.S.Subtle, + } +} + +func tabBorders(bt lipgloss.Border) (inactive, active lipgloss.Border) { + inactive = bt + inactive.BottomLeft = bt.MiddleBottom + inactive.BottomRight = bt.MiddleBottom + + active = bt + active.Bottom = " " + active.BottomLeft = bt.BottomRight + active.BottomRight = bt.BottomLeft + + return inactive, active +} + +type Model struct { + items []Item + active int + focused bool + loop bool + width int + height int + + styles Styles + keyMap KeyMap +} + +type Option func(*Model) + +func WithStyles(s Styles) Option { + return func(m *Model) { + m.styles = s + } +} + +func WithKeyMap(k KeyMap) Option { + return func(m *Model) { + m.keyMap = k + } +} + +func WithActive(i int) Option { + return func(m *Model) { + m.active = i + } +} + +func WithLoop(l bool) Option { + return func(m *Model) { + m.loop = l + } +} + +func WithFocus(f bool) Option { + return func(m *Model) { + m.focused = f + } +} + +func New(items []Item, opts ...Option) Model { + m := Model{ + items: items, + focused: true, + loop: true, + styles: DefaultStyles(), + keyMap: DefaultKeyMap(), + } + + for _, opt := range opts { + opt(&m) + } + + m.active = clamp(m.active, 0, len(m.items)-1) + + return m +} + +func (m Model) Init() tea.Cmd { + cmds := make([]tea.Cmd, len(m.items)) + for i, item := range m.items { + cmds[i] = item.Model.Init() + } + return tea.Batch(cmds...) +} + +func (m Model) Active() int { + return m.active +} + +func (m *Model) SetActive(i int) { + m.active = clamp(i, 0, len(m.items)-1) +} + +func (m Model) Items() []Item { + return m.items +} + +func (m Model) ActiveItem() Item { + return m.items[m.active] +} + +func (m Model) Focused() bool { + return m.focused +} + +func (m *Model) Focus() { + m.focused = true +} + +func (m *Model) Blur() { + m.focused = false +} + +func (m Model) Loop() bool { + return m.loop +} + +func (m *Model) SetLoop(l bool) { + m.loop = l +} + +func (m Model) Width() int { + return m.width +} + +func (m *Model) SetWidth(w int) { + m.width = w +} + +func (m Model) Height() int { + return m.height +} + +func (m *Model) SetHeight(h int) { + m.height = h +} + +func (m *Model) SetSize(w, h int) { + m.width = w + m.height = h +} + +func (m Model) ContentWidth() int { + if len(m.items) == 0 { + return 0 + } + w := lipgloss.Width(m.renderBar(m.collapsedSegments(m.width), m.styles.BlurredBorder, false)) + if m.width > w { + w = m.width + } + if inner := w - m.styles.Content.GetHorizontalFrameSize(); inner > 0 { + return inner + } + return 0 +} + +func (m Model) ContentHeight() int { + if len(m.items) == 0 || m.height <= 0 { + return 0 + } + barHeight := lipgloss.Height(m.renderBar(m.collapsedSegments(m.width), m.styles.BlurredBorder, false)) + if inner := m.height - barHeight - m.styles.Content.GetVerticalFrameSize(); inner > 0 { + return inner + } + return 0 +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyPressMsg); ok { + switch { + case key.Matches(keyMsg, m.keyMap.Next): + m.active = m.step(1) + return m, nil + case key.Matches(keyMsg, m.keyMap.Prev): + m.active = m.step(-1) + return m, nil + } + } + + if len(m.items) == 0 { + return m, nil + } + + var cmd tea.Cmd + m.items[m.active].Model, cmd = m.items[m.active].Model.Update(msg) + return m, cmd +} + +type tabSegment struct { + title string + isActive bool + isMore bool +} + +func (m Model) segments() []tabSegment { + segs := make([]tabSegment, len(m.items)) + for i, item := range m.items { + segs[i] = tabSegment{title: item.Title, isActive: i == m.active} + } + return segs +} + +func (m Model) segmentWidth(seg tabSegment) int { + tabStyle := m.styles.InactiveTab + titleStyle := m.styles.InactiveTitle + if seg.isActive { + tabStyle = m.styles.ActiveTab + titleStyle = m.styles.ActiveTitle + } + return lipgloss.Width(tabStyle.Render(titleStyle.Render(seg.title))) +} + +func (m Model) collapsedSegments(budget int) []tabSegment { + segs := m.segments() + if budget <= 0 { + return segs + } + + widths := make([]int, len(segs)) + total := 0 + for i, seg := range segs { + widths[i] = m.segmentWidth(seg) + total += widths[i] + } + if total <= budget { + return segs + } + + moreWidth := m.segmentWidth(tabSegment{title: fmt.Sprintf("+%d", len(segs)-1), isMore: true}) + fitBudget := budget - moreWidth + if fitBudget < widths[m.active] { + fitBudget = widths[m.active] + } + + start, end := m.active, m.active + used := widths[m.active] + for { + grew := false + if start > 0 && used+widths[start-1] <= fitBudget { + start-- + used += widths[start] + grew = true + } + if end < len(segs)-1 && used+widths[end+1] <= fitBudget { + end++ + used += widths[end] + grew = true + } + if !grew { + break + } + } + + hidden := len(segs) - (end - start + 1) + if hidden <= 0 { + return segs + } + + visible := append([]tabSegment{}, segs[start:end+1]...) + visible = append(visible, tabSegment{title: fmt.Sprintf("+%d", hidden), isMore: true}) + return visible +} + +func (m Model) View() string { + if len(m.items) == 0 { + return "" + } + + borderColor := m.styles.BlurredBorder + if m.focused { + borderColor = m.styles.FocusedBorder + } + + segs := m.collapsedSegments(m.width) + + bar := m.renderBar(segs, borderColor, false) + contentWidth := lipgloss.Width(bar) + + if m.width > contentWidth { + bar = m.extendBarCap(m.renderBar(segs, borderColor, true), m.width, borderColor) + contentWidth = m.width + } + contentStyle := m.styles.Content. + BorderForeground(borderColor). + Width(contentWidth) + if m.height > 0 { + if h := m.height - lipgloss.Height(bar); h > 0 { + contentStyle = contentStyle.Height(h) + } + } + + content := contentStyle.Render(m.items[m.active].Model.View()) + + return lipgloss.JoinVertical(lipgloss.Left, bar, content) +} + +func (m Model) renderBar(segs []tabSegment, borderColor color.Color, extendCap bool) string { + rendered := make([]string, len(segs)) + + for i, seg := range segs { + isFirst, isLast := i == 0, i == len(segs)-1 + + tabStyle := m.styles.InactiveTab + titleStyle := m.styles.InactiveTitle + if seg.isActive { + tabStyle = m.styles.ActiveTab + titleStyle = m.styles.ActiveTitle + } + tabStyle = tabStyle.BorderForeground(borderColor) + + bt := m.styles.BorderType + border, _, _, _, _ := tabStyle.GetBorder() + switch { + case isFirst && seg.isActive: + border.BottomLeft = bt.Left + case isFirst && !seg.isActive: + border.BottomLeft = bt.MiddleLeft + case isLast && seg.isActive && !extendCap: + border.BottomRight = bt.Right + case isLast && !seg.isActive && !extendCap: + border.BottomRight = bt.MiddleRight + } + + tabStyle = tabStyle.Border(border) + + rendered[i] = tabStyle.Render(titleStyle.Render(seg.title)) + } + + return lipgloss.JoinHorizontal(lipgloss.Top, rendered...) +} + +func (m Model) extendBarCap(bar string, width int, borderColor color.Color) string { + gap := width - lipgloss.Width(bar) + if gap <= 0 { + return bar + } + + bt := m.styles.BorderType + fill := lipgloss.NewStyle(). + Foreground(borderColor). + Render(strings.Repeat(bt.Bottom, gap-1) + bt.TopRight) + + lines := strings.Split(bar, "\n") + lines[len(lines)-1] += fill + + return strings.Join(lines, "\n") +} + +func (m Model) step(delta int) int { + n := len(m.items) + if n == 0 { + return 0 + } + if m.loop { + return ((m.active+delta)%n + n) % n + } + return clamp(m.active+delta, 0, n-1) +} + +func clamp(v, low, high int) int { + if high < low { + return low + } + if v < low { + return low + } + if v > high { + return high + } + return v +}