Change style package, new components, ...

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-08-18 20:10:32 +02:00
parent 499d61bddf
commit 21b574eb6f
56 changed files with 2741 additions and 363 deletions
+15 -74
View File
@@ -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. ## Install
## 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
```sh ```sh
go get github.com/anotherhadi/ilovetui go get github.com/anotherhadi/ilovetui
``` ```
## Theme ## Packages
The theme follows the [Base16](https://github.com/tinted-theming/home) standard (16 colors). The library exposes both the raw palette and semantic aliases: - [`style`](style/README.md): the theme itself. Colors, pre-built panel styles, config loading.
- [`bubbles`](bubbles/README.md): themed constructors for official `bubbles/v2` components (`help`, `textarea`, `textinput`, `viewport`, ...).
- [`tabs`](tabs/README.md), [`modal`](modal/README.md), [`drawer`](drawer/README.md), [`notification`](notification/README.md), [`helpbar`](helpbar/README.md): custom components not found in the official `bubbles` library, styled from the same theme.
| Alias | Base16 | Meaning | Each package has its own README and a runnable example under `examples/<package>`.
| ------------ | ------ | --------------------------------------- |
| `Background` | Base00 | Background |
| `SubtleBg` | Base01 | Lighter Background / Status Bars |
| `Selection` | Base02 | Selection Background |
| `Subtle` | Base03 | Comments / Invisibles |
| `Muted` | Base04 | Dark Foreground / Status Bars |
| `Text` | Base05 | Default Foreground |
| `Primary` | Base0D | Functions / Methods / Headings / Accent |
| `Success` | Base0B | Strings / Success / Diff Inserted |
| `Warning` | Base09 | Integers / Constants / Booleans |
| `Error` | Base08 | Variables / Errors / Diff Deleted |
The default theme is `./default.yaml`. Copy it and edit to customize: ## Quick start
```sh On import, `style` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (embedded default as fallback), exposed as the package-level `S`:
mkdir -p ~/.config/ilovetui
cp $(go env GOPATH)/pkg/mod/github.com/anotherhadi/ilovetui*/default.yaml ~/.config/ilovetui/config.yaml
```
Or let your app write it on first run:
```go ```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 No setup required. See [`style/README.md`](style/README.md) for config details.
`S` ships with a few ready-to-use lipgloss styles:
| Field | Description |
| ---------------- | ---------------------------------------- |
| `S.Bold` | Bold text |
| `S.Faint` | Muted / dimmed text |
| `S.Panel` | Rounded border, unfocused (Subtle color) |
| `S.PanelFocused` | Rounded border, focused (Primary color) |
## Helpers
```go
// Inner usable height of a bordered panel with outer height h
inner := 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
```
## Projects using ilovetui ## Projects using ilovetui
+17
View File
@@ -0,0 +1,17 @@
# bubbles
Themed constructors for every official `bubbles/v2` component: `help`, `textarea`, `textinput`,
`list`, `table`, `filepicker`, `spinner`, `progress`, `paginator`, `viewport`. Each wraps the
official `New`, then applies `style.S` colors on top.
- `spinner`, `table`, `progress` forward any `opts` to the upstream `New` before the theme is
applied, so the theme's colors always win over anything conflicting in `opts`.
- `ViewportView` renders a `viewport.Model` with a themed scrollbar thumb in the left gutter,
shown only when content overflows.
- `NewList`/`NewDefaultDelegate` theme both the list chrome and the selection/filter colors of its
default delegate.
Custom components in this repo (`tabs`, `modal`, `drawer`, `notification`, `helpbar`) build any
official component they need through here, never through `charm.land/bubbles/v2` directly.
See `examples/bubbles`.
+1
View File
@@ -0,0 +1 @@
package bubbles
+25
View File
@@ -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
}
+20
View File
@@ -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
}
+47
View File
@@ -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
}
+16
View File
@@ -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
}
+16
View File
@@ -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
}
+14
View File
@@ -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
}
+17
View File
@@ -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
}
+31
View File
@@ -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
}
+27
View File
@@ -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
}
+47
View File
@@ -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()
}
-96
View File
@@ -1,96 +0,0 @@
package ilovetui
import (
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
"charm.land/lipgloss/v2"
)
// NewHelp returns a help.Model styled with the active theme.
func NewHelp() help.Model {
h := help.New()
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(S.Primary)
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(S.Muted)
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
h.Styles.FullKey = lipgloss.NewStyle().Foreground(S.Primary)
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(S.Muted)
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(S.Subtle)
return h
}
// NewTextarea returns a textarea.Model styled with the active theme.
// Set showLineNumbers to true to display line numbers in the gutter.
func NewTextarea(showLineNumbers bool) textarea.Model {
ta := textarea.New()
ta.Prompt = ""
ta.ShowLineNumbers = showLineNumbers
ta.CharLimit = 0
ta.EndOfBufferCharacter = '~'
ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.Text = lipgloss.NewStyle().Foreground(S.Text)
ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text)
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Primary).Bold(true)
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.Muted)
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ta.SetStyles(ts)
return ta
}
// NewPaginator returns a dot-style paginator.Model styled with the active theme.
func NewPaginator() paginator.Model {
p := paginator.New()
p.Type = paginator.Dots
p.ActiveDot = S.PagerDotActive
p.InactiveDot = S.PagerDotInactive
return p
}
// SplitH splits totalHeight into top and bottom sections, accounting for the
// height of statusBar (measured by newline count).
func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
statusH := strings.Count(statusBar, "\n") + 1
available := totalHeight - statusH
top = int(float64(available) * ratio)
bottom = available - top
return
}
// NewViewport returns a viewport.Model with mouse wheel disabled.
func NewViewport() viewport.Model {
vp := viewport.New()
vp.MouseWheelEnabled = false
return vp
}
// ViewportView renders the viewport and appends a subtle scroll indicator
// on the last visible line when the user has not reached the bottom.
func ViewportView(vp *viewport.Model) string {
v := vp.View()
if vp.AtBottom() {
return v
}
lines := strings.Split(v, "\n")
if len(lines) == 0 {
return v
}
arrow := lipgloss.NewStyle().Foreground(S.Subtle).Render("↓")
arrowW := lipgloss.Width(arrow)
inner := vp.Width() - 2*arrowW
if inner < 0 {
inner = 0
}
lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow
return strings.Join(lines, "\n")
}
-51
View File
@@ -1,51 +0,0 @@
package ilovetui
type colorsYAML struct {
Base00 string `yaml:"base00"`
Base01 string `yaml:"base01"`
Base02 string `yaml:"base02"`
Base03 string `yaml:"base03"`
Base04 string `yaml:"base04"`
Base05 string `yaml:"base05"`
Base06 string `yaml:"base06"`
Base07 string `yaml:"base07"`
Base08 string `yaml:"base08"`
Base09 string `yaml:"base09"`
Base0A string `yaml:"base0a"`
Base0B string `yaml:"base0b"`
Base0C string `yaml:"base0c"`
Base0D string `yaml:"base0d"`
Base0E string `yaml:"base0e"`
Base0F string `yaml:"base0f"`
}
type configYAML struct {
Colors colorsYAML `yaml:"colors"`
}
func mergeColors(base, user colorsYAML) colorsYAML {
pick := func(b, u string) string {
if u != "" {
return u
}
return b
}
return colorsYAML{
Base00: pick(base.Base00, user.Base00),
Base01: pick(base.Base01, user.Base01),
Base02: pick(base.Base02, user.Base02),
Base03: pick(base.Base03, user.Base03),
Base04: pick(base.Base04, user.Base04),
Base05: pick(base.Base05, user.Base05),
Base06: pick(base.Base06, user.Base06),
Base07: pick(base.Base07, user.Base07),
Base08: pick(base.Base08, user.Base08),
Base09: pick(base.Base09, user.Base09),
Base0A: pick(base.Base0A, user.Base0A),
Base0B: pick(base.Base0B, user.Base0B),
Base0C: pick(base.Base0C, user.Base0C),
Base0D: pick(base.Base0D, user.Base0D),
Base0E: pick(base.Base0E, user.Base0E),
Base0F: pick(base.Base0F, user.Base0F),
}
}
+13
View File
@@ -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`.
+61
View File
@@ -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) }
+75
View File
@@ -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
}
+119
View File
@@ -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")
}
+68
View File
@@ -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")
}
}
+25
View File
@@ -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,
}
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
)
type model struct {
input textinput.Model
spinner spinner.Model
}
func newModel() model {
ti := bubbles.NewTextInput()
ti.Placeholder = "type something"
ti.Focus()
return model{input: ti, spinner: bubbles.NewSpinner()}
}
func (m model) Init() tea.Cmd { return m.spinner.Tick }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "ctrl+c" {
return m, tea.Quit
}
var inputCmd, spinnerCmd tea.Cmd
m.input, inputCmd = m.input.Update(msg)
m.spinner, spinnerCmd = m.spinner.Update(msg)
return m, tea.Batch(inputCmd, spinnerCmd)
}
func (m model) View() tea.View {
return tea.NewView(lipgloss.JoinHorizontal(lipgloss.Center, m.spinner.View(), " ", m.input.View()))
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+55
View File
@@ -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)
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/helpbar"
)
type keyMap struct {
Inc, Help, Quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
Inc: key.NewBinding(key.WithKeys("+"), key.WithHelp("+", "increment")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
}
}
type model struct {
help helpbar.Model
keys keyMap
n, w int
}
func newModel() model {
keys := defaultKeyMap()
return model{
keys: keys,
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.Quit)),
}
}
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.w = msg.Width
m.help.SetWidth(m.w)
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Inc):
m.n++
default:
m.help, _ = m.help.Update(msg)
}
}
return m, nil
}
func (m model) View() tea.View {
bar := m.help.View(m.keys.Inc)
body := lipgloss.Place(m.w, 1, lipgloss.Center, lipgloss.Top, fmt.Sprintf("count: %d", m.n))
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, bar))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+55
View File
@@ -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)
}
}
+51
View File
@@ -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)
}
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/style"
)
type model struct{ focused bool }
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "q":
return m, tea.Quit
case "tab":
m.focused = !m.focused
}
}
return m, nil
}
func (m model) View() tea.View {
box := style.S.Panel
if m.focused {
box = style.S.PanelFocused
}
return tea.NewView(style.RenderWithTitle(box, "Panel", "tab: toggle focus q: quit", 30, 5))
}
func main() {
if _, err := tea.NewProgram(model{}).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
+55
View File
@@ -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)
}
}
+1 -1
View File
@@ -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 = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+5 -2
View File
@@ -4,19 +4,20 @@ go 1.25.8
require ( require (
charm.land/bubbles/v2 v2.1.0 charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.2
charm.land/glamour/v2 v2.0.0 charm.land/glamour/v2 v2.0.0
charm.land/lipgloss/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.3
github.com/charmbracelet/x/ansi v0.11.7
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
charm.land/bubbletea/v2 v2.0.2 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -24,12 +25,14 @@ require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect
+8
View File
@@ -22,6 +22,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
@@ -42,10 +44,14 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
@@ -56,6 +62,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+13
View File
@@ -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`.
+118
View File
@@ -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
}
-115
View File
@@ -1,116 +1 @@
// Package ilovetui provides a shared Base16 color theme for bubbletea/lipgloss
// applications. The theme is loaded automatically on import from
// ~/.config/ilovetui/config.yaml (falling back to the embedded
// default config). Access colors and styles via the package-level variable S.
//
// import "github.com/anotherhadi/ilovetui"
//
// style := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
// box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Title", content, w, h)
package ilovetui 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")
}
+15
View File
@@ -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`.
+46
View File
@@ -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) }
+80
View File
@@ -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
}
+113
View File
@@ -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")
}
+25
View File
@@ -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,
}
}
+1
View File
@@ -15,6 +15,7 @@ in
[ [
go go
doctoc doctoc
(python3.withPackages (ps: [ps.pyte]))
] ]
++ hooks.enabledPackages; ++ hooks.enabledPackages;
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Run a TUI binary in a virtual terminal (via pyte) and print its rendered
screen as plain text, for visual verification of bubbletea/lipgloss output.
Dev tooling only, provided by the nix-shell. Not part of the Go module.
Usage:
tui-snapshot.py [--width W] [--height H] [--wait SECONDS] [--keys 'k:delay,...'] -- <cmd> [args...]
Example:
tui-snapshot.py --keys 'l:0.2,l:0.2,q:0' -- go run ./examples/tabs
"""
import argparse
import fcntl
import os
import pty
import select
import struct
import sys
import termios
import time
import pyte
class RepeatAwareScreen(pyte.Screen):
"""pyte has no support for the ECMA-48 REP sequence (``CSI Ps b``,
"repeat the preceding graphic character Ps times"), which bubbletea's
renderer uses to compress runs of identical styled cells (e.g. long
border/padding runs). Left unhandled, pyte silently drops it, corrupting
the frame. This adds the missing handler."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._last_drawn = " "
def draw(self, data):
super().draw(data)
if data:
self._last_drawn = data[-1]
def repeat_last_character(self, count=1):
self.draw(self._last_drawn * (count or 1))
class RepeatAwareStream(pyte.Stream):
"""Also works around a pyte bug: it maps ECMA-48 HPA (Character Position
Absolute, "move cursor to column Ps") to "'" (apostrophe, 0x27) instead
of the actual standard character "`" (backtick, 0x60) - see pyte's
escape.py. Real terminal apps send the correct backtick, which pyte then
silently no-ops on since it's not in its dispatch table, leaving the
cursor stuck instead of moving it. bubbletea's renderer uses HPA (mixed
with CUF) to jump the cursor to a border's column after erasing padding
with ECH, so without this the trailing border character gets drawn right
after the leading one instead of at the far edge."""
csi = {
**pyte.Stream.csi,
"b": "repeat_last_character",
"`": "cursor_to_column",
}
KEYMAP = {
"enter": "\r",
"esc": "\x1b",
"tab": "\t",
"up": "\x1b[A",
"down": "\x1b[B",
"right": "\x1b[C",
"left": "\x1b[D",
"space": " ",
}
def encode_key(key):
"""Resolve a --keys token to the bytes to write to the pty. Falls back to
the literal string for a plain character (e.g. 'l'), but without this,
something like 'ctrl+l' would be sent as the 6 literal characters
'c','t','r','l','+','l' instead of the single 0x0C control byte a real
terminal would send for Ctrl-L - silently testing the wrong thing."""
if key in KEYMAP:
return KEYMAP[key]
if key.startswith("ctrl+") and len(key) == 6 and key[5].isalpha():
return chr(ord(key[5].lower()) - ord("a") + 1)
return key
def set_size(fd, rows, cols):
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--width", type=int, default=80)
parser.add_argument("--height", type=int, default=24)
parser.add_argument(
"--wait", type=float, default=1.0, help="seconds to let the app render before the snapshot"
)
parser.add_argument(
"--keys",
default="",
help="comma-separated key:delay pairs sent before the final snapshot, e.g. 'l:0.2,l:0.2,q:0'",
)
parser.add_argument(
"--colors",
action="store_true",
help="also print, per row, the foreground color of each non-blank cell (for border/color bugs "
"that don't show up in the plain-text dump)",
)
parser.add_argument("cmd", nargs=argparse.REMAINDER)
args = parser.parse_args()
cmd = args.cmd
if cmd and cmd[0] == "--":
cmd = cmd[1:]
if not cmd:
parser.error("missing command to run")
screen = RepeatAwareScreen(args.width, args.height)
stream = RepeatAwareStream(screen)
# Open the pty and set its size *before* forking, so the child never
# observes a stale/default size on its first render (pyte has no way to
# recover from a corrupted initial frame drawn at the wrong width).
master_fd, slave_fd = pty.openpty()
set_size(slave_fd, args.height, args.width)
pid = os.fork()
if pid == 0:
os.close(master_fd)
os.setsid()
fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
os.dup2(slave_fd, 0)
os.dup2(slave_fd, 1)
os.dup2(slave_fd, 2)
os.close(slave_fd)
os.execvp(cmd[0], cmd)
os._exit(1)
os.close(slave_fd)
fd = master_fd
def pump(duration):
end = time.time() + duration
while True:
remaining = end - time.time()
if remaining <= 0:
break
r, _, _ = select.select([fd], [], [], remaining)
if fd not in r:
continue
try:
data = os.read(fd, 65536)
except OSError:
break
if not data:
break
stream.feed(data.decode(errors="ignore"))
pump(args.wait)
for pair in filter(None, args.keys.split(",")):
key, _, delay = pair.partition(":")
try:
os.write(fd, encode_key(key).encode())
except OSError:
break
pump(float(delay) if delay else 0.3)
try:
os.kill(pid, 15)
os.waitpid(pid, 0)
except (ProcessLookupError, ChildProcessError):
pass
for y, line in enumerate(screen.display):
print(line.rstrip())
if args.colors:
row = screen.buffer[y]
cells = []
for x in sorted(row):
ch = row[x]
if ch.data.strip():
fg = ch.fg if ch.fg != "default" else "-"
cells.append(f"{x}:{ch.data!r}:{fg}")
if cells:
print(" " + " ".join(cells))
if __name__ == "__main__":
main()
+14
View File
@@ -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`.
+93
View File
@@ -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
}
+48
View File
@@ -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
}
+127
View File
@@ -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")
}
+58
View File
@@ -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
}
}
+71
View File
@@ -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 }
+18
View File
@@ -0,0 +1,18 @@
# style
Shared Base16 theming for bubbletea/lipgloss TUIs. Loaded automatically on import from
`~/.config/ilovetui/config.yaml` (embedded default as fallback), exposed as the package-level
`style.S`.
- `S.NerdFonts` and `S.BorderType` come from the same config as the colors. This package has no
icon registry: components that want icons read `NerdFonts` and pick their own glyphs.
- `RenderWithTitle` renders a bordered box with a title embedded in the top border, following
`S.BorderType`.
- Never imports `charm.land/bubbles/v2/*`. Anything that needs to know about a specific component
belongs in `bubbles/` instead.
## Config
Copy [`default.yaml`](default.yaml) to `~/.config/ilovetui/config.yaml` and edit it.
See `examples/style`.
+23 -10
View File
@@ -1,4 +1,4 @@
package ilovetui package style
import ( import (
"strings" "strings"
@@ -6,7 +6,22 @@ import (
"charm.land/lipgloss/v2" "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 { func ContentHeight(totalH int) int {
h := totalH - 2 h := totalH - 2
if h < 0 { if h < 0 {
@@ -15,12 +30,6 @@ func ContentHeight(totalH int) int {
return h return h
} }
// RenderWithTitle renders a bordered box with a title embedded in the top border.
// title may contain ANSI color codes. width and height are the total outer dimensions.
//
// Example:
//
// box := ilovetui.RenderWithTitle(theme.Styles.PanelFocused, "Header", content, w, h)
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string { func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
boxH := height - 1 boxH := height - 1
if contentH := boxH - 1; contentH > 0 { if contentH := boxH - 1; contentH > 0 {
@@ -33,12 +42,16 @@ func RenderWithTitle(border lipgloss.Style, title, content string, width, height
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0]) boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
titleW := lipgloss.Width(title) 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 { if fillW < 0 {
fillW = 0 fillW = 0
} }
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground()) 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) return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
} }
+63
View File
@@ -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),
}
}
+10 -1
View File
@@ -1,5 +1,14 @@
# ilovetui default theme # ilovetui default config
# Copy to ~/.config/ilovetui/config.yaml and edit to customize. # Copy to ~/.config/ilovetui/config.yaml and edit to customize.
# Whether components may use Nerd Font glyphs (requires a patched font).
# Leave false for maximum terminal/font compatibility.
nerd_fonts: false
# Border style used by panels across all ilovetui-based TUIs.
# One of: rounded, normal, thick, double, hidden, ascii.
border: rounded
colors: colors:
base00: "#110F12" # Background base00: "#110F12" # Background
base01: "#1C1920" # Lighter Background / Status Bars base01: "#1C1920" # Lighter Background / Status Bars
+1 -2
View File
@@ -1,4 +1,4 @@
package ilovetui package style
import ( import (
"fmt" "fmt"
@@ -13,7 +13,6 @@ func hexColor(c color.Color) *string {
return &s return &s
} }
// GlamourStyleConfig returns a glamour ansi.StyleConfig using the active theme.
func GlamourStyleConfig() ansi.StyleConfig { func GlamourStyleConfig() ansi.StyleConfig {
str := func(s string) *string { return &s } str := func(s string) *string { return &s }
boolPtr := func(b bool) *bool { return &b } boolPtr := func(b bool) *bool { return &b }
+12 -11
View File
@@ -1,4 +1,4 @@
package ilovetui package style
import ( import (
"image/color" "image/color"
@@ -7,10 +7,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
) )
// Styles holds both the raw Base16 palette and ready-to-use semantic colors
// and lipgloss styles. Access via the package-level variable S.
type Styles struct { type Styles struct {
// Raw Base16 palette
Base00 color.Color Base00 color.Color
Base01 color.Color Base01 color.Color
Base02 color.Color Base02 color.Color
@@ -28,7 +25,6 @@ type Styles struct {
Base0E color.Color Base0E color.Color
Base0F color.Color Base0F color.Color
// Semantic color aliases
Background color.Color Background color.Color
SubtleBg color.Color SubtleBg color.Color
Selection color.Color Selection color.Color
@@ -40,20 +36,20 @@ type Styles struct {
Warning color.Color Warning color.Color
Error color.Color Error color.Color
// Pre-built text styles
Bold lipgloss.Style Bold lipgloss.Style
Faint lipgloss.Style Faint lipgloss.Style
// Pre-built panel styles (rounded border) NerdFonts bool
BorderType lipgloss.Border
Panel lipgloss.Style Panel lipgloss.Style
PanelFocused lipgloss.Style PanelFocused lipgloss.Style
// Pre-rendered pager dot strings
PagerDotActive string PagerDotActive string
PagerDotInactive string PagerDotInactive string
} }
func newStyles(c colorsYAML) Styles { func newStyles(c colorsYAML, nerdFonts bool, borderName string) Styles {
lc := func(s string) color.Color { lc := func(s string) color.Color {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if s != "" && s[0] != '#' { if s != "" && s[0] != '#' {
@@ -79,6 +75,8 @@ func newStyles(c colorsYAML) Styles {
b0E := lc(c.Base0E) b0E := lc(c.Base0E)
b0F := lc(c.Base0F) b0F := lc(c.Base0F)
borderType := resolveBorderType(borderName)
return Styles{ return Styles{
Base00: b00, Base01: b01, Base02: b02, Base03: b03, Base00: b00, Base01: b01, Base02: b02, Base03: b03,
Base04: b04, Base05: b05, Base06: b06, Base07: b07, Base04: b04, Base05: b05, Base06: b06, Base07: b07,
@@ -99,12 +97,15 @@ func newStyles(c colorsYAML) Styles {
Bold: lipgloss.NewStyle().Bold(true), Bold: lipgloss.NewStyle().Bold(true),
Faint: lipgloss.NewStyle().Foreground(b03).Faint(true), Faint: lipgloss.NewStyle().Foreground(b03).Faint(true),
NerdFonts: nerdFonts,
BorderType: borderType,
Panel: lipgloss.NewStyle(). Panel: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(borderType).
BorderForeground(b03), BorderForeground(b03),
PanelFocused: lipgloss.NewStyle(). PanelFocused: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(borderType).
BorderForeground(b0D), BorderForeground(b0D),
PagerDotActive: lipgloss.NewStyle().Foreground(b0D).SetString("•").String(), PagerDotActive: lipgloss.NewStyle().Foreground(b0D).SetString("•").String(),
+84
View File
@@ -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")
}
+15
View File
@@ -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`.
+454
View File
@@ -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
}