mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-10-05 19:08:25 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa6496901f | ||
|
|
5f99fa7521 | ||
|
|
f3a1877832 | ||
|
|
499d61bddf | ||
|
|
39d2b852a8 | ||
|
|
47ed1886a9 | ||
|
|
9a336aff8e | ||
|
|
161a117d00 | ||
|
|
cd0a179fe9 |
@@ -4,6 +4,9 @@ before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- skip: true
|
||||
|
||||
archives:
|
||||
- formats:
|
||||
- tar.gz
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
# Ilovetui
|
||||
|
||||
A minimal Go library that provides a shared [Base16](https://github.com/tinted-theming/home) color theme for terminal UIs built with [bubbletea](https://github.com/charmbracelet/bubbletea) and [lipgloss](https://github.com/charmbracelet/lipgloss).
|
||||
A minimal Go library that provides a shared [Base16](https://github.com/tinted-theming/home) color theme for terminal UIs built with [bubbletea](https://github.com/charmbracelet/bubbletea) and [lipgloss](https://github.com/charmbracelet/lipgloss), plus a small collection of Bubble Tea v2 components on top of it.
|
||||
|
||||
The idea is simple: instead of every TUI app managing its own colors, they all share one theme file so the user customizes once and every app looks consistent.
|
||||
|
||||
## Packages
|
||||
|
||||
- `github.com/anotherhadi/ilovetui/style` — the theme itself: colors, pre-built panel styles, config loading.
|
||||
- `github.com/anotherhadi/ilovetui/bubbles` — themed constructors for official `bubbles/v2` components (`help`, `textarea`, `textinput`, `list`, `table`, `filepicker`, `spinner`, `progress`, `paginator`, `viewport`).
|
||||
- `github.com/anotherhadi/ilovetui/tabs`, `.../helpbar`, `.../modal`, `.../drawer`, `.../notification` — custom components not found in the official `bubbles` library, styled from the same theme.
|
||||
|
||||
## How it works
|
||||
|
||||
On import, `ilovetui` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (respecting `$XDG_CONFIG_HOME`).
|
||||
On import, `style` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (respecting `$XDG_CONFIG_HOME`).
|
||||
If no config exists, it falls back to the embedded default. The active theme is exposed as the package-level variable `S`.
|
||||
|
||||
```go
|
||||
import "github.com/anotherhadi/ilovetui"
|
||||
import "github.com/anotherhadi/ilovetui/style"
|
||||
|
||||
// Use colors directly
|
||||
style := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||
s := lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
|
||||
// Use pre-built panel styles
|
||||
box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Title", content, w, h)
|
||||
box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h)
|
||||
```
|
||||
|
||||
No setup required — just import and use.
|
||||
@@ -44,17 +50,17 @@ The theme follows the [Base16](https://github.com/tinted-theming/home) standard
|
||||
| `Warning` | Base09 | Integers / Constants / Booleans |
|
||||
| `Error` | Base08 | Variables / Errors / Diff Deleted |
|
||||
|
||||
The default theme is `./default.yaml`. Copy it and edit to customize:
|
||||
The default theme is `style/default.yaml`. Copy it and edit to customize:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/ilovetui
|
||||
cp $(go env GOPATH)/pkg/mod/github.com/anotherhadi/ilovetui*/default.yaml ~/.config/ilovetui/config.yaml
|
||||
cp $(go env GOPATH)/pkg/mod/github.com/anotherhadi/ilovetui*/style/default.yaml ~/.config/ilovetui/config.yaml
|
||||
```
|
||||
|
||||
Or let your app write it on first run:
|
||||
|
||||
```go
|
||||
ilovetui.WriteDefaultConfig(ilovetui.DefaultConfigPath())
|
||||
style.WriteDefaultConfig(style.DefaultConfigPath())
|
||||
```
|
||||
|
||||
## Pre-built styles
|
||||
@@ -72,22 +78,60 @@ ilovetui.WriteDefaultConfig(ilovetui.DefaultConfigPath())
|
||||
|
||||
```go
|
||||
// Inner usable height of a bordered panel with outer height h
|
||||
inner := ilovetui.ContentHeight(h)
|
||||
inner := style.ContentHeight(h)
|
||||
|
||||
// Render a box with a title embedded in the top border
|
||||
box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Header", content, w, h)
|
||||
box := style.RenderWithTitle(style.S.PanelFocused, "Header", content, w, h)
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```go
|
||||
ilovetui.Init() // Reload from default config path
|
||||
ilovetui.InitFrom(path string) // Reload from a custom path
|
||||
ilovetui.InitFromBytes(data []byte) // Parse raw YAML
|
||||
ilovetui.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml
|
||||
ilovetui.WriteDefaultConfig(path) // Write default config if missing
|
||||
style.Init() // Reload from default config path
|
||||
style.InitFrom(path string) // Reload from a custom path
|
||||
style.InitFromBytes(data []byte) // Parse raw YAML
|
||||
style.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml
|
||||
style.WriteDefaultConfig(path) // Write default config if missing
|
||||
```
|
||||
|
||||
## Themed official components
|
||||
|
||||
```go
|
||||
import "github.com/anotherhadi/ilovetui/bubbles"
|
||||
|
||||
h := bubbles.NewHelp()
|
||||
ta := bubbles.NewTextarea(false)
|
||||
ti := bubbles.NewTextInput()
|
||||
l := bubbles.NewList(items, width, height)
|
||||
t := bubbles.NewTable()
|
||||
fp := bubbles.NewFilePicker()
|
||||
sp := bubbles.NewSpinner()
|
||||
pr := bubbles.NewProgress()
|
||||
pg := bubbles.NewPaginator()
|
||||
vp := bubbles.NewViewport()
|
||||
```
|
||||
|
||||
Each constructor mirrors the official component's own `New`, then applies `style.S` on top. Where the
|
||||
official `New` takes options (`spinner`, `table`, `progress`), they're forwarded before the theme is
|
||||
applied, so you can still customize behavior; anything you pass that also sets colors will be overridden
|
||||
by the theme afterward.
|
||||
|
||||
## Custom components
|
||||
|
||||
```go
|
||||
import "github.com/anotherhadi/ilovetui/tabs"
|
||||
|
||||
t := tabs.New([]tabs.Item{{Title: "First", Model: firstPane}, {Title: "Second", Model: secondPane}})
|
||||
```
|
||||
|
||||
`tabs` renders a horizontal tab bar styled from `style.S`. The host application renders the content
|
||||
below it; see [`tabs/README.md`](tabs/README.md) and `examples/tabs` for a full example.
|
||||
|
||||
For the other custom components, see their own README: [`helpbar`](helpbar/README.md) (responsive
|
||||
help bar that reflows into as many columns as fit), [`modal`](modal/README.md) (centered popup
|
||||
dialogs), [`drawer`](drawer/README.md) (left/right sidebar panels, mirroring `modal`) and
|
||||
[`notification`](notification/README.md) (toast notifications).
|
||||
|
||||
## Projects using ilovetui
|
||||
|
||||
- [anotherhadi/spilltea](https://github.com/anotherhadi/spilltea): A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players. Think Burp Suite or Caido, but entirely in your terminal.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package ilovetui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// ContentHeight returns the usable inner height for a bordered panel of totalH rows.
|
||||
func ContentHeight(totalH int) int {
|
||||
h := totalH - 2
|
||||
if h < 0 {
|
||||
return 0
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// RenderWithTitle renders a bordered box with a title embedded in the top border.
|
||||
// title may contain ANSI color codes. width and height are the total outer dimensions.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// box := ilovetui.RenderWithTitle(theme.Styles.PanelFocused, "Header", content, w, h)
|
||||
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
|
||||
boxH := height - 1
|
||||
if contentH := boxH - 1; contentH > 0 {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) > contentH {
|
||||
content = strings.Join(lines[:contentH], "\n")
|
||||
}
|
||||
}
|
||||
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
|
||||
|
||||
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
|
||||
titleW := lipgloss.Width(title)
|
||||
fillW := boxWidth - titleW - 4 // 4 = "╭ " + " " + "╮"
|
||||
if fillW < 0 {
|
||||
fillW = 0
|
||||
}
|
||||
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
|
||||
topLine := bc.Render("╭ ") + title + bc.Render(" "+strings.Repeat("─", fillW)+"╮")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Package bubbles provides themed constructors for official bubbles/v2
|
||||
// components (help, textarea, textinput, list, table, filepicker, spinner,
|
||||
// progress, paginator, viewport). Each constructor mirrors the official
|
||||
// component's own New function, then applies the shared ilovetui/style
|
||||
// theme on top.
|
||||
package bubbles
|
||||
@@ -0,0 +1,26 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/filepicker"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// NewFilePicker returns a filepicker.Model styled with the active theme.
|
||||
func NewFilePicker() filepicker.Model {
|
||||
f := filepicker.New()
|
||||
s := filepicker.DefaultStyles()
|
||||
s.Cursor = s.Cursor.Foreground(style.S.Primary)
|
||||
s.DisabledCursor = s.DisabledCursor.Foreground(style.S.Subtle)
|
||||
s.Symlink = s.Symlink.Foreground(style.S.Warning)
|
||||
s.Directory = s.Directory.Foreground(style.S.Primary)
|
||||
s.File = s.File.Foreground(style.S.Text)
|
||||
s.DisabledFile = s.DisabledFile.Foreground(style.S.Subtle)
|
||||
s.Permission = s.Permission.Foreground(style.S.Muted)
|
||||
s.Selected = s.Selected.Foreground(style.S.Primary)
|
||||
s.DisabledSelected = s.DisabledSelected.Foreground(style.S.Subtle)
|
||||
s.FileSize = s.FileSize.Foreground(style.S.Muted)
|
||||
s.EmptyDirectory = s.EmptyDirectory.Foreground(style.S.Subtle)
|
||||
f.Styles = s
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// NewHelp returns a help.Model styled with the active theme.
|
||||
func NewHelp() help.Model {
|
||||
h := help.New()
|
||||
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
|
||||
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
h.Styles.FullKey = lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
|
||||
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package bubbles
|
||||
|
||||
import "strings"
|
||||
|
||||
// SplitH splits totalHeight into top and bottom sections, accounting for the
|
||||
// height of statusBar (measured by newline count).
|
||||
func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
|
||||
statusH := strings.Count(statusBar, "\n") + 1
|
||||
available := totalHeight - statusH
|
||||
top = int(float64(available) * ratio)
|
||||
bottom = available - top
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/list"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// NewList returns a list.Model styled with the active theme, using
|
||||
// NewDefaultDelegate for item rendering.
|
||||
func NewList(items []list.Item, width, height int) list.Model {
|
||||
m := list.New(items, NewDefaultDelegate(), width, height)
|
||||
m.Styles = themedListStyles()
|
||||
return m
|
||||
}
|
||||
|
||||
// themedListStyles builds list.Styles from the active theme.
|
||||
func themedListStyles() list.Styles {
|
||||
// isDark only affects a couple of fallback colors below, all of which
|
||||
// are overridden regardless.
|
||||
s := list.DefaultStyles(true)
|
||||
s.Title = s.Title.Background(style.S.Primary).Foreground(style.S.Background)
|
||||
s.Spinner = s.Spinner.Foreground(style.S.Primary)
|
||||
s.Filter = themedTextInputStyles()
|
||||
s.DefaultFilterCharacterMatch = s.DefaultFilterCharacterMatch.Foreground(style.S.Primary)
|
||||
s.StatusBar = s.StatusBar.Foreground(style.S.Muted)
|
||||
s.StatusEmpty = s.StatusEmpty.Foreground(style.S.Subtle)
|
||||
s.StatusBarActiveFilter = s.StatusBarActiveFilter.Foreground(style.S.Text)
|
||||
s.StatusBarFilterCount = s.StatusBarFilterCount.Foreground(style.S.Subtle)
|
||||
s.NoItems = s.NoItems.Foreground(style.S.Subtle)
|
||||
s.ArabicPagination = s.ArabicPagination.Foreground(style.S.Subtle)
|
||||
s.ActivePaginationDot = s.ActivePaginationDot.Foreground(style.S.Primary)
|
||||
s.InactivePaginationDot = s.InactivePaginationDot.Foreground(style.S.Subtle)
|
||||
s.DividerDot = s.DividerDot.Foreground(style.S.Subtle)
|
||||
return s
|
||||
}
|
||||
|
||||
// NewDefaultDelegate returns a list.DefaultDelegate styled with the active
|
||||
// theme, for use with NewList or a custom list.Model.
|
||||
func NewDefaultDelegate() list.DefaultDelegate {
|
||||
d := list.NewDefaultDelegate()
|
||||
d.Styles.NormalTitle = d.Styles.NormalTitle.Foreground(style.S.Text)
|
||||
d.Styles.NormalDesc = d.Styles.NormalDesc.Foreground(style.S.Muted)
|
||||
d.Styles.SelectedTitle = d.Styles.SelectedTitle.
|
||||
BorderForeground(style.S.Primary).
|
||||
Foreground(style.S.Primary)
|
||||
d.Styles.SelectedDesc = d.Styles.SelectedDesc.
|
||||
BorderForeground(style.S.Primary).
|
||||
Foreground(style.S.Primary)
|
||||
d.Styles.DimmedTitle = d.Styles.DimmedTitle.Foreground(style.S.Subtle)
|
||||
d.Styles.DimmedDesc = d.Styles.DimmedDesc.Foreground(style.S.SubtleBg)
|
||||
d.Styles.FilterMatch = d.Styles.FilterMatch.Foreground(style.S.Primary)
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/paginator"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// NewPaginator returns a dot-style paginator.Model styled with the active theme.
|
||||
func NewPaginator() paginator.Model {
|
||||
p := paginator.New()
|
||||
p.Type = paginator.Dots
|
||||
p.ActiveDot = lipgloss.NewStyle().Foreground(style.S.Primary).Render("•")
|
||||
p.InactiveDot = lipgloss.NewStyle().Foreground(style.S.Subtle).Render("•")
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/progress"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// NewProgress returns a progress.Model with a themed fill, blending from
|
||||
// style.S.Subtle to style.S.Primary. Any opts are forwarded to progress.New;
|
||||
// pass progress.WithColors to override the default blend.
|
||||
func NewProgress(opts ...progress.Option) progress.Model {
|
||||
allOpts := append([]progress.Option{
|
||||
progress.WithColors(style.S.Subtle, style.S.Primary),
|
||||
}, opts...)
|
||||
p := progress.New(allOpts...)
|
||||
p.EmptyColor = style.S.SubtleBg
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// NewSpinner returns a spinner.Model styled with the active theme. Any opts
|
||||
// are forwarded to spinner.New before the theme is applied.
|
||||
func NewSpinner(opts ...spinner.Option) spinner.Model {
|
||||
s := spinner.New(opts...)
|
||||
s.Style = lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/table"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// NewTable returns a table.Model styled with the active theme. Any opts are
|
||||
// forwarded to table.New before the theme is applied.
|
||||
func NewTable(opts ...table.Option) table.Model {
|
||||
t := table.New(opts...)
|
||||
s := table.DefaultStyles()
|
||||
s.Header = s.Header.Foreground(style.S.Primary)
|
||||
s.Cell = s.Cell.Foreground(style.S.Text)
|
||||
s.Selected = s.Selected.Foreground(style.S.Primary)
|
||||
t.SetStyles(s)
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// NewTextarea returns a textarea.Model styled with the active theme.
|
||||
// Set showLineNumbers to true to display line numbers in the gutter.
|
||||
func NewTextarea(showLineNumbers bool) textarea.Model {
|
||||
ta := textarea.New()
|
||||
ta.Prompt = ""
|
||||
ta.ShowLineNumbers = showLineNumbers
|
||||
ta.CharLimit = 0
|
||||
ta.EndOfBufferCharacter = '~'
|
||||
ts := ta.Styles()
|
||||
ts.Focused.Base = lipgloss.NewStyle()
|
||||
ts.Blurred.Base = lipgloss.NewStyle()
|
||||
ts.Focused.Text = lipgloss.NewStyle().Foreground(style.S.Text)
|
||||
ts.Focused.CursorLine = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Text)
|
||||
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Primary).Bold(true)
|
||||
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
|
||||
ts.Blurred.Text = lipgloss.NewStyle().Foreground(style.S.Muted)
|
||||
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
|
||||
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
|
||||
ta.SetStyles(ts)
|
||||
return ta
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// NewTextInput returns a textinput.Model styled with the active theme.
|
||||
func NewTextInput() textinput.Model {
|
||||
t := textinput.New()
|
||||
t.SetStyles(themedTextInputStyles())
|
||||
return t
|
||||
}
|
||||
|
||||
// themedTextInputStyles builds textinput.Styles from the active theme.
|
||||
// Shared with NewList, which themes its filter input the same way.
|
||||
func themedTextInputStyles() textinput.Styles {
|
||||
// isDark only affects textinput.DefaultStyles' Blurred.Text color, which
|
||||
// we override below regardless.
|
||||
s := textinput.DefaultStyles(true)
|
||||
s.Focused.Text = s.Focused.Text.Foreground(style.S.Text)
|
||||
s.Focused.Placeholder = s.Focused.Placeholder.Foreground(style.S.Subtle)
|
||||
s.Focused.Suggestion = s.Focused.Suggestion.Foreground(style.S.Subtle)
|
||||
s.Focused.Prompt = s.Focused.Prompt.Foreground(style.S.Primary)
|
||||
s.Blurred.Text = s.Blurred.Text.Foreground(style.S.Muted)
|
||||
s.Blurred.Placeholder = s.Blurred.Placeholder.Foreground(style.S.Subtle)
|
||||
s.Blurred.Suggestion = s.Blurred.Suggestion.Foreground(style.S.Subtle)
|
||||
s.Blurred.Prompt = s.Blurred.Prompt.Foreground(style.S.Subtle)
|
||||
s.Cursor.Color = style.S.Primary
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// NewViewport returns a viewport.Model with mouse wheel disabled.
|
||||
func NewViewport() viewport.Model {
|
||||
vp := viewport.New()
|
||||
vp.MouseWheelEnabled = false
|
||||
return vp
|
||||
}
|
||||
|
||||
// ViewportView renders the viewport and appends a subtle scroll indicator
|
||||
// on the last visible line when the user has not reached the bottom.
|
||||
func ViewportView(vp *viewport.Model) string {
|
||||
v := vp.View()
|
||||
if vp.AtBottom() {
|
||||
return v
|
||||
}
|
||||
lines := strings.Split(v, "\n")
|
||||
if len(lines) == 0 {
|
||||
return v
|
||||
}
|
||||
arrow := lipgloss.NewStyle().Foreground(style.S.Subtle).Render("↓")
|
||||
arrowW := lipgloss.Width(arrow)
|
||||
inner := vp.Width() - 2*arrowW
|
||||
if inner < 0 {
|
||||
inner = 0
|
||||
}
|
||||
lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -1,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),
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
# ilovetui default theme
|
||||
# Copy to ~/.config/ilovetui/config.yaml and edit to customize.
|
||||
colors:
|
||||
base00: "#1e1e2e" # Background
|
||||
base01: "#181825" # Lighter Background / Status Bars
|
||||
base02: "#313244" # Selection Background
|
||||
base03: "#45475a" # Comments / Invisibles
|
||||
base04: "#585b70" # Dark Foreground / Status Bars
|
||||
base05: "#cdd6f4" # Default Foreground
|
||||
base06: "#f5f5f5" # Light Foreground
|
||||
base07: "#b4befe" # Light Background
|
||||
base08: "#f38ba8" # Variables / Errors / Diff Deleted
|
||||
base09: "#fab387" # Integers / Constants / Booleans
|
||||
base0a: "#f9e2af" # Classes / Warnings / Search Background
|
||||
base0b: "#a6e3a1" # Strings / Success / Diff Inserted
|
||||
base0c: "#94e2d5" # Support / Regex / Escape Characters
|
||||
base0d: "#89b4fa" # Functions / Methods / Headings / Accent
|
||||
base0e: "#cba6f7" # Keywords / Storage / Diff Changed
|
||||
base0f: "#f2cdcd" # Embedded / Misc
|
||||
@@ -0,0 +1,140 @@
|
||||
# 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 this package
|
||||
otherwise mirrors closely: same stack of panels triggered from anywhere via an exported `tea.Msg`
|
||||
(`ShowMsg`/`Show`) rather than a direct reference to the `Model` that ends up rendering it, same
|
||||
composite-over-an-already-rendered-string `Render`, no assumption about how the host builds that
|
||||
string.
|
||||
|
||||
## Quick start
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/anotherhadi/ilovetui/drawer"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
d drawer.Model
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
return model{d: drawer.New()}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.d.Init() }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "l" {
|
||||
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
|
||||
}
|
||||
if msg.String() == "esc" && m.d.Open() {
|
||||
return m, drawer.Close()
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.d, cmd = m.d.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
background := renderYourUI(m.width, m.height)
|
||||
view := tea.NewView(m.d.Render(background))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
```
|
||||
|
||||
Any component in the same bubbletea program can trigger a drawer via `drawer.Show`, without
|
||||
holding a reference to the `drawer.Model` that will actually render it - that `Model` just needs
|
||||
to see every `tea.Msg` the program produces (i.e. get its `Update` called from the top-level
|
||||
`Update`), same as any other child model.
|
||||
|
||||
## Content is a model
|
||||
|
||||
A drawer's body is a `tea.Model`, not a string. While a drawer is on top of the stack it gets
|
||||
every message the `drawer.Model` receives, its `Init` runs when it opens, and its commands come
|
||||
back out - so it can hold a file list, a filter form, or a picker that reports its choice with a
|
||||
`tea.Msg` of its own, which the component that opened it listens for:
|
||||
|
||||
```go
|
||||
type pickedMsg struct{ file string }
|
||||
|
||||
// somewhere else
|
||||
return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
|
||||
```
|
||||
|
||||
Only the topmost drawer is updated: everything beneath it is dimmed and frozen until the drawers
|
||||
above it close.
|
||||
|
||||
For a drawer with nothing to interact with, `drawer.Text` wraps a plain string:
|
||||
|
||||
```go
|
||||
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
|
||||
```
|
||||
|
||||
The box shrinks to fit whatever the content draws (unless `WithWidth` fixes it), so a content
|
||||
that wants a specific size sets it on itself - the drawer only ever sees the rendered result.
|
||||
|
||||
## Side and width
|
||||
|
||||
```go
|
||||
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"),
|
||||
drawer.WithSide(drawer.Right), drawer.WithWidth(24))
|
||||
```
|
||||
|
||||
`WithSide` anchors the drawer to `drawer.Left` (the default) or `drawer.Right`. `WithWidth` fixes
|
||||
the drawer's total width instead of shrinking to fit its content, still capped by whatever
|
||||
actually fits the background - same unit as the `Model`-level `WithMaxWidth`. Either way, the
|
||||
drawer always spans the background's full height, flush top to bottom.
|
||||
|
||||
## Showing and closing
|
||||
|
||||
```go
|
||||
return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
|
||||
|
||||
return m, drawer.Close() // close the topmost drawer
|
||||
```
|
||||
|
||||
The stack is a plain LIFO, with no identity: a drawer is closed by being on top, never by being
|
||||
named. There is nothing to tag a drawer with, and nothing that can target one in the middle of the
|
||||
stack - the topmost is both the only one that receives messages and the only one `Close` can
|
||||
reach, so the two rules never disagree.
|
||||
|
||||
- `drawer.Open()` reports whether at least one drawer is currently shown - handy for a host that
|
||||
wants to route key presses to the drawer instead of its normal UI while one is open. Note that a
|
||||
host doing this also makes it impossible for a key to open a second drawer, which is what keeps
|
||||
the stack shallow without any bookkeeping.
|
||||
- A content model closes its own drawer by returning `drawer.Close()`, since it only ever runs
|
||||
while it is the topmost one.
|
||||
|
||||
## Stacking
|
||||
|
||||
Drawers stack: showing a second one while the first is still open pushes it on top, dimming both
|
||||
the background and the first drawer to the same flat color, same as `modal`. Opening a left drawer
|
||||
and a right drawer at once still stacks - if you want both visible at full color simultaneously,
|
||||
render them as two ordinary panels via `layout` instead; this package is for transient
|
||||
sidebars, not permanent chrome.
|
||||
|
||||
## Styling
|
||||
|
||||
```go
|
||||
d := drawer.New(drawer.WithMaxWidth(30), drawer.WithStyles(myStyles))
|
||||
|
||||
return m, drawer.Show("Nav", drawer.Text("content"), drawer.WithDrawerStyle(oneOffStyles))
|
||||
```
|
||||
|
||||
`WithMaxWidth` caps how wide a drawer can grow (border and padding included) before wrapping; a
|
||||
drawer narrower than the cap shrinks to fit its content instead of padding out to it, unless shown
|
||||
with `WithWidth`. A drawer can also never overflow past the edge of whatever background it's
|
||||
rendered on. `WithStyles` sets the default look for every drawer shown by this `Model`;
|
||||
`WithDrawerStyle` (a `Show` option) overrides it for one drawer alone. `DefaultStyles()` builds
|
||||
from `style.S`, same palette roles as `modal.DefaultStyles()`.
|
||||
|
||||
## Examples
|
||||
|
||||
- `examples/drawer` - a left nav drawer and a right inspector drawer, either one at a time.
|
||||
@@ -0,0 +1,109 @@
|
||||
package drawer
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
// Side is which edge of the background a Drawer is anchored to.
|
||||
type Side int
|
||||
|
||||
const (
|
||||
// Left anchors the drawer to the left edge (the zero value, so a Drawer
|
||||
// built without WithSide opens on the left).
|
||||
Left Side = iota
|
||||
// Right anchors the drawer to the right edge.
|
||||
Right
|
||||
)
|
||||
|
||||
// Drawer is one sidebar panel on the stack.
|
||||
type Drawer struct {
|
||||
Title string
|
||||
// Content is the drawer's body: a full model, updated and rendered by
|
||||
// the drawer.Model while it's on top of the stack. Wrap a plain string
|
||||
// with Text for a drawer with nothing to interact with.
|
||||
Content tea.Model
|
||||
Side Side
|
||||
// Width, if non-zero, fixes the drawer's total width (border and
|
||||
// padding included, same unit as the Model's WithMaxWidth) instead of
|
||||
// shrinking to fit what Content draws, and Title. Still capped by whatever actually
|
||||
// fits the background.
|
||||
Width int
|
||||
// Style, if non-nil, overrides the Model's default Styles for this
|
||||
// drawer alone.
|
||||
Style *Styles
|
||||
}
|
||||
|
||||
// DrawerOption configures a Drawer built by Show.
|
||||
type DrawerOption func(*Drawer)
|
||||
|
||||
// WithSide anchors the drawer to the given Side (default Left).
|
||||
func WithSide(s Side) DrawerOption {
|
||||
return func(d *Drawer) { d.Side = s }
|
||||
}
|
||||
|
||||
// WithWidth fixes the drawer's total width instead of shrinking to fit its
|
||||
// content, still capped by whatever actually fits the background.
|
||||
func WithWidth(w int) DrawerOption {
|
||||
return func(d *Drawer) { d.Width = w }
|
||||
}
|
||||
|
||||
// WithDrawerStyle overrides the Model's default Styles for this drawer
|
||||
// alone, for a one-off custom look instead of the shared theme.
|
||||
func WithDrawerStyle(s Styles) DrawerOption {
|
||||
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
|
||||
}
|
||||
|
||||
// ShowMsg tells a drawer.Model to display Drawer, pushing it on top of the
|
||||
// stack. Any component in the same bubbletea program can trigger one via
|
||||
// Show, without holding a reference to the drawer.Model that will actually
|
||||
// render it - that Model just needs to see every tea.Msg the program
|
||||
// produces, same as any other child model.
|
||||
type ShowMsg struct{ Drawer Drawer }
|
||||
|
||||
// Show returns a tea.Cmd that opens a new drawer on top of the stack,
|
||||
// anchored to the left edge by default. content is a model, so a drawer can
|
||||
// hold anything a pane can - a file list, a filter form, a picker that
|
||||
// reports its choice with its own tea.Msg:
|
||||
//
|
||||
// return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
|
||||
// return m, drawer.Show("Nav", drawer.Text("Home\nProjects"))
|
||||
//
|
||||
// The content's Init runs when the drawer opens, and it receives every
|
||||
// message while it's the topmost drawer (see Model.Update).
|
||||
func Show(title string, content tea.Model, opts ...DrawerOption) tea.Cmd {
|
||||
d := newDrawer(title, content, opts...)
|
||||
return func() tea.Msg { return ShowMsg{Drawer: d} }
|
||||
}
|
||||
|
||||
// DismissMsg closes the topmost drawer. The stack is a plain LIFO: a drawer
|
||||
// is closed by being on top, never by being named.
|
||||
type DismissMsg struct{}
|
||||
|
||||
// Close returns a tea.Cmd that closes the topmost drawer - which is also the
|
||||
// only one that can act (see Model.Update), so a content model closes itself
|
||||
// by returning it:
|
||||
//
|
||||
// return c, drawer.Close()
|
||||
func Close() tea.Cmd {
|
||||
return func() tea.Msg { return DismissMsg{} }
|
||||
}
|
||||
|
||||
// text is a model wrapping a fixed string: a drawer body with nothing to
|
||||
// update.
|
||||
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)) }
|
||||
|
||||
// Text wraps a plain string as drawer content, for the common drawer that has
|
||||
// nothing to interact with:
|
||||
//
|
||||
// drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
|
||||
func Text(s string) tea.Model { return text(s) }
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package drawer renders a full-height panel flush against the left or
|
||||
// right edge of an already-rendered background, on top of it dimmed -
|
||||
// the sidebar/drawer equivalent of github.com/anotherhadi/ilovetui/modal,
|
||||
// which this package otherwise mirrors closely: same stack of panels
|
||||
// triggered from anywhere via an exported tea.Msg (see ShowMsg/Show)
|
||||
// rather than a direct reference to the Model that ends up rendering it,
|
||||
// same composite-over-an-already-rendered-string Render, same absence of
|
||||
// any assumption about how the host builds that string.
|
||||
//
|
||||
// A drawer's content is a model, not a string: it is updated while it's on
|
||||
// top of the stack, so it can hold anything a pane can - a file list, a
|
||||
// filter form, a picker reporting its choice back with its own tea.Msg. See
|
||||
// Show and Text.
|
||||
package drawer
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
// Model holds the currently open drawers (a stack: the most recently shown
|
||||
// is drawn on top, everything beneath it - the background and any earlier
|
||||
// drawer - dimmed, see Render) and the rendering config (max width, styles)
|
||||
// they share. Build one with New.
|
||||
type Model struct {
|
||||
drawers []Drawer
|
||||
nextID int
|
||||
maxWidth int
|
||||
styles Styles
|
||||
}
|
||||
|
||||
// Option configures a Model at construction. See WithMaxWidth, WithStyles.
|
||||
type Option func(*Model)
|
||||
|
||||
// WithMaxWidth caps a drawer's total width (border and padding included). A
|
||||
// drawer shown without WithWidth shrinks to fit its content instead of
|
||||
// padding out to the cap; a drawer shown with WithWidth uses that width
|
||||
// instead, still capped by this. 0 means only the background's own width
|
||||
// caps it.
|
||||
func WithMaxWidth(w int) Option {
|
||||
return func(m *Model) { m.maxWidth = w }
|
||||
}
|
||||
|
||||
// WithStyles overrides the default styles (see DefaultStyles).
|
||||
func WithStyles(s Styles) Option {
|
||||
return func(m *Model) { m.styles = s }
|
||||
}
|
||||
|
||||
// New builds a Model. Defaults: a 30-column max width, DefaultStyles.
|
||||
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)
|
||||
}
|
||||
|
||||
// updateTop forwards msg to the topmost drawer's content - the only one the
|
||||
// user can interact with, everything beneath it being dimmed (see Render). A
|
||||
// drawer deeper in the stack is frozen until the ones above it close.
|
||||
//
|
||||
// This is what lets drawer content be a real model: it gets the key presses,
|
||||
// the ticks and the results of its own commands, and can report back to the
|
||||
// rest of the program with a tea.Msg of its own.
|
||||
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
|
||||
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
|
||||
}
|
||||
|
||||
// Open reports whether at least one drawer is currently shown - handy for a
|
||||
// host that wants to route key presses to the drawer (e.g. esc to dismiss)
|
||||
// instead of its normal UI while one is open.
|
||||
func (m Model) Open() bool { return len(m.drawers) > 0 }
|
||||
|
||||
// show pushes a drawer on top of the stack and returns its content's Init - a
|
||||
// drawer's body starts the same way any other model does.
|
||||
func (m Model) show(d Drawer) (Model, tea.Cmd) {
|
||||
m.drawers = append(m.drawers, d)
|
||||
return m, initContent(d)
|
||||
}
|
||||
|
||||
// initContent is d's content's Init, or nil for a drawer without content.
|
||||
func initContent(d Drawer) tea.Cmd {
|
||||
if d.Content == nil {
|
||||
return nil
|
||||
}
|
||||
return d.Content.Init()
|
||||
}
|
||||
|
||||
// pop closes the topmost drawer (see Close).
|
||||
func (m Model) pop() Model {
|
||||
if len(m.drawers) == 0 {
|
||||
return m
|
||||
}
|
||||
m.drawers = m.drawers[:len(m.drawers)-1]
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package drawer
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// Render draws every open drawer (see Model.Update/Show) on top of
|
||||
// background (already rendered, e.g. layout.Model.View() or any other
|
||||
// component's View()) and returns the result. Each drawer in the stack
|
||||
// first flattens whatever came before it - background plus any earlier
|
||||
// drawer - to a single flat DimColor (see dim), then draws its own
|
||||
// full-height box flush against its Side on top, so nesting a second
|
||||
// drawer on top of a first dims the first one too. background is returned
|
||||
// unchanged whenever there's nothing to draw.
|
||||
func (m Model) Render(background string) string {
|
||||
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
|
||||
}
|
||||
|
||||
// View is a convenience for a pane whose sole purpose is showing drawers
|
||||
// (e.g. a dedicated layout.Leaf): it draws the stack over a blank
|
||||
// width x height area instead of an existing background.
|
||||
func (m Model) View(width, height int) string {
|
||||
return m.Render(blank(width, height))
|
||||
}
|
||||
|
||||
// renderOne dims background flat and draws d's box, spanning its full
|
||||
// height, flush against d.Side.
|
||||
func (m Model) renderOne(d Drawer, background string, w, h int) string {
|
||||
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()
|
||||
}
|
||||
|
||||
// dim flattens s to a single flat color: every existing style (colors,
|
||||
// bold, underline...) is stripped, then every character - including
|
||||
// whitespace, so highlighted/selected backgrounds vanish too - is
|
||||
// repainted in c. Applying a Foreground style to a multi-line string styles
|
||||
// each line independently (see lipgloss.Style.Render), so this keeps s's
|
||||
// line structure intact.
|
||||
func dim(s string, c color.Color) string {
|
||||
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
|
||||
}
|
||||
|
||||
// renderBox draws d as a bordered, title-embedded box (style.RenderWithTitle)
|
||||
// spanning the background's full height, capped by the Model's configured
|
||||
// max width and by whatever actually fits inside a bgW-wide background.
|
||||
func (m Model) renderBox(d Drawer, s Styles, bgW, bgH int) string {
|
||||
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 // border (2) + Padding(0, 1) (2)
|
||||
|
||||
return style.RenderWithTitle(s.Border, s.Title.Render(d.Title), content, boxWidth, bgH)
|
||||
}
|
||||
|
||||
// contentView is the drawer body's rendered string, or "" for a drawer
|
||||
// without content. The box shrinks to fit whatever the content model draws,
|
||||
// so a content that wants a specific size sets it on itself - the drawer only
|
||||
// ever sees the result.
|
||||
func contentView(d Drawer) string {
|
||||
if d.Content == nil {
|
||||
return ""
|
||||
}
|
||||
return d.Content.View().Content
|
||||
}
|
||||
|
||||
// contentWidth is the drawer's inner (border/padding excluded) width, given
|
||||
// the resolved total-width cap maxWidth (see renderBox): maxWidth-4 if
|
||||
// d.Width is set (a fixed total width), otherwise its natural size (long
|
||||
// enough for the widest line of title/content), capped at maxWidth-4.
|
||||
func contentWidth(d Drawer, body string, maxWidth int) int {
|
||||
capped := max(maxWidth-4, 1)
|
||||
if d.Width > 0 {
|
||||
return capped
|
||||
}
|
||||
natural := max(naturalWidth(body), lipgloss.Width(d.Title), 1)
|
||||
return min(natural, capped)
|
||||
}
|
||||
|
||||
// naturalWidth is the width of content's widest line.
|
||||
func naturalWidth(content string) int {
|
||||
w := 0
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if lw := lipgloss.Width(line); lw > w {
|
||||
w = lw
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// effectiveMax resolves the cap actually used along the width axis:
|
||||
// configured (0 = unlimited) narrowed down to fits, whatever actually fits
|
||||
// the background - a drawer can never overflow past the edge of the
|
||||
// background, or the terminal, when the background is a full-screen View(),
|
||||
// regardless of how WithMaxWidth/WithWidth was set.
|
||||
func effectiveMax(configured, fits int) int {
|
||||
if fits < 1 {
|
||||
fits = 1
|
||||
}
|
||||
if configured > 0 && configured < fits {
|
||||
return configured
|
||||
}
|
||||
return fits
|
||||
}
|
||||
|
||||
func blank(width, height int) string {
|
||||
if width <= 0 || height <= 0 {
|
||||
return ""
|
||||
}
|
||||
line := strings.Repeat(" ", width)
|
||||
lines := make([]string, height)
|
||||
for i := range lines {
|
||||
lines[i] = line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,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")
|
||||
}
|
||||
// The border's top-left corner glyph should be the very first rune.
|
||||
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 { // WithWidth is the total box width
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package drawer
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// Styles is the set of lipgloss styles, plus the dim color, used to render a
|
||||
// drawer and the background behind it. Border carries the box's border
|
||||
// (shape + color, no size), Title and Content color the two pieces of text
|
||||
// drawn inside it, DimColor is the single flat color every character of the
|
||||
// background gets overwritten with while the drawer is open.
|
||||
type Styles struct {
|
||||
Border lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
Content lipgloss.Style
|
||||
DimColor color.Color
|
||||
}
|
||||
|
||||
// DefaultStyles builds a Styles from style.S: the box borrows
|
||||
// PanelFocused's border (the drawer is what has focus while it's open).
|
||||
// DimColor reuses Subtle - the base16 "comments/invisibles" role, already
|
||||
// used across this repo for de-emphasized text (borders, placeholders,
|
||||
// separators, see bubbles/*.go and modal.DefaultStyles) - darker than
|
||||
// Muted, which reads too bright once it's covering an entire screen instead
|
||||
// of a single blurred field.
|
||||
func DefaultStyles() Styles {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/drawer"
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
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
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
|
||||
case "l":
|
||||
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"),
|
||||
drawer.WithSide(drawer.Left), drawer.WithWidth(20))
|
||||
|
||||
case "r":
|
||||
return m, drawer.Show("Inspector", drawer.Text("id: 42\nstatus: ok"),
|
||||
drawer.WithSide(drawer.Right), drawer.WithWidth(20))
|
||||
|
||||
case "esc":
|
||||
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 {
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App")
|
||||
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(
|
||||
"Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the drawer.")
|
||||
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
|
||||
"l: open left drawer r: open right drawer esc: close the top one q: quit")
|
||||
|
||||
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
||||
lipgloss.JoinVertical(lipgloss.Center, title, "", body, "", help))
|
||||
|
||||
view := tea.NewView(m.d.Render(background))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/drawer"
|
||||
"github.com/anotherhadi/ilovetui/examples/fullapp/metrics"
|
||||
"github.com/anotherhadi/ilovetui/examples/fullapp/overview"
|
||||
"github.com/anotherhadi/ilovetui/examples/fullapp/settings"
|
||||
"github.com/anotherhadi/ilovetui/examples/fullapp/sidebar"
|
||||
"github.com/anotherhadi/ilovetui/helpbar"
|
||||
"github.com/anotherhadi/ilovetui/modal"
|
||||
"github.com/anotherhadi/ilovetui/notification"
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
var pages = []struct {
|
||||
item sidebar.NavItem
|
||||
new func() tea.Model
|
||||
}{
|
||||
{sidebar.NavItem{Icon: "", Name: "Overview"}, func() tea.Model { return overview.New() }},
|
||||
{sidebar.NavItem{Icon: "", Name: "Metrics"}, func() tea.Model { return metrics.New() }},
|
||||
{sidebar.NavItem{Icon: "", Name: "Settings"}, func() tea.Model { return settings.New() }},
|
||||
}
|
||||
|
||||
type HelpProvider interface {
|
||||
HelpBindings() []key.Binding
|
||||
}
|
||||
|
||||
func navItems() []sidebar.NavItem {
|
||||
items := make([]sidebar.NavItem, len(pages))
|
||||
for i, p := range pages {
|
||||
items[i] = p.item
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
type keyMap struct {
|
||||
FocusSidebar key.Binding
|
||||
Close key.Binding
|
||||
Help key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeyMap() keyMap {
|
||||
return keyMap{
|
||||
FocusSidebar: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "focus sidebar")),
|
||||
Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q / ctrl+c", "quit")),
|
||||
}
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
sidebar sidebar.Model
|
||||
page tea.Model
|
||||
help helpbar.Model
|
||||
notif notification.Model
|
||||
modal modal.Model
|
||||
drawer drawer.Model
|
||||
keys keyMap
|
||||
|
||||
sidebarWidth int
|
||||
hideNav bool
|
||||
contentFocused bool
|
||||
width, height int
|
||||
}
|
||||
|
||||
func NewModel() Model {
|
||||
keys := defaultKeyMap()
|
||||
return Model{
|
||||
sidebar: sidebar.New(navItems()...),
|
||||
sidebarWidth: 24,
|
||||
page: pages[0].new(),
|
||||
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.FocusSidebar, keys.Quit)),
|
||||
notif: notification.New(),
|
||||
modal: modal.New(),
|
||||
drawer: drawer.New(),
|
||||
keys: keys,
|
||||
hideNav: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return m.page.Init()
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
return m.resize()
|
||||
|
||||
case sidebar.SelectMsg:
|
||||
m.page = pages[msg.Index].new()
|
||||
var cmd tea.Cmd
|
||||
m, cmd = m.resize()
|
||||
return m, tea.Batch(m.page.Init(), cmd)
|
||||
|
||||
case sidebar.BlurMsg:
|
||||
m.contentFocused = true
|
||||
return m.resize()
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if m.modal.Open() {
|
||||
if key.Matches(msg, m.keys.Close) {
|
||||
return m, modal.Close()
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.modal, cmd = m.modal.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
if m.drawer.Open() {
|
||||
if key.Matches(msg, m.keys.Close) {
|
||||
return m, drawer.Close()
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.drawer, cmd = m.drawer.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Help):
|
||||
m.help, _ = m.help.Update(msg)
|
||||
return m.resize()
|
||||
case key.Matches(msg, m.keys.FocusSidebar):
|
||||
m.contentFocused = !m.contentFocused
|
||||
return m.resize()
|
||||
}
|
||||
|
||||
if m.contentFocused {
|
||||
var cmd tea.Cmd
|
||||
m.page, cmd = m.page.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.sidebar, cmd = m.sidebar.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
var notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd tea.Cmd
|
||||
m.notif, notifCmd = m.notif.Update(msg)
|
||||
m.modal, modalCmd = m.modal.Update(msg)
|
||||
m.drawer, drawerCmd = m.drawer.Update(msg)
|
||||
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
|
||||
m.page, pageCmd = m.page.Update(msg)
|
||||
return m, tea.Batch(notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd)
|
||||
}
|
||||
|
||||
func (m Model) navWidth() int {
|
||||
if m.hideNav && m.contentFocused {
|
||||
return 0
|
||||
}
|
||||
return m.sidebarWidth
|
||||
}
|
||||
|
||||
func (m Model) resize() (Model, tea.Cmd) {
|
||||
if m.width <= 0 || m.height <= 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.help.SetWidth(m.width)
|
||||
inner := style.ContentHeight(m.height - m.help.Height(m.focusedHelp()...))
|
||||
|
||||
navW := m.navWidth()
|
||||
m.sidebar.SetSize(max(navW-2, 0), inner)
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.page, cmd = m.page.Update(tea.WindowSizeMsg{
|
||||
Width: max(m.width-navW-2, 0),
|
||||
Height: inner,
|
||||
})
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) focusedHelp() []key.Binding {
|
||||
if !m.contentFocused {
|
||||
return m.sidebar.HelpBindings()
|
||||
}
|
||||
if hp, ok := m.page.(HelpProvider); ok {
|
||||
return hp.HelpBindings()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
if m.width <= 0 || m.height <= 0 {
|
||||
return tea.NewView("")
|
||||
}
|
||||
helpBar := m.help.View(m.focusedHelp()...)
|
||||
bodyH := max(m.height-lipgloss.Height(helpBar), 0)
|
||||
|
||||
navW := m.navWidth()
|
||||
body := style.RenderWithTitle(
|
||||
panel(m.contentFocused), m.sidebar.Selected().Name, m.page.View().Content, m.width-navW, bodyH)
|
||||
|
||||
if navW > 0 {
|
||||
left := style.RenderWithTitle(
|
||||
panel(!m.contentFocused), "Menu", m.sidebar.View(), navW, bodyH)
|
||||
body = lipgloss.JoinHorizontal(lipgloss.Top, left, body)
|
||||
}
|
||||
|
||||
appView := lipgloss.JoinVertical(lipgloss.Left,
|
||||
body,
|
||||
helpBar,
|
||||
)
|
||||
|
||||
view := tea.NewView(m.notif.Render(m.modal.Render(m.drawer.Render(appView))))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func panel(focused bool) lipgloss.Style {
|
||||
if focused {
|
||||
return style.S.PanelFocused
|
||||
}
|
||||
return style.S.Panel
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(NewModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package metrics is the fullapp example's second page: a spinner and a few
|
||||
// gauges. It has no keys either, but unlike overview it runs a command, so
|
||||
// it's what proves the shell keeps feeding ticks to a pane that doesn't have
|
||||
// focus.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"charm.land/bubbles/v2/progress"
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/bubbles"
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// gaugeWidth is how wide a bar renders, before the label in front of it.
|
||||
const gaugeWidth = 30
|
||||
|
||||
type gauge struct {
|
||||
name string
|
||||
percent float64
|
||||
}
|
||||
|
||||
// Model is the page. Values are hardcoded - this is a layout test, not a
|
||||
// monitoring tool.
|
||||
type Model struct {
|
||||
spinner spinner.Model
|
||||
bar progress.Model
|
||||
gauges []gauge
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
bar := bubbles.NewProgress()
|
||||
bar.SetWidth(gaugeWidth)
|
||||
return Model{
|
||||
spinner: bubbles.NewSpinner(spinner.WithSpinner(spinner.MiniDot)),
|
||||
bar: bar,
|
||||
gauges: []gauge{
|
||||
{"cpu", 0.42},
|
||||
{"memory", 0.71},
|
||||
{"disk", 0.13},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Init starts the spinner ticking. The shell returns it from its own Init,
|
||||
// or the spinner never starts.
|
||||
func (m Model) Init() tea.Cmd { return m.spinner.Tick }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if size, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.width, m.height = size.Width, size.Height
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Every other message goes to the spinner, whose own tick keeps it
|
||||
// turning - including while another pane has focus.
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
rows := []string{style.S.Bold.Render(m.spinner.View() + " collecting")}
|
||||
for _, g := range m.gauges {
|
||||
// ViewAs renders a given percentage without animating toward it,
|
||||
// which is all a fixed value needs.
|
||||
rows = append(rows, fmt.Sprintf("%-8s %s", g.name, m.bar.ViewAs(g.percent)))
|
||||
}
|
||||
|
||||
return tea.NewView(lipgloss.NewStyle().
|
||||
Width(m.width).Height(m.height).
|
||||
AlignHorizontal(lipgloss.Center).
|
||||
AlignVertical(lipgloss.Center).
|
||||
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Package overview is the fullapp example's first page: the simplest pane
|
||||
// there is - no keys, no commands, just text sized to whatever room the
|
||||
// shell gives it.
|
||||
package overview
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// Model is the page. It's an ordinary tea.Model: nothing about it knows it
|
||||
// lives in a shell, and it never implements HelpBindings because it has no
|
||||
// keys of its own to advertise.
|
||||
type Model struct {
|
||||
width, height int
|
||||
}
|
||||
|
||||
func New() Model { return Model{} }
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
// Update only tracks the size the shell hands down as a tea.WindowSizeMsg,
|
||||
// same as if the page were the whole program.
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if size, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.width, m.height = size.Width, size.Height
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
body := lipgloss.JoinVertical(lipgloss.Center,
|
||||
style.S.Bold.Render("Overview"),
|
||||
"",
|
||||
style.S.Faint.Render("tab focuses this pane, but there's"),
|
||||
style.S.Faint.Render("nothing here to focus on."),
|
||||
)
|
||||
return tea.NewView(lipgloss.NewStyle().
|
||||
Width(m.width).Height(m.height).
|
||||
AlignHorizontal(lipgloss.Center).
|
||||
AlignVertical(lipgloss.Center).
|
||||
Render(body))
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Package settings is the fullapp example's third page: a short list of
|
||||
// toggles. It's the one page with keys of its own, so it's what proves the
|
||||
// shell routes presses to the focused pane and lists that pane's bindings in
|
||||
// the help bar.
|
||||
package settings
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
type toggle struct {
|
||||
name string
|
||||
on bool
|
||||
}
|
||||
|
||||
type keyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Toggle key.Binding
|
||||
}
|
||||
|
||||
// Model is the page. The cursor is a plain int: three lines don't need a
|
||||
// list.Model behind them.
|
||||
type Model struct {
|
||||
keys keyMap
|
||||
toggles []toggle
|
||||
cursor int
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
return Model{
|
||||
keys: keyMap{
|
||||
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||
Toggle: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")),
|
||||
},
|
||||
toggles: []toggle{
|
||||
{name: "Nerd fonts", on: style.S.NerdFonts},
|
||||
{name: "Notifications", on: true},
|
||||
{name: "Telemetry", on: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
// The shell only sends these while this pane has focus, so there's
|
||||
// no focused check to make here.
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
m.cursor = max(m.cursor-1, 0)
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.cursor = min(m.cursor+1, len(m.toggles)-1)
|
||||
case key.Matches(msg, m.keys.Toggle):
|
||||
m.toggles[m.cursor].on = !m.toggles[m.cursor].on
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
rows := make([]string, len(m.toggles))
|
||||
for i, t := range m.toggles {
|
||||
cursor, box := " ", "[ ]"
|
||||
if i == m.cursor {
|
||||
cursor = "> "
|
||||
}
|
||||
if t.on {
|
||||
box = "[x]"
|
||||
}
|
||||
line := cursor + box + " " + t.name
|
||||
if i == m.cursor {
|
||||
line = lipgloss.NewStyle().Foreground(style.S.Primary).Render(line)
|
||||
}
|
||||
rows[i] = line
|
||||
}
|
||||
|
||||
return tea.NewView(lipgloss.NewStyle().
|
||||
Width(m.width).Height(m.height).
|
||||
AlignHorizontal(lipgloss.Center).
|
||||
AlignVertical(lipgloss.Center).
|
||||
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
|
||||
}
|
||||
|
||||
// HelpBindings makes the page's keys show up in the shell's help bar while
|
||||
// it has focus. It's the optional half of the contract: overview and metrics
|
||||
// have no keys and don't implement it.
|
||||
func (m Model) HelpBindings() []key.Binding {
|
||||
return []key.Binding{m.keys.Up, m.keys.Down, m.keys.Toggle}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package sidebar
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/bubbles"
|
||||
)
|
||||
|
||||
type NavItem struct {
|
||||
Icon string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (n NavItem) Title() string {
|
||||
if n.Icon == "" {
|
||||
return n.Name
|
||||
}
|
||||
return n.Icon + " " + n.Name
|
||||
}
|
||||
|
||||
func (n NavItem) Description() string { return "" }
|
||||
func (n NavItem) FilterValue() string { return n.Name }
|
||||
|
||||
type SelectMsg struct {
|
||||
Index int
|
||||
Item NavItem
|
||||
}
|
||||
|
||||
// BlurMsg is the sidebar asking to be given up: it goes out with the
|
||||
// SelectMsg, on the grounds that picking an entry means you're done with the
|
||||
// menu. Where focus lands instead is the host's call - the sidebar has no
|
||||
// idea what else is on screen.
|
||||
type BlurMsg struct{}
|
||||
|
||||
// blur is BlurMsg's command form.
|
||||
func blur() tea.Msg { return BlurMsg{} }
|
||||
|
||||
type KeyMap struct {
|
||||
Select key.Binding
|
||||
}
|
||||
|
||||
func DefaultKeyMap() KeyMap {
|
||||
return KeyMap{
|
||||
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
|
||||
}
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
KeyMap KeyMap
|
||||
|
||||
list list.Model
|
||||
items []NavItem
|
||||
selected int
|
||||
}
|
||||
|
||||
func New(items ...NavItem) Model {
|
||||
d := bubbles.NewDefaultDelegate()
|
||||
d.ShowDescription = false
|
||||
d.SetSpacing(0)
|
||||
|
||||
l := bubbles.NewList(listItems(items), 0, 0)
|
||||
l.SetDelegate(d)
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowPagination(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
l.DisableQuitKeybindings()
|
||||
|
||||
return Model{
|
||||
KeyMap: DefaultKeyMap(),
|
||||
list: l,
|
||||
items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func listItems(items []NavItem) []list.Item {
|
||||
out := make([]list.Item, len(items))
|
||||
for i, item := range items {
|
||||
out[i] = item
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
if press, ok := msg.(tea.KeyPressMsg); ok && key.Matches(press, m.KeyMap.Select) {
|
||||
// Only the interactive path asks for focus to move on. Select on its
|
||||
// own doesn't, because the host also calls it to set the starting
|
||||
// entry - which must not steal focus from anything.
|
||||
return m, tea.Batch(m.Select(m.list.Index()), blur)
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) Select(index int) tea.Cmd {
|
||||
if index < 0 || index >= len(m.items) {
|
||||
return nil
|
||||
}
|
||||
m.selected = index
|
||||
m.list.Select(index)
|
||||
|
||||
item := m.items[index]
|
||||
return func() tea.Msg { return SelectMsg{Index: index, Item: item} }
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(width, height int) { m.list.SetSize(width, height) }
|
||||
|
||||
func (m Model) Selected() NavItem {
|
||||
if m.selected < 0 || m.selected >= len(m.items) {
|
||||
return NavItem{}
|
||||
}
|
||||
return m.items[m.selected]
|
||||
}
|
||||
|
||||
func (m Model) SelectedIndex() int { return m.selected }
|
||||
|
||||
func (m Model) Cursor() int { return m.list.Index() }
|
||||
|
||||
func (m Model) View() string { return m.list.View() }
|
||||
|
||||
func (m Model) HelpBindings() []key.Binding {
|
||||
return []key.Binding{
|
||||
m.list.KeyMap.CursorUp,
|
||||
m.list.KeyMap.CursorDown,
|
||||
m.KeyMap.Select,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/modal"
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// confirmedMsg is what the confirmation modal reports back with. The app
|
||||
// listens for it like any other message - it never holds a reference to the
|
||||
// modal, and the modal never knows what confirming means.
|
||||
type confirmedMsg struct{}
|
||||
|
||||
// confirm is the modal's content: a model, so it owns its own keys. The host
|
||||
// no longer has to ask which modal is on top to know where "y" should go.
|
||||
type confirm struct{}
|
||||
|
||||
func (c confirm) Init() tea.Cmd { return nil }
|
||||
|
||||
func (c confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "y" {
|
||||
// Report back and close itself: Close() is a package-level command,
|
||||
// so the content needs no reference to the modal.Model either.
|
||||
return c, tea.Batch(
|
||||
func() tea.Msg { return confirmedMsg{} },
|
||||
modal.Close(),
|
||||
)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c confirm) View() tea.View {
|
||||
return tea.NewView("This can't be undone.\n\ny: confirm esc: cancel")
|
||||
}
|
||||
|
||||
type model struct {
|
||||
m modal.Model
|
||||
deleted bool
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
return model{m: modal.New()}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.m.Init() }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
return m, nil
|
||||
|
||||
case confirmedMsg:
|
||||
m.deleted = true
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
|
||||
case "m":
|
||||
return m, modal.Show("Delete file?", confirm{})
|
||||
|
||||
case "n":
|
||||
if m.m.Open() {
|
||||
return m, modal.Show("Really sure?", modal.Text("There's no undo for this one either."))
|
||||
}
|
||||
|
||||
case "esc":
|
||||
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 {
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App")
|
||||
text := "Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the modal."
|
||||
if m.deleted {
|
||||
text = "File deleted - the modal's content reported back\nwith its own message, and closed itself."
|
||||
}
|
||||
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(text)
|
||||
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
|
||||
"m: open modal n: open nested modal y: confirm esc: cancel q: quit")
|
||||
|
||||
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
||||
lipgloss.JoinVertical(lipgloss.Center, title, "", body, "", help))
|
||||
|
||||
view := tea.NewView(m.m.Render(background))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/notification"
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
var positions = []struct {
|
||||
name string
|
||||
pos notification.Position
|
||||
}{
|
||||
{"top", notification.Top},
|
||||
{"top-left", notification.TopLeft},
|
||||
{"top-right", notification.TopRight},
|
||||
{"bottom", notification.Bottom},
|
||||
{"bottom-left", notification.BottomLeft},
|
||||
{"bottom-right", notification.BottomRight},
|
||||
}
|
||||
|
||||
const stickyID = "sticky-demo"
|
||||
|
||||
type model struct {
|
||||
notif notification.Model
|
||||
posIdx int
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
return model{notif: notification.New(notification.WithPosition(positions[0].pos))}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return m.notif.Init()
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
|
||||
case "1":
|
||||
return m, notification.Show("Info", "Just so you know.", notification.Info)
|
||||
case "2":
|
||||
return m, notification.Show("Success", "Config written to disk.", notification.Success)
|
||||
case "3":
|
||||
return m, notification.Show("Warning", "Disk space getting low on /dev/sda1.", notification.Warning)
|
||||
case "4":
|
||||
return m, notification.Show("Error", "Failed to reach the remote host.", notification.Error)
|
||||
|
||||
case "s":
|
||||
return m, notification.Show("Sticky", "Stays until you press d.",
|
||||
notification.Info, notification.WithID(stickyID), notification.WithDuration(0))
|
||||
case "d":
|
||||
return m, notification.Dismiss(stickyID)
|
||||
|
||||
case "p":
|
||||
m.posIdx = (m.posIdx + 1) % len(positions)
|
||||
m.notif = notification.New(notification.WithPosition(positions[m.posIdx].pos))
|
||||
return m, m.notif.Init()
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.notif, cmd = m.notif.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
|
||||
"1-4: info/success/warning/error s: sticky d: dismiss sticky p: position (" +
|
||||
positions[m.posIdx].name + ") q: quit")
|
||||
|
||||
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, help)
|
||||
|
||||
view := tea.NewView(m.notif.Render(background))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Command sidebar is the whole app-shell pattern in one file: a sidebar on
|
||||
// the left, an ordinary tea.Model on the right, a global help bar at the
|
||||
// bottom, tab to move focus between the two. No layout package involved -
|
||||
// lipgloss.JoinHorizontal/JoinVertical and style.RenderWithTitle already do
|
||||
// all of it, and the panes stay plain tea.Models.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/bubbles"
|
||||
"github.com/anotherhadi/ilovetui/helpbar"
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// sidebarWidth is the sidebar's total width, border included.
|
||||
const sidebarWidth = 24
|
||||
|
||||
// HelpProvider is the only contract in this pattern, and it's optional: a
|
||||
// pane that implements it gets its own bindings listed in the global help
|
||||
// bar while it's focused. A pane that doesn't just contributes nothing.
|
||||
type HelpProvider interface {
|
||||
HelpBindings() []key.Binding
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shell keys
|
||||
|
||||
type keyMap struct {
|
||||
Focus key.Binding
|
||||
Help key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeyMap() keyMap {
|
||||
return keyMap{
|
||||
Focus: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch pane")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q", "quit")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the shell
|
||||
|
||||
type model struct {
|
||||
sidebar list.Model
|
||||
content tea.Model
|
||||
help helpbar.Model
|
||||
keys keyMap
|
||||
|
||||
contentFocused bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
items := []list.Item{page("Overview"), page("Metrics"), page("Settings")}
|
||||
sidebar := bubbles.NewList(items, sidebarWidth-2, 0)
|
||||
sidebar.SetShowTitle(false)
|
||||
sidebar.SetShowStatusBar(false)
|
||||
sidebar.SetShowHelp(false)
|
||||
|
||||
keys := defaultKeyMap()
|
||||
return model{
|
||||
sidebar: sidebar,
|
||||
content: newCounter(),
|
||||
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.Focus, keys.Quit)),
|
||||
keys: keys,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.content.Init() }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.w, m.h = msg.Width, msg.Height
|
||||
return m.resize()
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Help):
|
||||
// Expanding the bar takes rows away from the panes.
|
||||
m.help, _ = m.help.Update(msg)
|
||||
return m.resize()
|
||||
case key.Matches(msg, m.keys.Focus):
|
||||
m.contentFocused = !m.contentFocused
|
||||
return m, nil
|
||||
}
|
||||
// Only the focused pane sees key presses.
|
||||
if m.contentFocused {
|
||||
var cmd tea.Cmd
|
||||
m.content, cmd = m.content.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.sidebar, cmd = m.sidebar.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Everything else (ticks, HTTP responses...) goes to both, so a blurred
|
||||
// pane keeps working.
|
||||
var sidebarCmd, contentCmd tea.Cmd
|
||||
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
|
||||
m.content, contentCmd = m.content.Update(msg)
|
||||
return m, tea.Batch(sidebarCmd, contentCmd)
|
||||
}
|
||||
|
||||
// bodyHeight is the height left for the two panes once the help bar has
|
||||
// taken its share. resize and View both go through it so they can't disagree.
|
||||
func (m model) bodyHeight() int {
|
||||
return max(m.h-m.help.Height(m.focusedHelp()...), 0)
|
||||
}
|
||||
|
||||
func (m model) resize() (model, tea.Cmd) {
|
||||
if m.w <= 0 || m.h <= 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.help.SetWidth(m.w)
|
||||
inner := style.ContentHeight(m.bodyHeight())
|
||||
|
||||
m.sidebar.SetSize(sidebarWidth-2, inner)
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.content, cmd = m.content.Update(tea.WindowSizeMsg{
|
||||
Width: max(m.w-sidebarWidth-2, 0), Height: inner,
|
||||
})
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// focusedHelp is the focused pane's own bindings, if it offers any.
|
||||
func (m model) focusedHelp() []key.Binding {
|
||||
if m.contentFocused {
|
||||
if hp, ok := m.content.(HelpProvider); ok {
|
||||
return hp.HelpBindings()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
if m.w <= 0 || m.h <= 0 {
|
||||
return tea.NewView("")
|
||||
}
|
||||
helpBar := m.help.View(m.focusedHelp()...)
|
||||
bodyH := m.bodyHeight()
|
||||
|
||||
left := style.RenderWithTitle(
|
||||
panel(!m.contentFocused), "Menu", m.sidebar.View(), sidebarWidth, bodyH)
|
||||
right := style.RenderWithTitle(
|
||||
panel(m.contentFocused), "Content", m.content.View().Content, m.w-sidebarWidth, bodyH)
|
||||
|
||||
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
|
||||
lipgloss.JoinHorizontal(lipgloss.Top, left, right),
|
||||
helpBar,
|
||||
))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
// panel picks the bordered panel style matching a pane's focus state.
|
||||
func panel(focused bool) lipgloss.Style {
|
||||
if focused {
|
||||
return style.S.PanelFocused
|
||||
}
|
||||
return style.S.Panel
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- the right-hand pane
|
||||
|
||||
// counter is an ordinary tea.Model - nothing about it knows it's living in a
|
||||
// shell. It implements HelpProvider purely to appear in the help bar.
|
||||
type counter struct {
|
||||
n int
|
||||
w, h int
|
||||
keys struct{ Inc, Dec key.Binding }
|
||||
}
|
||||
|
||||
func newCounter() *counter {
|
||||
c := &counter{}
|
||||
c.keys.Inc = key.NewBinding(key.WithKeys("+", "k"), key.WithHelp("+/k", "increment"))
|
||||
c.keys.Dec = key.NewBinding(key.WithKeys("-", "j"), key.WithHelp("-/j", "decrement"))
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *counter) Init() tea.Cmd { return nil }
|
||||
|
||||
func (c *counter) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
c.w, c.h = msg.Width, msg.Height
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, c.keys.Inc):
|
||||
c.n++
|
||||
case key.Matches(msg, c.keys.Dec):
|
||||
c.n--
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *counter) View() tea.View {
|
||||
return tea.NewView(lipgloss.NewStyle().
|
||||
Width(c.w).Height(c.h).
|
||||
AlignHorizontal(lipgloss.Center).
|
||||
AlignVertical(lipgloss.Center).
|
||||
Render(fmt.Sprintf("count: %d", c.n)))
|
||||
}
|
||||
|
||||
func (c *counter) HelpBindings() []key.Binding {
|
||||
return []key.Binding{c.keys.Inc, c.keys.Dec}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- sidebar items
|
||||
|
||||
type page string
|
||||
|
||||
func (p page) Title() string { return string(p) }
|
||||
func (p page) Description() string { return "" }
|
||||
func (p page) FilterValue() string { return string(p) }
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/tabs"
|
||||
)
|
||||
|
||||
// pane is a minimal tabs.Tab implementation. It keeps its own counter to
|
||||
// show that each tab's model has independent state that persists across
|
||||
// switches, and its own width/height to show how a host propagates size
|
||||
// down to a wrapped Tab (see model.Update's tea.WindowSizeMsg case).
|
||||
type pane struct {
|
||||
name string
|
||||
count int
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newPane(name string) pane {
|
||||
return pane{name: name}
|
||||
}
|
||||
|
||||
func (p pane) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p pane) Update(msg tea.Msg) (tabs.Tab, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
p.width, p.height = msg.Width, msg.Height
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "+" {
|
||||
p.count++
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p pane) View() string {
|
||||
return fmt.Sprintf("%s\n\npress + to increment: %d\n(content area: %dx%d)", p.name, p.count, p.width, p.height)
|
||||
}
|
||||
|
||||
var docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2)
|
||||
|
||||
type model struct {
|
||||
tabs tabs.Model
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
items := []tabs.Item{
|
||||
{Title: "Lip Gloss", Model: newPane("Lip Gloss")},
|
||||
{Title: "Blush", Model: newPane("Blush")},
|
||||
{Title: "Eye Shadow", Model: newPane("Eye Shadow")},
|
||||
{Title: "Mascara", Model: newPane("Mascara")},
|
||||
{Title: "Foundation", Model: newPane("Foundation")},
|
||||
}
|
||||
|
||||
return model{tabs: tabs.New(items)}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return m.tabs.Init()
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "ctrl+c" || msg.String() == "q" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
// Size the tabs component to fill the terminal, net of docStyle's own
|
||||
// frame. The tab bar itself keeps its natural width; Content stretches.
|
||||
m.tabs.SetSize(
|
||||
msg.Width-docStyle.GetHorizontalFrameSize(),
|
||||
msg.Height-docStyle.GetVerticalFrameSize(),
|
||||
)
|
||||
|
||||
// tabs has no generic way to size an arbitrary Tab itself, so forward
|
||||
// the actual usable content area as a WindowSizeMsg: tabs.Update
|
||||
// already routes non-key messages to the active item's Update, so
|
||||
// this reaches pane.Update's own tea.WindowSizeMsg case above.
|
||||
var cmd tea.Cmd
|
||||
m.tabs, cmd = m.tabs.Update(tea.WindowSizeMsg{
|
||||
Width: m.tabs.ContentWidth(),
|
||||
Height: m.tabs.ContentHeight(),
|
||||
})
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.tabs, cmd = m.tabs.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
view := tea.NewView(docStyle.Render(m.tabs.View()))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "";
|
||||
description = "A minimal Go library that provides a shared Base16color theme for terminal UIs built with bubbletea and lipgloss.";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
module github.com/anotherhadi/ilovetui
|
||||
|
||||
go 1.25.0
|
||||
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 (
|
||||
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/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // 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
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
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
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
|
||||
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||
charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
|
||||
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
|
||||
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
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/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
|
||||
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=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
@@ -16,22 +42,45 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
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=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
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=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Helpbar
|
||||
|
||||
A responsive help bar: one line of key bindings that expands, on demand, into a multi-column view reflowed to use as many columns as the available width allows.
|
||||
|
||||
## Quick start
|
||||
|
||||
```go
|
||||
import "github.com/anotherhadi/ilovetui/helpbar"
|
||||
|
||||
type model struct {
|
||||
help helpbar.Model
|
||||
keys keyMap
|
||||
h int
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
keys := defaultKeyMap()
|
||||
return model{
|
||||
help: helpbar.New(
|
||||
helpbar.WithToggle(keys.Help), // '?' expands/collapses
|
||||
helpbar.WithGlobal(keys.Focus, keys.Quit), // always shown
|
||||
),
|
||||
keys: keys,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.h = msg.Height
|
||||
m.help.SetWidth(msg.Width)
|
||||
case tea.KeyPressMsg:
|
||||
m.help, _ = m.help.Update(msg) // flips ShowAll on the toggle binding
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
bar := m.help.View(m.focused().HelpBindings()...)
|
||||
body := renderBody(m.h - lipgloss.Height(bar))
|
||||
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, bar))
|
||||
}
|
||||
```
|
||||
|
||||
## Global vs. contextual bindings
|
||||
|
||||
Bindings come from two places, and they render in this order:
|
||||
|
||||
1. The `WithToggle` binding, so the way to expand the bar always leads.
|
||||
2. `WithGlobal` bindings - what your app reserves for itself (quit, switch pane...), set once.
|
||||
3. Contextual bindings, passed to `View` at render time.
|
||||
|
||||
Contextual bindings are meant to change from render to render, so the bar can track whatever component currently has focus. Nothing in the package knows what "focus" means for your app - you just hand it a different slice:
|
||||
|
||||
```go
|
||||
func (m model) focusedHelp() []key.Binding {
|
||||
if m.contentFocused {
|
||||
return m.content.HelpBindings()
|
||||
}
|
||||
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
|
||||
}
|
||||
```
|
||||
|
||||
Disabled bindings (`key.Binding.SetEnabled(false)`) are dropped before layout, so the reflow never
|
||||
budgets width for something that won't be drawn.
|
||||
|
||||
## Reserving room for the bar
|
||||
|
||||
The bar's height depends on its content and on whether it's expanded, so ask it rather than assuming
|
||||
a fixed number of rows:
|
||||
|
||||
```go
|
||||
body := m.height - m.help.Height(contextual...)
|
||||
```
|
||||
|
||||
`Height` is exactly `lipgloss.Height` of what `View` returns for the same arguments, so the two can
|
||||
never disagree about where the bar begins. An empty bar (no bindings, or no width set) renders `""`
|
||||
and takes zero rows.
|
||||
|
||||
## Styling
|
||||
|
||||
Defaults come from the shared `style` theme. Override with `WithStyles`:
|
||||
|
||||
```go
|
||||
helpbar.New(helpbar.WithStyles(myStyles)) // help.Styles from charm.land/bubbles/v2/help
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
- `examples/sidebar` uses it as an app-wide bar tracking the focused pane.
|
||||
- `examples/app` full app with a single help bar
|
||||
@@ -0,0 +1,185 @@
|
||||
// Package helpbar is a responsive help bar: a single line of key bindings
|
||||
// that expands, on demand, into a multi-column view reflowed to use as many
|
||||
// columns as the available width allows.
|
||||
//
|
||||
// It has no dependency on any particular layout or container - it's just a
|
||||
// component that takes a width and some bindings and returns a string, so it
|
||||
// works as well under a plain lipgloss.JoinVertical as anywhere else.
|
||||
//
|
||||
// Bindings come from two places. Global ones (quit, toggle help, whatever
|
||||
// your app reserves for itself) are set once via WithGlobal and always shown
|
||||
// first. Contextual ones are passed to View at render time, so the bar can
|
||||
// track whatever component currently has focus:
|
||||
//
|
||||
// bar := m.help.View(m.focused().HelpBindings()...)
|
||||
// body := m.height - lipgloss.Height(bar)
|
||||
package helpbar
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// Model is a help bar. The zero value isn't usable - build one with New.
|
||||
type Model struct {
|
||||
// ShowAll switches between the one-line short view and the full
|
||||
// multi-column view. Set it directly, or let WithToggle bind a key to
|
||||
// it and have Update flip it for you.
|
||||
ShowAll bool
|
||||
|
||||
help help.Model
|
||||
global []key.Binding
|
||||
toggle key.Binding
|
||||
width int
|
||||
}
|
||||
|
||||
// Option configures a Model at construction.
|
||||
type Option func(*Model)
|
||||
|
||||
// WithGlobal sets the bindings shown before the contextual ones on every
|
||||
// render - the keys your app reserves for itself regardless of what's
|
||||
// focused.
|
||||
func WithGlobal(bindings ...key.Binding) Option {
|
||||
return func(m *Model) { m.global = bindings }
|
||||
}
|
||||
|
||||
// WithToggle makes Update flip ShowAll when b matches, and lists b ahead of
|
||||
// every other binding (including WithGlobal's) so the way to expand the bar
|
||||
// is always the first thing shown. Without it, Update ignores key presses
|
||||
// and toggling ShowAll is entirely up to the caller.
|
||||
func WithToggle(b key.Binding) Option {
|
||||
return func(m *Model) { m.toggle = b }
|
||||
}
|
||||
|
||||
// WithStyles overrides the themed default styles.
|
||||
func WithStyles(s help.Styles) Option {
|
||||
return func(m *Model) { m.help.Styles = s }
|
||||
}
|
||||
|
||||
// New builds a help bar themed from the shared style package.
|
||||
func New(opts ...Option) Model {
|
||||
m := Model{help: bubbles.NewHelp()}
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// SetWidth sets the width the bar renders within. Nothing is shown until
|
||||
// this is called with a positive value - typically from your
|
||||
// tea.WindowSizeMsg handler.
|
||||
func (m *Model) SetWidth(w int) {
|
||||
m.width = w
|
||||
m.help.SetWidth(w)
|
||||
}
|
||||
|
||||
// Width returns the width last given to SetWidth.
|
||||
func (m Model) Width() int { return m.width }
|
||||
|
||||
// Update flips ShowAll when a key press matches WithToggle's binding. It's
|
||||
// optional: a Model built without WithToggle ignores every message, and you
|
||||
// can always set ShowAll yourself instead.
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
if keyMsg, ok := msg.(tea.KeyPressMsg); ok && key.Matches(keyMsg, m.toggle) {
|
||||
m.ShowAll = !m.ShowAll
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the bar: one line while ShowAll is false, otherwise the full
|
||||
// multi-column view. contextual bindings follow the toggle and global ones,
|
||||
// and are meant to change from render to render as focus moves.
|
||||
//
|
||||
// Returns "" when no width has been set or nothing is left to show, in which
|
||||
// case the bar occupies no rows at all.
|
||||
func (m Model) View(contextual ...key.Binding) string {
|
||||
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))
|
||||
}
|
||||
|
||||
// Height is the number of rows View would take for the same bindings. Use it
|
||||
// to work out how much room is left for the rest of your UI. View is the
|
||||
// source of truth: this is exactly lipgloss.Height of its output, so the two
|
||||
// can't disagree about where the bar begins.
|
||||
func (m Model) Height(contextual ...key.Binding) int {
|
||||
view := m.View(contextual...)
|
||||
if view == "" {
|
||||
return 0
|
||||
}
|
||||
return lipgloss.Height(view)
|
||||
}
|
||||
|
||||
// bindings is the full ordered list: the toggle first (so the way to expand
|
||||
// the bar always leads), then global, then contextual. Disabled bindings are
|
||||
// dropped here so column reflow never budgets width for something
|
||||
// help.FullHelpView will skip anyway.
|
||||
func (m Model) bindings(contextual []key.Binding) []key.Binding {
|
||||
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
|
||||
}
|
||||
|
||||
// columns reflows bindings into as many columns as fit within the bar's
|
||||
// width, which is the same as using as few rows as possible. It walks row
|
||||
// counts upward and returns the first arrangement that fits, so the result is
|
||||
// the widest (fewest-rows) layout the width allows.
|
||||
//
|
||||
// help.FullHelpView fills each column top to bottom, so a group is a column,
|
||||
// not a row.
|
||||
func (m Model) columns(bindings []key.Binding) [][]key.Binding {
|
||||
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
|
||||
}
|
||||
}
|
||||
// Everything in one column: the narrowest arrangement possible. It may
|
||||
// still overflow, in which case help.FullHelpView truncates as usual.
|
||||
return chunkColumns(bindings, len(bindings))
|
||||
}
|
||||
|
||||
// renderedWidth measures what FullHelpView would actually produce for
|
||||
// groups, by asking it - rather than reimplementing its column and separator
|
||||
// arithmetic here, which would silently drift the moment upstream changes a
|
||||
// style or a separator.
|
||||
//
|
||||
// The width is zeroed first because that's what disables FullHelpView's own
|
||||
// truncation (see its shouldAddItem): at width 0 it lays every column out in
|
||||
// full, which is the untruncated width this needs to measure.
|
||||
func (m Model) renderedWidth(groups [][]key.Binding) int {
|
||||
unbounded := m.help
|
||||
unbounded.SetWidth(0)
|
||||
return lipgloss.Width(unbounded.FullHelpView(groups))
|
||||
}
|
||||
|
||||
// chunkColumns slices bindings into consecutive groups of at most rows each.
|
||||
func chunkColumns(bindings []key.Binding, rows int) [][]key.Binding {
|
||||
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
@@ -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")
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# modal
|
||||
|
||||
A centered popup box on top of a dimmed background, triggered from anywhere in a bubbletea
|
||||
program via an exported `tea.Msg` (`ShowMsg`/`Show`) rather than a direct reference to the
|
||||
`Model` that ends up rendering it - standard Elm architecture, no IPC between processes.
|
||||
|
||||
It composites over an already-rendered string, so it makes no assumption about how the host
|
||||
builds that string: the same `Model` works whatever the host uses to lay out its main content.
|
||||
|
||||
## Quick start
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/anotherhadi/ilovetui/modal"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
m modal.Model
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
return model{m: modal.New()}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.m.Init() }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "d" {
|
||||
return m, modal.Show("Delete file?", modal.Text("This can't be undone.\n\ny: confirm esc: cancel"))
|
||||
}
|
||||
if msg.String() == "esc" && m.m.Open() {
|
||||
return m, modal.Close()
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.m, cmd = m.m.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
background := renderYourUI(m.width, m.height)
|
||||
view := tea.NewView(m.m.Render(background))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
```
|
||||
|
||||
Any component in the same bubbletea program can trigger a modal via `modal.Show`, without holding
|
||||
a reference to the `modal.Model` that will actually render it - that `Model` just needs to see
|
||||
every `tea.Msg` the program produces (i.e. get its `Update` called from the top-level `Update`),
|
||||
same as any other child model.
|
||||
|
||||
## Content is a model
|
||||
|
||||
A modal's body is a `tea.Model`, not a string. While a modal is on top of the stack it gets every
|
||||
message the `modal.Model` receives, its `Init` runs when it opens, and its commands come back out
|
||||
- so it can hold a form, a list, or a confirmation that reports its answer with a `tea.Msg` of
|
||||
its own, which the component that opened it listens for:
|
||||
|
||||
```go
|
||||
type confirmedMsg struct{ path string }
|
||||
|
||||
func (c confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "y" {
|
||||
path := c.path
|
||||
return c, func() tea.Msg { return confirmedMsg{path} }
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// somewhere else
|
||||
return m, modal.Show("Delete file?", confirm{path: p})
|
||||
```
|
||||
|
||||
Only the topmost modal is updated: everything beneath it is dimmed and frozen until the modals
|
||||
above it close.
|
||||
|
||||
For a modal with nothing to interact with, `modal.Text` wraps a plain string:
|
||||
|
||||
```go
|
||||
return m, modal.Show("About", modal.Text("v1.0\n\nesc to close"))
|
||||
```
|
||||
|
||||
The box shrinks to fit whatever the content draws, so a content that wants a specific size sets
|
||||
it on itself - the modal only ever sees the rendered result.
|
||||
|
||||
## Showing and closing
|
||||
|
||||
```go
|
||||
return m, modal.Show("Delete file?", confirm{})
|
||||
|
||||
return m, modal.Close() // close the topmost modal
|
||||
```
|
||||
|
||||
The stack is a plain LIFO, with no identity: a modal is closed by being on top, never by being
|
||||
named. There is nothing to tag a modal with, and nothing that can target one in the middle of the
|
||||
stack - the topmost is both the only one that receives messages and the only one `Close` can
|
||||
reach, so the two rules never disagree.
|
||||
|
||||
- `modal.Open()` reports whether at least one modal is currently shown - handy for a host that
|
||||
wants to route key presses to the modal instead of its normal UI while one is open. Note that a
|
||||
host doing this also makes it impossible for a key to open a second modal, which is what keeps
|
||||
the stack shallow without any bookkeeping.
|
||||
- A content model closes its own modal by returning `modal.Close()`, since it only ever runs while
|
||||
it is the topmost one.
|
||||
|
||||
## Stacking
|
||||
|
||||
Modals stack: showing a second one while the first is still open pushes it on top, dimming both
|
||||
the background and the first modal to the same flat color. Closing the top one reveals the one
|
||||
beneath, still in full color. This is what lets a "delete?" confirmation open a nested "really
|
||||
sure?" modal without any special-casing - the nested one is pushed by the content on top, the only
|
||||
one able to act.
|
||||
|
||||
## Styling
|
||||
|
||||
```go
|
||||
m := modal.New(modal.WithMaxWidth(60), modal.WithMaxHeight(20), modal.WithStyles(myStyles))
|
||||
|
||||
return m, modal.Show("Title", modal.Text("Message"), modal.WithModalStyle(oneOffStyles))
|
||||
```
|
||||
|
||||
`WithMaxWidth`/`WithMaxHeight` cap how large a modal box can grow before wrapping/truncating; a
|
||||
modal narrower than the cap shrinks to fit its content instead of padding out to it. A modal can
|
||||
also never overflow past the edge of whatever background it's rendered on, regardless of these
|
||||
caps. `WithStyles` sets the default look for every modal shown by this `Model`; `WithModalStyle`
|
||||
(a `Show` option) overrides it for one modal alone. `DefaultStyles()` builds from `style.S`: the
|
||||
box borrows `PanelFocused`'s border (the modal is what has focus while it's open), the dim color
|
||||
reuses `Subtle`.
|
||||
|
||||
## Examples
|
||||
|
||||
- `examples/modal` - open/dismiss, nested modals, styled from theme colors.
|
||||
@@ -0,0 +1,80 @@
|
||||
package modal
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
// Modal is one popup on the stack.
|
||||
type Modal struct {
|
||||
Title string
|
||||
// Content is the modal's body: a full model, updated and rendered by
|
||||
// the modal.Model while it's on top of the stack. Wrap a plain string
|
||||
// with Text for a modal with nothing to interact with.
|
||||
Content tea.Model
|
||||
// Style, if non-nil, overrides the Model's default Styles for this
|
||||
// modal alone.
|
||||
Style *Styles
|
||||
}
|
||||
|
||||
// ModalOption configures a Modal built by Show.
|
||||
type ModalOption func(*Modal)
|
||||
|
||||
// WithModalStyle overrides the Model's default Styles for this modal alone,
|
||||
// for a one-off custom look instead of the shared theme.
|
||||
func WithModalStyle(s Styles) ModalOption {
|
||||
return func(mo *Modal) { mo.Style = &s }
|
||||
}
|
||||
|
||||
func newModal(title string, content tea.Model, opts ...ModalOption) Modal {
|
||||
mo := Modal{Title: title, Content: content}
|
||||
for _, opt := range opts {
|
||||
opt(&mo)
|
||||
}
|
||||
return mo
|
||||
}
|
||||
|
||||
// ShowMsg tells a modal.Model to display Modal, pushing it on top of the
|
||||
// stack. Any component in the same bubbletea program can trigger one via
|
||||
// Show, without holding a reference to the modal.Model that will actually
|
||||
// render it - that Model just needs to see every tea.Msg the program
|
||||
// produces, same as any other child model.
|
||||
type ShowMsg struct{ Modal Modal }
|
||||
|
||||
// Show returns a tea.Cmd that opens a new modal on top of the stack. content
|
||||
// is a model, so a modal can hold anything a pane can - a form, a list, a
|
||||
// confirmation that reports back with its own tea.Msg:
|
||||
//
|
||||
// return m, modal.Show("Delete file?", modal.Text("This can't be undone."))
|
||||
// return m, modal.Show("Rename", newRenameForm(path))
|
||||
//
|
||||
// The content's Init runs when the modal opens, and it receives every message
|
||||
// while it's the topmost modal (see Model.Update).
|
||||
func Show(title string, content tea.Model, opts ...ModalOption) tea.Cmd {
|
||||
mo := newModal(title, content, opts...)
|
||||
return func() tea.Msg { return ShowMsg{Modal: mo} }
|
||||
}
|
||||
|
||||
// DismissMsg closes the topmost modal. The stack is a plain LIFO: a modal is
|
||||
// closed by being on top, never by being named.
|
||||
type DismissMsg struct{}
|
||||
|
||||
// Close returns a tea.Cmd that closes the topmost modal - which is also the
|
||||
// only one that can act (see Model.Update), so a content model closes itself
|
||||
// by returning it:
|
||||
//
|
||||
// return c, modal.Close()
|
||||
func Close() tea.Cmd {
|
||||
return func() tea.Msg { return DismissMsg{} }
|
||||
}
|
||||
|
||||
// text is a model wrapping a fixed string: a modal body with nothing to
|
||||
// update.
|
||||
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)) }
|
||||
|
||||
// Text wraps a plain string as modal content, for the common modal that has
|
||||
// nothing to interact with:
|
||||
//
|
||||
// modal.Show("About", modal.Text("v1.0\n\nesc to close"))
|
||||
func Text(s string) tea.Model { return text(s) }
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
// Package modal renders a centered popup box on top of a dimmed background,
|
||||
// triggered from anywhere in a bubbletea program via an exported tea.Msg
|
||||
// (see ShowMsg/Show) rather than a direct reference to the Model that ends
|
||||
// up rendering it.
|
||||
//
|
||||
// It composites over an already-rendered string (see Model.Render), so it
|
||||
// makes no assumption at all about how the host builds that string: the same
|
||||
// Model works whatever the host uses to lay out its main content (see
|
||||
// Model.Render and Model.View).
|
||||
//
|
||||
// A modal's content is a model, not a string: it is updated while it's on
|
||||
// top of the stack, so it can hold anything a pane can - a form, a list, a
|
||||
// confirmation reporting its answer back with its own tea.Msg. See Show and
|
||||
// Text.
|
||||
package modal
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
// Model holds the currently open modals (a stack: the most recently shown
|
||||
// is drawn on top, everything beneath it - the background and any earlier
|
||||
// modal - dimmed, see Render) and the rendering config (max size, styles)
|
||||
// they share. Build one with New.
|
||||
type Model struct {
|
||||
modals []Modal
|
||||
maxWidth int
|
||||
maxHeight int
|
||||
styles Styles
|
||||
}
|
||||
|
||||
// Option configures a Model at construction. See WithMaxWidth,
|
||||
// WithMaxHeight, WithStyles.
|
||||
type Option func(*Model)
|
||||
|
||||
// WithMaxWidth caps how wide a modal box can grow before its content wraps.
|
||||
// A modal narrower than this shrinks to fit its content instead of padding
|
||||
// out to the cap. 0 (also the zero-value Model's default without New) means
|
||||
// only the background's own size caps it.
|
||||
func WithMaxWidth(w int) Option {
|
||||
return func(m *Model) { m.maxWidth = w }
|
||||
}
|
||||
|
||||
// WithMaxHeight caps how tall a modal box can grow before its content is
|
||||
// truncated. 0 means only the background's own size caps it.
|
||||
func WithMaxHeight(h int) Option {
|
||||
return func(m *Model) { m.maxHeight = h }
|
||||
}
|
||||
|
||||
// WithStyles overrides the default styles (see DefaultStyles).
|
||||
func WithStyles(s Styles) Option {
|
||||
return func(m *Model) { m.styles = s }
|
||||
}
|
||||
|
||||
// New builds a Model. Defaults: a 60x20 max size, DefaultStyles.
|
||||
func New(opts ...Option) Model {
|
||||
m := Model{
|
||||
maxWidth: 60,
|
||||
maxHeight: 20,
|
||||
styles: DefaultStyles(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case ShowMsg:
|
||||
return m.show(msg.Modal)
|
||||
case DismissMsg:
|
||||
return m.pop(), nil
|
||||
}
|
||||
return m.updateTop(msg)
|
||||
}
|
||||
|
||||
// updateTop forwards msg to the topmost modal's content - the only one the
|
||||
// user can interact with, everything beneath it being dimmed (see Render). A
|
||||
// modal deeper in the stack is frozen until the ones above it close.
|
||||
//
|
||||
// This is what lets modal content be a real model: it gets the key presses,
|
||||
// the ticks and the results of its own commands, and can report back to the
|
||||
// rest of the program with a tea.Msg of its own.
|
||||
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
|
||||
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
|
||||
}
|
||||
|
||||
// Open reports whether at least one modal is currently shown - handy for a
|
||||
// host that wants to route key presses to the modal (e.g. esc to dismiss,
|
||||
// enter to confirm) instead of its normal UI while one is open.
|
||||
func (m Model) Open() bool { return len(m.modals) > 0 }
|
||||
|
||||
// show pushes a modal on top of the stack and returns its content's Init - a
|
||||
// modal's body starts the same way any other model does.
|
||||
func (m Model) show(mo Modal) (Model, tea.Cmd) {
|
||||
m.modals = append(m.modals, mo)
|
||||
return m, initContent(mo)
|
||||
}
|
||||
|
||||
// initContent is mo's content's Init, or nil for a modal without content.
|
||||
func initContent(mo Modal) tea.Cmd {
|
||||
if mo.Content == nil {
|
||||
return nil
|
||||
}
|
||||
return mo.Content.Init()
|
||||
}
|
||||
|
||||
// pop closes the topmost modal (see Close).
|
||||
func (m Model) pop() Model {
|
||||
if len(m.modals) == 0 {
|
||||
return m
|
||||
}
|
||||
m.modals = m.modals[:len(m.modals)-1]
|
||||
return m
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package modal
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// margin is the fixed gap, in cells, kept between a modal box and the edges
|
||||
// of the background it's centered on.
|
||||
const margin = 2
|
||||
|
||||
// Render draws every open modal (see Model.Update/Show) on top of background
|
||||
// (already rendered, e.g. layout.Model.View() or any other component's
|
||||
// View()) and returns the result. Each modal in the stack first flattens
|
||||
// whatever came before it - background plus any earlier modal - to a single
|
||||
// flat DimColor (see dim), then draws its own box centered on top, so
|
||||
// nesting a second modal on top of a first dims the first one too. background
|
||||
// is returned unchanged whenever there's nothing to draw.
|
||||
func (m Model) Render(background string) string {
|
||||
if len(m.modals) == 0 {
|
||||
return background
|
||||
}
|
||||
|
||||
result := background
|
||||
for _, mo := range m.modals {
|
||||
w, h := lipgloss.Width(result), lipgloss.Height(result)
|
||||
if w <= 0 || h <= 0 {
|
||||
return result
|
||||
}
|
||||
result = m.renderOne(mo, result, w, h)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// View is a convenience for a pane whose sole purpose is showing modals
|
||||
// (e.g. a dedicated layout.Leaf): it draws the stack over a blank
|
||||
// width x height area instead of an existing background.
|
||||
func (m Model) View(width, height int) string {
|
||||
return m.Render(blank(width, height))
|
||||
}
|
||||
|
||||
// renderOne dims background flat and draws mo's box centered on top of it.
|
||||
func (m Model) renderOne(mo Modal, background string, w, h int) string {
|
||||
s := m.styles
|
||||
if mo.Style != nil {
|
||||
s = *mo.Style
|
||||
}
|
||||
|
||||
box := m.renderBox(mo, s, w, h)
|
||||
bw, bh := lipgloss.Width(box), lipgloss.Height(box)
|
||||
x, y := max((w-bw)/2, 0), max((h-bh)/2, 0)
|
||||
|
||||
compositor := lipgloss.NewCompositor(
|
||||
lipgloss.NewLayer(dim(background, s.DimColor)),
|
||||
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
|
||||
)
|
||||
return compositor.Render()
|
||||
}
|
||||
|
||||
// dim flattens s to a single flat color: every existing style (colors,
|
||||
// bold, underline...) is stripped, then every character - including
|
||||
// whitespace, so highlighted/selected backgrounds vanish too - is
|
||||
// repainted in c. Applying a Foreground style to a multi-line string styles
|
||||
// each line independently (see lipgloss.Style.Render), so this keeps s's
|
||||
// line structure intact.
|
||||
func dim(s string, c color.Color) string {
|
||||
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
|
||||
}
|
||||
|
||||
// renderBox draws mo as a bordered, title-embedded box (style.RenderWithTitle),
|
||||
// shrunk to fit its content, capped by the Model's configured max size and by
|
||||
// whatever actually fits inside a bgW x bgH background.
|
||||
func (m Model) renderBox(mo Modal, s Styles, bgW, bgH int) string {
|
||||
maxW := effectiveMax(m.maxWidth, bgW-2*margin)
|
||||
maxH := effectiveMax(m.maxHeight, bgH-2*margin)
|
||||
|
||||
body := contentView(mo)
|
||||
inner := contentWidth(body, mo.Title, maxW)
|
||||
content := s.Content.Width(inner).Render(body)
|
||||
|
||||
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
|
||||
boxHeight := min(lipgloss.Height(content)+2, maxH)
|
||||
|
||||
return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight)
|
||||
}
|
||||
|
||||
// contentView is the modal body's rendered string, or "" for a modal without
|
||||
// content. The box shrinks to fit whatever the content model draws, so a
|
||||
// content that wants a specific size sets it on itself - the modal only ever
|
||||
// sees the result.
|
||||
func contentView(mo Modal) string {
|
||||
if mo.Content == nil {
|
||||
return ""
|
||||
}
|
||||
return mo.Content.View().Content
|
||||
}
|
||||
|
||||
// contentWidth is the modal's inner (border/padding excluded) width: its
|
||||
// natural size (long enough for the widest line of title/content), capped
|
||||
// at maxWidth.
|
||||
func contentWidth(body, title string, maxWidth int) int {
|
||||
natural := max(naturalWidth(body), lipgloss.Width(title), 1)
|
||||
capped := max(maxWidth-4, 1)
|
||||
return min(natural, capped)
|
||||
}
|
||||
|
||||
// naturalWidth is the width of content's widest line.
|
||||
func naturalWidth(content string) int {
|
||||
w := 0
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if lw := lipgloss.Width(line); lw > w {
|
||||
w = lw
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// effectiveMax resolves the cap actually used along one axis: configured
|
||||
// (0 = unlimited) narrowed down to fits, whatever actually fits the
|
||||
// background - a modal can never overflow past the edge of the background,
|
||||
// or the terminal, when the background is a full-screen View(), regardless
|
||||
// of how WithMaxWidth/WithMaxHeight was set.
|
||||
func effectiveMax(configured, fits int) int {
|
||||
if fits < 1 {
|
||||
fits = 1
|
||||
}
|
||||
if configured > 0 && configured < fits {
|
||||
return configured
|
||||
}
|
||||
return fits
|
||||
}
|
||||
|
||||
func blank(width, height int) string {
|
||||
if width <= 0 || height <= 0 {
|
||||
return ""
|
||||
}
|
||||
line := strings.Repeat(" ", width)
|
||||
lines := make([]string, height)
|
||||
for i := range lines {
|
||||
lines[i] = line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package modal
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// Styles is the set of lipgloss styles, plus the dim color, used to render a
|
||||
// modal and the background behind it. Border carries the box's border
|
||||
// (shape + color, no size), Title and Content color the two pieces of text
|
||||
// drawn inside it, DimColor is the single flat color every character of the
|
||||
// background gets overwritten with while the modal is open.
|
||||
type Styles struct {
|
||||
Border lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
Content lipgloss.Style
|
||||
DimColor color.Color
|
||||
}
|
||||
|
||||
// DefaultStyles builds a Styles from style.S: the box borrows
|
||||
// PanelFocused's border (the modal is what has focus while it's open).
|
||||
// DimColor reuses Subtle - the base16 "comments/invisibles" role, already
|
||||
// used across this repo for de-emphasized text (borders, placeholders,
|
||||
// separators, see bubbles/*.go) - darker than Muted, which reads too bright
|
||||
// once it's covering an entire screen instead of a single blurred field.
|
||||
func DefaultStyles() Styles {
|
||||
return Styles{
|
||||
Border: style.S.PanelFocused.Padding(0, 1),
|
||||
Title: lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary),
|
||||
Content: lipgloss.NewStyle().Foreground(style.S.Text),
|
||||
DimColor: style.S.Subtle,
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ in
|
||||
[
|
||||
go
|
||||
doctoc
|
||||
(python3.withPackages (ps: [ps.pyte]))
|
||||
]
|
||||
++ hooks.enabledPackages;
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a TUI binary in a virtual terminal (via pyte) and print its rendered
|
||||
screen as plain text, for visual verification of bubbletea/lipgloss output.
|
||||
|
||||
Dev tooling only, provided by the nix-shell. Not part of the Go module.
|
||||
|
||||
Usage:
|
||||
tui-snapshot.py [--width W] [--height H] [--wait SECONDS] [--keys 'k:delay,...'] -- <cmd> [args...]
|
||||
|
||||
Example:
|
||||
tui-snapshot.py --keys 'l:0.2,l:0.2,q:0' -- go run ./examples/tabs
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import struct
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
|
||||
import pyte
|
||||
|
||||
class RepeatAwareScreen(pyte.Screen):
|
||||
"""pyte has no support for the ECMA-48 REP sequence (``CSI Ps b``,
|
||||
"repeat the preceding graphic character Ps times"), which bubbletea's
|
||||
renderer uses to compress runs of identical styled cells (e.g. long
|
||||
border/padding runs). Left unhandled, pyte silently drops it, corrupting
|
||||
the frame. This adds the missing handler."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._last_drawn = " "
|
||||
|
||||
def draw(self, data):
|
||||
super().draw(data)
|
||||
if data:
|
||||
self._last_drawn = data[-1]
|
||||
|
||||
def repeat_last_character(self, count=1):
|
||||
self.draw(self._last_drawn * (count or 1))
|
||||
|
||||
|
||||
class RepeatAwareStream(pyte.Stream):
|
||||
"""Also works around a pyte bug: it maps ECMA-48 HPA (Character Position
|
||||
Absolute, "move cursor to column Ps") to "'" (apostrophe, 0x27) instead
|
||||
of the actual standard character "`" (backtick, 0x60) - see pyte's
|
||||
escape.py. Real terminal apps send the correct backtick, which pyte then
|
||||
silently no-ops on since it's not in its dispatch table, leaving the
|
||||
cursor stuck instead of moving it. bubbletea's renderer uses HPA (mixed
|
||||
with CUF) to jump the cursor to a border's column after erasing padding
|
||||
with ECH, so without this the trailing border character gets drawn right
|
||||
after the leading one instead of at the far edge."""
|
||||
|
||||
csi = {
|
||||
**pyte.Stream.csi,
|
||||
"b": "repeat_last_character",
|
||||
"`": "cursor_to_column",
|
||||
}
|
||||
|
||||
|
||||
KEYMAP = {
|
||||
"enter": "\r",
|
||||
"esc": "\x1b",
|
||||
"tab": "\t",
|
||||
"up": "\x1b[A",
|
||||
"down": "\x1b[B",
|
||||
"right": "\x1b[C",
|
||||
"left": "\x1b[D",
|
||||
"space": " ",
|
||||
}
|
||||
|
||||
|
||||
def encode_key(key):
|
||||
"""Resolve a --keys token to the bytes to write to the pty. Falls back to
|
||||
the literal string for a plain character (e.g. 'l'), but without this,
|
||||
something like 'ctrl+l' would be sent as the 6 literal characters
|
||||
'c','t','r','l','+','l' instead of the single 0x0C control byte a real
|
||||
terminal would send for Ctrl-L - silently testing the wrong thing."""
|
||||
if key in KEYMAP:
|
||||
return KEYMAP[key]
|
||||
if key.startswith("ctrl+") and len(key) == 6 and key[5].isalpha():
|
||||
return chr(ord(key[5].lower()) - ord("a") + 1)
|
||||
return key
|
||||
|
||||
|
||||
def set_size(fd, rows, cols):
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--width", type=int, default=80)
|
||||
parser.add_argument("--height", type=int, default=24)
|
||||
parser.add_argument(
|
||||
"--wait", type=float, default=1.0, help="seconds to let the app render before the snapshot"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keys",
|
||||
default="",
|
||||
help="comma-separated key:delay pairs sent before the final snapshot, e.g. 'l:0.2,l:0.2,q:0'",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--colors",
|
||||
action="store_true",
|
||||
help="also print, per row, the foreground color of each non-blank cell (for border/color bugs "
|
||||
"that don't show up in the plain-text dump)",
|
||||
)
|
||||
parser.add_argument("cmd", nargs=argparse.REMAINDER)
|
||||
args = parser.parse_args()
|
||||
|
||||
cmd = args.cmd
|
||||
if cmd and cmd[0] == "--":
|
||||
cmd = cmd[1:]
|
||||
if not cmd:
|
||||
parser.error("missing command to run")
|
||||
|
||||
screen = RepeatAwareScreen(args.width, args.height)
|
||||
stream = RepeatAwareStream(screen)
|
||||
|
||||
# Open the pty and set its size *before* forking, so the child never
|
||||
# observes a stale/default size on its first render (pyte has no way to
|
||||
# recover from a corrupted initial frame drawn at the wrong width).
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
set_size(slave_fd, args.height, args.width)
|
||||
|
||||
pid = os.fork()
|
||||
if pid == 0:
|
||||
os.close(master_fd)
|
||||
os.setsid()
|
||||
fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
|
||||
os.dup2(slave_fd, 0)
|
||||
os.dup2(slave_fd, 1)
|
||||
os.dup2(slave_fd, 2)
|
||||
os.close(slave_fd)
|
||||
os.execvp(cmd[0], cmd)
|
||||
os._exit(1)
|
||||
|
||||
os.close(slave_fd)
|
||||
fd = master_fd
|
||||
|
||||
def pump(duration):
|
||||
end = time.time() + duration
|
||||
while True:
|
||||
remaining = end - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
r, _, _ = select.select([fd], [], [], remaining)
|
||||
if fd not in r:
|
||||
continue
|
||||
try:
|
||||
data = os.read(fd, 65536)
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
stream.feed(data.decode(errors="ignore"))
|
||||
|
||||
pump(args.wait)
|
||||
|
||||
for pair in filter(None, args.keys.split(",")):
|
||||
key, _, delay = pair.partition(":")
|
||||
try:
|
||||
os.write(fd, encode_key(key).encode())
|
||||
except OSError:
|
||||
break
|
||||
pump(float(delay) if delay else 0.3)
|
||||
|
||||
try:
|
||||
os.kill(pid, 15)
|
||||
os.waitpid(pid, 0)
|
||||
except (ProcessLookupError, ChildProcessError):
|
||||
pass
|
||||
|
||||
for y, line in enumerate(screen.display):
|
||||
print(line.rstrip())
|
||||
if args.colors:
|
||||
row = screen.buffer[y]
|
||||
cells = []
|
||||
for x in sorted(row):
|
||||
ch = row[x]
|
||||
if ch.data.strip():
|
||||
fg = ch.fg if ch.fg != "default" else "-"
|
||||
cells.append(f"{x}:{ch.data!r}:{fg}")
|
||||
if cells:
|
||||
print(" " + " ".join(cells))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
# notification
|
||||
|
||||
Toast-style notifications, triggered from anywhere in a bubbletea program via an exported
|
||||
`tea.Msg` (`ShowMsg`/`Show`) rather than a direct reference to the `Model` that ends up rendering
|
||||
them - standard Elm architecture, no IPC between processes.
|
||||
|
||||
It composites over an already-rendered string, so it makes no assumption about how the host
|
||||
builds that string: the same `Model` works whatever the host uses to lay out its main content.
|
||||
|
||||
## Quick start
|
||||
|
||||
```go
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/ilovetui/notification"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
notif notification.Model
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
return model{notif: notification.New()}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.notif.Init() }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "s" {
|
||||
return m, notification.Show("Saved", "Config written to disk", notification.Success)
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.notif, cmd = m.notif.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
background := renderYourUI(m.width, m.height)
|
||||
view := tea.NewView(m.notif.Render(background))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
```
|
||||
|
||||
Any component in the same bubbletea program can trigger a toast via `notification.Show`, without
|
||||
holding a reference to the `notification.Model` that will actually render it - that `Model` just
|
||||
needs to see every `tea.Msg` the program produces (i.e. get its `Update` called from the top-level
|
||||
`Update`), same as any other child model.
|
||||
|
||||
## Showing and dismissing
|
||||
|
||||
```go
|
||||
return m, notification.Show("Saved", "Config written to disk", notification.Success)
|
||||
|
||||
return m, notification.Show("Sticky", "Stays until dismissed",
|
||||
notification.Info, notification.WithID("sticky-demo"), notification.WithDuration(0))
|
||||
return m, notification.Dismiss("sticky-demo")
|
||||
```
|
||||
|
||||
Four kinds: `Info`, `Success`, `Warning`, `Error`, each with its own color preset (see Styling
|
||||
below). By default a toast auto-dismisses after `notification.DefaultDuration` (3s);
|
||||
`WithDuration(0)` makes it sticky - it stays until `Dismiss(id)` removes it, so a sticky toast
|
||||
needs `WithID` to be dismissable later (an auto-generated id is never returned to the caller).
|
||||
Showing again with the same id replaces the toast in place, resetting its position and timer,
|
||||
instead of stacking a duplicate.
|
||||
|
||||
## Position and stacking
|
||||
|
||||
```go
|
||||
n := notification.New(notification.WithPosition(notification.TopRight))
|
||||
```
|
||||
|
||||
Six anchors: `Top`, `TopLeft`, `TopRight`, `Bottom`, `BottomLeft`, `BottomRight` - toasts always
|
||||
hug an edge or corner, never the middle of the screen. Multiple toasts stack along the anchored
|
||||
edge, newest closest to it; a stack that overflows the background's height clips the oldest
|
||||
toasts first, so the newest ones stay visible.
|
||||
|
||||
## Styling
|
||||
|
||||
```go
|
||||
n := notification.New(notification.WithMaxWidth(40), notification.WithStyles(myStyles))
|
||||
|
||||
return m, notification.Show("Title", "Message", notification.Success,
|
||||
notification.WithToastStyle(oneOffStyle))
|
||||
```
|
||||
|
||||
`WithMaxWidth` caps how wide a toast box can grow before its message wraps; a toast narrower than
|
||||
the cap shrinks to fit its content instead of padding out to it. A toast can also never overflow
|
||||
past the edge of whatever background it's rendered on, regardless of this cap. `WithStyles` sets
|
||||
the default per-`Kind` look for every toast shown by this `Model`; `WithToastStyle` (a `Show`
|
||||
option) overrides it for one toast alone. `DefaultStyles()` builds from `style.S`: `Info` uses
|
||||
`Primary` (no dedicated "info" color in the theme), `Success`/`Warning`/`Error` use their matching
|
||||
`style.S` alias.
|
||||
|
||||
## Examples
|
||||
|
||||
- `examples/notification` - all four kinds, a sticky toast with manual dismiss, cycling through
|
||||
all six positions.
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package notification renders toast-style notifications, triggered from
|
||||
// anywhere in a bubbletea program via an exported tea.Msg (see ShowMsg/Show)
|
||||
// rather than a direct reference to the Model that ends up rendering them.
|
||||
//
|
||||
// It composites over an already-rendered string (see Model.Render), so it
|
||||
// makes no assumption at all about how the host builds that string: the same
|
||||
// Model works whatever the host uses to lay out its main content (see
|
||||
// Model.Render and Model.View).
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// Model holds the currently visible toasts and the rendering config
|
||||
// (position, max width, per-Kind styles) they share. Build one with New.
|
||||
type Model struct {
|
||||
toasts []Toast
|
||||
nextID int
|
||||
position Position
|
||||
maxWidth int
|
||||
styles Styles
|
||||
}
|
||||
|
||||
// Option configures a Model at construction. See WithPosition, WithMaxWidth,
|
||||
// WithStyles.
|
||||
type Option func(*Model)
|
||||
|
||||
// WithPosition sets which edge/corner the toast stack anchors to. TopRight
|
||||
// by default.
|
||||
func WithPosition(p Position) Option {
|
||||
return func(m *Model) { m.position = p }
|
||||
}
|
||||
|
||||
// WithMaxWidth caps how wide a toast box can grow before its message
|
||||
// wraps. A toast narrower than this shrinks to fit its content instead of
|
||||
// padding out to the cap. 0 (also the zero-value Model's default without
|
||||
// New) means unlimited.
|
||||
func WithMaxWidth(w int) Option {
|
||||
return func(m *Model) { m.maxWidth = w }
|
||||
}
|
||||
|
||||
// WithStyles overrides the default per-Kind styles (see DefaultStyles).
|
||||
func WithStyles(s Styles) Option {
|
||||
return func(m *Model) { m.styles = s }
|
||||
}
|
||||
|
||||
// New builds a Model. Defaults: TopRight, a 40-cell max width, DefaultStyles.
|
||||
func New(opts ...Option) Model {
|
||||
m := Model{
|
||||
position: TopRight,
|
||||
maxWidth: 40,
|
||||
styles: DefaultStyles(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case ShowMsg:
|
||||
return m.show(msg.Toast)
|
||||
case DismissMsg:
|
||||
return m.remove(msg.ID), nil
|
||||
case expireMsg:
|
||||
return m.remove(msg.id), nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// show adds or replaces (see WithID) a toast, and schedules its expiry via
|
||||
// tea.Tick if it isn't sticky (Duration <= 0).
|
||||
func (m Model) show(t Toast) (Model, tea.Cmd) {
|
||||
if t.ID == "" {
|
||||
t.ID = fmt.Sprintf("toast-%d", m.nextID)
|
||||
m.nextID++
|
||||
}
|
||||
|
||||
replaced := false
|
||||
for i, existing := range m.toasts {
|
||||
if existing.ID == t.ID {
|
||||
m.toasts[i] = t
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
m.toasts = append(m.toasts, t)
|
||||
}
|
||||
|
||||
if t.Duration <= 0 {
|
||||
return m, nil
|
||||
}
|
||||
id := t.ID
|
||||
return m, tea.Tick(t.Duration, func(time.Time) tea.Msg {
|
||||
return expireMsg{id: id}
|
||||
})
|
||||
}
|
||||
|
||||
func (m Model) remove(id string) Model {
|
||||
for i, t := range m.toasts {
|
||||
if t.ID == id {
|
||||
m.toasts = append(m.toasts[:i], m.toasts[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package notification
|
||||
|
||||
// Position anchors the toast stack to one of six spots on the rendered
|
||||
// background. There's no center/middle variant: toasts always hug an edge or
|
||||
// a corner, never the middle of the screen.
|
||||
type Position int
|
||||
|
||||
const (
|
||||
Top Position = iota
|
||||
TopLeft
|
||||
TopRight
|
||||
Bottom
|
||||
BottomLeft
|
||||
BottomRight
|
||||
)
|
||||
|
||||
// margin is the fixed gap, in cells, kept between the toast stack and the
|
||||
// edge(s) of the background it's anchored to.
|
||||
const margin = 1
|
||||
|
||||
// placement resolves the top-left (x, y) coordinate to draw a stack of size
|
||||
// (sw, sh) at, given a background of size (w, h) and the anchor position.
|
||||
func placement(pos Position, w, h, sw, sh int) (x, y int) {
|
||||
switch pos {
|
||||
case Top:
|
||||
x = (w - sw) / 2
|
||||
y = margin
|
||||
case TopLeft:
|
||||
x = margin
|
||||
y = margin
|
||||
case TopRight:
|
||||
x = w - sw - margin
|
||||
y = margin
|
||||
case Bottom:
|
||||
x = (w - sw) / 2
|
||||
y = h - sh - margin
|
||||
case BottomLeft:
|
||||
x = margin
|
||||
y = h - sh - margin
|
||||
case BottomRight:
|
||||
x = w - sw - margin
|
||||
y = h - sh - margin
|
||||
}
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
}
|
||||
return x, y
|
||||
}
|
||||
|
||||
// anchoredTop reports whether pos hugs the top edge, which decides both the
|
||||
// stacking order (see Model.orderedToasts) and which side of an overflowing
|
||||
// stack gets clipped (see clipToHeight).
|
||||
func (pos Position) anchoredTop() bool {
|
||||
return pos == Top || pos == TopLeft || pos == TopRight
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// Render composites the current toasts on top of background (already
|
||||
// rendered, e.g. layout.Model.View() or any other component's View()) and
|
||||
// returns the result. background is returned unchanged whenever there's
|
||||
// nothing to draw (no toasts, or a background with no measurable size).
|
||||
//
|
||||
// This is what makes notification work identically with or without layout:
|
||||
// the host just wraps whatever it would otherwise return from its own
|
||||
// View() with this call.
|
||||
func (m Model) Render(background string) string {
|
||||
if len(m.toasts) == 0 {
|
||||
return background
|
||||
}
|
||||
w, h := lipgloss.Width(background), lipgloss.Height(background)
|
||||
if w <= 0 || h <= 0 {
|
||||
return background
|
||||
}
|
||||
|
||||
stack := clipToHeight(m.renderStack(effectiveMaxWidth(m.maxWidth, w)), h-2*margin, m.position.anchoredTop())
|
||||
if stack == "" {
|
||||
return background
|
||||
}
|
||||
sw, sh := lipgloss.Width(stack), lipgloss.Height(stack)
|
||||
x, y := placement(m.position, w, h, sw, sh)
|
||||
|
||||
// Canvas.Compose(layer) alone ignores the layer's X/Y and draws it across
|
||||
// the canvas's whole bounds, not just its own footprint - that's what
|
||||
// made the toast layer blank out the entire background instead of
|
||||
// floating over it. Compositor is what actually resolves each layer's
|
||||
// absolute bounds (background at 0,0, the stack at x,y) before drawing
|
||||
// each one only within its own area.
|
||||
compositor := lipgloss.NewCompositor(
|
||||
lipgloss.NewLayer(background),
|
||||
lipgloss.NewLayer(stack).X(x).Y(y).Z(1),
|
||||
)
|
||||
return compositor.Render()
|
||||
}
|
||||
|
||||
// View is a convenience for a pane whose sole purpose is showing toasts (e.g.
|
||||
// a dedicated layout.Leaf): it draws the stack over a blank width x height
|
||||
// area instead of an existing background.
|
||||
func (m Model) View(width, height int) string {
|
||||
return m.Render(blank(width, height))
|
||||
}
|
||||
|
||||
// renderStack stacks every visible toast into one block, newest closest to
|
||||
// the anchored edge (see Position.anchoredTop), separated by a blank line,
|
||||
// and aligned so the edge the stack anchors to stays flush across toasts of
|
||||
// different widths.
|
||||
func (m Model) renderStack(maxWidth int) string {
|
||||
ordered := m.orderedToasts()
|
||||
parts := make([]string, 0, len(ordered)*2-1)
|
||||
for i, t := range ordered {
|
||||
if i > 0 {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
parts = append(parts, m.renderToast(t, maxWidth))
|
||||
}
|
||||
return lipgloss.JoinVertical(stackAlign(m.position), parts...)
|
||||
}
|
||||
|
||||
// orderedToasts returns the toasts in the order they should stack, newest
|
||||
// nearest the anchored edge: reversed (newest first) for a top anchor,
|
||||
// insertion order (oldest first, newest last) for a bottom anchor.
|
||||
func (m Model) orderedToasts() []Toast {
|
||||
if !m.position.anchoredTop() {
|
||||
return m.toasts
|
||||
}
|
||||
ordered := make([]Toast, len(m.toasts))
|
||||
for i, t := range m.toasts {
|
||||
ordered[len(m.toasts)-1-i] = t
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func stackAlign(pos Position) lipgloss.Position {
|
||||
switch pos {
|
||||
case TopLeft, BottomLeft:
|
||||
return lipgloss.Left
|
||||
case TopRight, BottomRight:
|
||||
return lipgloss.Right
|
||||
default:
|
||||
return lipgloss.Center
|
||||
}
|
||||
}
|
||||
|
||||
// renderToast draws a single toast as a box with its title embedded in the
|
||||
// top border (style.RenderWithTitle), shrunk to fit its content up to
|
||||
// maxWidth.
|
||||
func (m Model) renderToast(t Toast, maxWidth int) string {
|
||||
k := m.styles.forKind(t)
|
||||
|
||||
inner := contentWidth(t, maxWidth)
|
||||
message := k.Message.Width(inner).Render(t.Message)
|
||||
|
||||
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
|
||||
boxHeight := lipgloss.Height(message) + 2
|
||||
|
||||
return style.RenderWithTitle(k.Border, k.Title.Render(t.Title), message, boxWidth, boxHeight)
|
||||
}
|
||||
|
||||
// contentWidth is the toast's inner (border/padding excluded) width: its
|
||||
// natural size (long enough for the wider of title/message on one line),
|
||||
// capped at maxWidth if positive.
|
||||
func contentWidth(t Toast, maxWidth int) int {
|
||||
natural := max(lipgloss.Width(t.Title), lipgloss.Width(t.Message), 1)
|
||||
if maxWidth <= 0 {
|
||||
return natural
|
||||
}
|
||||
capped := max(maxWidth-4, 1)
|
||||
return min(natural, capped)
|
||||
}
|
||||
|
||||
// effectiveMaxWidth resolves the cap actually used to render a toast:
|
||||
// configured (Model.maxWidth, 0 = unlimited) narrowed down to whatever
|
||||
// actually fits the background it's about to be drawn on, so a toast can
|
||||
// never overflow past the edge of the background - or the terminal, when
|
||||
// the background is a full-screen View() - regardless of how WithMaxWidth
|
||||
// was set. bgWidth is background's own width, already measured by Render.
|
||||
func effectiveMaxWidth(configured, bgWidth int) int {
|
||||
fits := max(bgWidth-2*margin, 1)
|
||||
if configured > 0 && configured < fits {
|
||||
return configured
|
||||
}
|
||||
return fits
|
||||
}
|
||||
|
||||
// clipToHeight trims stack to at most maxHeight lines when it overflows,
|
||||
// keeping the lines nearest the anchored edge (top rows for a top anchor,
|
||||
// bottom rows for a bottom anchor) so the newest toasts - always nearest
|
||||
// that edge, see orderedToasts - are the ones that stay visible.
|
||||
func clipToHeight(stack string, maxHeight int, anchoredTop bool) string {
|
||||
lines := strings.Split(stack, "\n")
|
||||
if len(lines) <= maxHeight {
|
||||
return stack
|
||||
}
|
||||
if maxHeight <= 0 {
|
||||
return ""
|
||||
}
|
||||
if anchoredTop {
|
||||
lines = lines[:maxHeight]
|
||||
} else {
|
||||
lines = lines[len(lines)-maxHeight:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func blank(width, height int) string {
|
||||
if width <= 0 || height <= 0 {
|
||||
return ""
|
||||
}
|
||||
line := strings.Repeat(" ", width)
|
||||
lines := make([]string, height)
|
||||
for i := range lines {
|
||||
lines[i] = line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// KindStyle is the set of lipgloss styles used to render one toast: Border
|
||||
// carries the box's border (shape + color, no size), Title and Message color
|
||||
// the two pieces of text drawn inside it. Building a value directly (rather
|
||||
// than through a constructor) is the intended way to hand WithToastStyle a
|
||||
// custom, per-toast look.
|
||||
type KindStyle struct {
|
||||
Border lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
Message lipgloss.Style
|
||||
}
|
||||
|
||||
// Styles maps each Kind to the KindStyle used to render it. Build one with
|
||||
// DefaultStyles and tweak individual fields, or construct one from scratch
|
||||
// for a fully custom palette across all kinds.
|
||||
type Styles struct {
|
||||
Info KindStyle
|
||||
Success KindStyle
|
||||
Warning KindStyle
|
||||
Error KindStyle
|
||||
}
|
||||
|
||||
// DefaultStyles builds a Styles from style.S: Info uses the theme's primary
|
||||
// accent (style.S has no dedicated "info" color, Primary already fills that
|
||||
// neutral-accent role elsewhere in this repo), Success/Warning/Error use
|
||||
// their matching style.S alias.
|
||||
func DefaultStyles() Styles {
|
||||
return Styles{
|
||||
Info: kindStyle(style.S.Primary),
|
||||
Success: kindStyle(style.S.Success),
|
||||
Warning: kindStyle(style.S.Warning),
|
||||
Error: kindStyle(style.S.Error),
|
||||
}
|
||||
}
|
||||
|
||||
func kindStyle(c color.Color) KindStyle {
|
||||
return KindStyle{
|
||||
Border: lipgloss.NewStyle().
|
||||
Border(style.S.BorderType).
|
||||
BorderForeground(c).
|
||||
Padding(0, 1),
|
||||
Title: lipgloss.NewStyle().Bold(true).Foreground(c),
|
||||
Message: lipgloss.NewStyle().Foreground(style.S.Text),
|
||||
}
|
||||
}
|
||||
|
||||
// forKind resolves the KindStyle to render t with: its own Style override if
|
||||
// set, otherwise s's preset for t.Kind (falling back to Info for an
|
||||
// out-of-range Kind).
|
||||
func (s Styles) forKind(t Toast) KindStyle {
|
||||
if t.Style != nil {
|
||||
return *t.Style
|
||||
}
|
||||
switch t.Kind {
|
||||
case Success:
|
||||
return s.Success
|
||||
case Warning:
|
||||
return s.Warning
|
||||
case Error:
|
||||
return s.Error
|
||||
default:
|
||||
return s.Info
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// Kind picks which of Styles' presets a toast renders with, unless
|
||||
// overridden per-toast via WithToastStyle.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
Info Kind = iota
|
||||
Success
|
||||
Warning
|
||||
Error
|
||||
)
|
||||
|
||||
// DefaultDuration is how long a toast stays visible when WithDuration isn't
|
||||
// used. Show has no reference to a Model (see ShowMsg's doc comment), so this
|
||||
// lives as a package constant rather than a Model-level default.
|
||||
const DefaultDuration = 3 * time.Second
|
||||
|
||||
// Toast is one notification. Build it via Show's opts rather than a literal:
|
||||
// ID and Duration both get defaults (see WithID, DefaultDuration) that a bare
|
||||
// literal would silently skip.
|
||||
type Toast struct {
|
||||
ID string
|
||||
Title string
|
||||
Message string
|
||||
Kind Kind
|
||||
// Duration is how long the toast stays up before auto-dismissing. 0
|
||||
// means sticky: it stays until DismissMsg/Dismiss(ID) removes it.
|
||||
Duration time.Duration
|
||||
// Style, if non-nil, overrides the Model's Kind-based preset for this
|
||||
// toast alone.
|
||||
Style *KindStyle
|
||||
}
|
||||
|
||||
// ToastOption configures a Toast built by Show.
|
||||
type ToastOption func(*Toast)
|
||||
|
||||
// WithID gives the toast a stable id, so a later Show reusing the same id
|
||||
// replaces it in place (resetting its position and timer) instead of
|
||||
// stacking a duplicate, and so it can be targeted by Dismiss.
|
||||
func WithID(id string) ToastOption {
|
||||
return func(t *Toast) { t.ID = id }
|
||||
}
|
||||
|
||||
// WithDuration overrides DefaultDuration. 0 makes the toast sticky: it never
|
||||
// auto-dismisses, only Dismiss(ID) removes it.
|
||||
func WithDuration(d time.Duration) ToastOption {
|
||||
return func(t *Toast) { t.Duration = d }
|
||||
}
|
||||
|
||||
// WithToastStyle overrides the Model's Kind-based preset for this toast
|
||||
// alone, for a one-off custom look instead of the type-based theme.
|
||||
func WithToastStyle(s KindStyle) ToastOption {
|
||||
return func(t *Toast) { t.Style = &s }
|
||||
}
|
||||
|
||||
func newToast(title, message string, kind Kind, opts ...ToastOption) Toast {
|
||||
t := Toast{
|
||||
Title: title,
|
||||
Message: message,
|
||||
Kind: kind,
|
||||
Duration: DefaultDuration,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&t)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// ShowMsg tells a notification.Model to display Toast. Any component in the
|
||||
// same bubbletea program can trigger one via Show, without holding a
|
||||
// reference to the notification.Model that will actually render it - that
|
||||
// Model just needs to see every tea.Msg the program produces, same as any
|
||||
// other child model.
|
||||
type ShowMsg struct{ Toast Toast }
|
||||
|
||||
// Show returns a tea.Cmd that shows a new toast of the given kind. Call it
|
||||
// from any component's Update:
|
||||
//
|
||||
// return m, notification.Show("Saved", "Config written to disk", notification.Success)
|
||||
func Show(title, message string, kind Kind, opts ...ToastOption) tea.Cmd {
|
||||
t := newToast(title, message, kind, opts...)
|
||||
return func() tea.Msg { return ShowMsg{Toast: t} }
|
||||
}
|
||||
|
||||
// DismissMsg removes the toast identified by ID, whether it's sticky or
|
||||
// mid-countdown. A no-op if ID isn't currently shown (already expired, or
|
||||
// never had an explicit id in the first place - see WithID).
|
||||
type DismissMsg struct{ ID string }
|
||||
|
||||
// Dismiss returns a tea.Cmd that removes the toast identified by id. Only
|
||||
// useful for toasts shown with WithID, since an auto-generated id is never
|
||||
// exposed back to the caller.
|
||||
func Dismiss(id string) tea.Cmd {
|
||||
return func() tea.Msg { return DismissMsg{ID: id} }
|
||||
}
|
||||
|
||||
// expireMsg fires once a toast's Duration has elapsed, scheduled by
|
||||
// Model.show via tea.Tick. Unexported: nothing outside the package should
|
||||
// construct or match on it directly, that's what DismissMsg is for.
|
||||
type expireMsg struct{ id string }
|
||||
@@ -0,0 +1,72 @@
|
||||
package style
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// borderTypes maps the `border:` config value to a lipgloss.Border. Only the
|
||||
// symmetric, general-purpose border kinds are exposed here; MarkdownBorder,
|
||||
// BlockBorder and the half-block variants are content-specific rather than a
|
||||
// theming choice.
|
||||
var borderTypes = map[string]lipgloss.Border{
|
||||
"rounded": lipgloss.RoundedBorder(),
|
||||
"normal": lipgloss.NormalBorder(),
|
||||
"thick": lipgloss.ThickBorder(),
|
||||
"double": lipgloss.DoubleBorder(),
|
||||
"hidden": lipgloss.HiddenBorder(),
|
||||
"ascii": lipgloss.ASCIIBorder(),
|
||||
}
|
||||
|
||||
// resolveBorderType maps a `border:` config value to a lipgloss.Border,
|
||||
// falling back to RoundedBorder for an empty or unrecognized name.
|
||||
func resolveBorderType(name string) lipgloss.Border {
|
||||
if b, ok := borderTypes[strings.ToLower(strings.TrimSpace(name))]; ok {
|
||||
return b
|
||||
}
|
||||
return lipgloss.RoundedBorder()
|
||||
}
|
||||
|
||||
// ContentHeight returns the usable inner height for a bordered panel of totalH rows.
|
||||
func ContentHeight(totalH int) int {
|
||||
h := totalH - 2
|
||||
if h < 0 {
|
||||
return 0
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// RenderWithTitle renders a bordered box with a title embedded in the top border.
|
||||
// title may contain ANSI color codes. width and height are the total outer dimensions.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// box := style.RenderWithTitle(style.S.PanelFocused, "Header", content, w, h)
|
||||
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
|
||||
boxH := height - 1
|
||||
if contentH := boxH - 1; contentH > 0 {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) > contentH {
|
||||
content = strings.Join(lines[:contentH], "\n")
|
||||
}
|
||||
}
|
||||
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
|
||||
|
||||
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
|
||||
titleW := lipgloss.Width(title)
|
||||
|
||||
// Pull the corner/fill glyphs from the style's own border spec instead of
|
||||
// hardcoding rounded-border characters, so this respects style.S.BorderType.
|
||||
b, _, _, _, _ := border.GetBorder()
|
||||
topLeft, top, topRight := b.TopLeft, b.Top, b.TopRight
|
||||
|
||||
fillW := boxWidth - titleW - lipgloss.Width(topLeft) - lipgloss.Width(topRight) - 2 // 2 = the spaces around the title
|
||||
if fillW < 0 {
|
||||
fillW = 0
|
||||
}
|
||||
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
|
||||
topLine := bc.Render(topLeft+" ") + bc.Render(title) + bc.Render(" "+strings.Repeat(top, fillW)+topRight)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package style
|
||||
|
||||
type colorsYAML struct {
|
||||
Base00 string `yaml:"base00"`
|
||||
Base01 string `yaml:"base01"`
|
||||
Base02 string `yaml:"base02"`
|
||||
Base03 string `yaml:"base03"`
|
||||
Base04 string `yaml:"base04"`
|
||||
Base05 string `yaml:"base05"`
|
||||
Base06 string `yaml:"base06"`
|
||||
Base07 string `yaml:"base07"`
|
||||
Base08 string `yaml:"base08"`
|
||||
Base09 string `yaml:"base09"`
|
||||
Base0A string `yaml:"base0a"`
|
||||
Base0B string `yaml:"base0b"`
|
||||
Base0C string `yaml:"base0c"`
|
||||
Base0D string `yaml:"base0d"`
|
||||
Base0E string `yaml:"base0e"`
|
||||
Base0F string `yaml:"base0f"`
|
||||
}
|
||||
|
||||
type configYAML struct {
|
||||
Colors colorsYAML `yaml:"colors"`
|
||||
NerdFonts bool `yaml:"nerd_fonts"`
|
||||
Border string `yaml:"border"`
|
||||
}
|
||||
|
||||
func pickString(base, user string) string {
|
||||
if user != "" {
|
||||
return user
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func mergeConfig(base, user configYAML) configYAML {
|
||||
return configYAML{
|
||||
Colors: mergeColors(base.Colors, user.Colors),
|
||||
// The embedded default is always nerd_fonts: false, so this just
|
||||
// reduces to "whatever the user set".
|
||||
NerdFonts: base.NerdFonts || user.NerdFonts,
|
||||
Border: pickString(base.Border, user.Border),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeColors(base, user colorsYAML) colorsYAML {
|
||||
return colorsYAML{
|
||||
Base00: pickString(base.Base00, user.Base00),
|
||||
Base01: pickString(base.Base01, user.Base01),
|
||||
Base02: pickString(base.Base02, user.Base02),
|
||||
Base03: pickString(base.Base03, user.Base03),
|
||||
Base04: pickString(base.Base04, user.Base04),
|
||||
Base05: pickString(base.Base05, user.Base05),
|
||||
Base06: pickString(base.Base06, user.Base06),
|
||||
Base07: pickString(base.Base07, user.Base07),
|
||||
Base08: pickString(base.Base08, user.Base08),
|
||||
Base09: pickString(base.Base09, user.Base09),
|
||||
Base0A: pickString(base.Base0A, user.Base0A),
|
||||
Base0B: pickString(base.Base0B, user.Base0B),
|
||||
Base0C: pickString(base.Base0C, user.Base0C),
|
||||
Base0D: pickString(base.Base0D, user.Base0D),
|
||||
Base0E: pickString(base.Base0E, user.Base0E),
|
||||
Base0F: pickString(base.Base0F, user.Base0F),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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
|
||||
base02: "#1D1A26" # Selection Background
|
||||
base03: "#514D63" # Comments / Invisibles
|
||||
base04: "#8E8AA0" # Dark Foreground / Status Bars
|
||||
base05: "#C2BED6" # Default Foreground
|
||||
base06: "#D8D5EA" # Light Foreground
|
||||
base07: "#EAE7F7" # Light Background
|
||||
base08: "#E07080" # Variables / Errors / Diff Deleted
|
||||
base09: "#D49070" # Integers / Constants / Booleans
|
||||
base0a: "#C4B060" # Classes / Warnings / Search Background
|
||||
base0b: "#80B880" # Strings / Success / Diff Inserted
|
||||
base0c: "#70B8C0" # Support / Regex / Escape Characters
|
||||
base0d: "#9E97F8" # Functions / Methods / Headings / Accent
|
||||
base0e: "#C090E8" # Keywords / Storage / Diff Changed
|
||||
base0f: "#D080A0" # Embedded / Misc
|
||||
@@ -0,0 +1,242 @@
|
||||
package style
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
|
||||
"charm.land/glamour/v2/ansi"
|
||||
)
|
||||
|
||||
func hexColor(c color.Color) *string {
|
||||
r, g, b, _ := c.RGBA()
|
||||
s := fmt.Sprintf("#%02X%02X%02X", r>>8, g>>8, b>>8)
|
||||
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 }
|
||||
uintPtr := func(u uint) *uint { return &u }
|
||||
hex := hexColor
|
||||
|
||||
return ansi.StyleConfig{
|
||||
Document: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
BlockPrefix: "\n",
|
||||
BlockSuffix: "\n",
|
||||
Color: hex(S.Base05),
|
||||
},
|
||||
Margin: uintPtr(2),
|
||||
},
|
||||
BlockQuote: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: hex(S.Base03),
|
||||
Italic: boolPtr(true),
|
||||
},
|
||||
Indent: uintPtr(1),
|
||||
IndentToken: str("│ "),
|
||||
},
|
||||
List: ansi.StyleList{
|
||||
LevelIndent: 2,
|
||||
},
|
||||
Heading: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
BlockSuffix: "\n",
|
||||
Color: hex(S.Base0D),
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
},
|
||||
H1: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: " ",
|
||||
Suffix: " ",
|
||||
Color: hex(S.Base07),
|
||||
BackgroundColor: hex(S.Base0D),
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
},
|
||||
H2: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "## ",
|
||||
Color: hex(S.Base0D),
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
},
|
||||
H3: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "### ",
|
||||
Color: hex(S.Base0C),
|
||||
},
|
||||
},
|
||||
H4: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "#### ",
|
||||
Color: hex(S.Base0B),
|
||||
},
|
||||
},
|
||||
H5: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "##### ",
|
||||
Color: hex(S.Base09),
|
||||
},
|
||||
},
|
||||
H6: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "###### ",
|
||||
Color: hex(S.Base08),
|
||||
Bold: boolPtr(false),
|
||||
},
|
||||
},
|
||||
Strikethrough: ansi.StylePrimitive{
|
||||
CrossedOut: boolPtr(true),
|
||||
},
|
||||
Emph: ansi.StylePrimitive{
|
||||
Italic: boolPtr(true),
|
||||
},
|
||||
Strong: ansi.StylePrimitive{
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
HorizontalRule: ansi.StylePrimitive{
|
||||
Color: hex(S.Base03),
|
||||
Format: "\n--------\n",
|
||||
},
|
||||
Item: ansi.StylePrimitive{
|
||||
BlockPrefix: "• ",
|
||||
},
|
||||
Enumeration: ansi.StylePrimitive{
|
||||
BlockPrefix: ". ",
|
||||
},
|
||||
Task: ansi.StyleTask{
|
||||
Ticked: "[✓] ",
|
||||
Unticked: "[ ] ",
|
||||
},
|
||||
Link: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0C),
|
||||
Underline: boolPtr(true),
|
||||
},
|
||||
LinkText: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0D),
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
Image: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0C),
|
||||
Underline: boolPtr(true),
|
||||
},
|
||||
ImageText: ansi.StylePrimitive{
|
||||
Color: hex(S.Base04),
|
||||
Format: "Image: {{.text}} ->",
|
||||
},
|
||||
Code: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: " ",
|
||||
Suffix: " ",
|
||||
Color: hex(S.Base0B),
|
||||
BackgroundColor: hex(S.Base01),
|
||||
},
|
||||
},
|
||||
CodeBlock: ansi.StyleCodeBlock{
|
||||
StyleBlock: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: hex(S.Base04),
|
||||
},
|
||||
Margin: uintPtr(2),
|
||||
},
|
||||
Chroma: &ansi.Chroma{
|
||||
Text: ansi.StylePrimitive{
|
||||
Color: hex(S.Base05),
|
||||
},
|
||||
Error: ansi.StylePrimitive{
|
||||
Color: hex(S.Base07),
|
||||
BackgroundColor: hex(S.Base08),
|
||||
},
|
||||
Comment: ansi.StylePrimitive{
|
||||
Color: hex(S.Base03),
|
||||
Italic: boolPtr(true),
|
||||
},
|
||||
CommentPreproc: ansi.StylePrimitive{
|
||||
Color: hex(S.Base09),
|
||||
},
|
||||
Keyword: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0E),
|
||||
},
|
||||
KeywordReserved: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0E),
|
||||
},
|
||||
KeywordNamespace: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0D),
|
||||
},
|
||||
KeywordType: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0A),
|
||||
},
|
||||
Operator: ansi.StylePrimitive{
|
||||
Color: hex(S.Base05),
|
||||
},
|
||||
Punctuation: ansi.StylePrimitive{
|
||||
Color: hex(S.Base05),
|
||||
},
|
||||
Name: ansi.StylePrimitive{
|
||||
Color: hex(S.Base05),
|
||||
},
|
||||
NameBuiltin: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0D),
|
||||
},
|
||||
NameTag: ansi.StylePrimitive{
|
||||
Color: hex(S.Base08),
|
||||
},
|
||||
NameAttribute: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0A),
|
||||
},
|
||||
NameClass: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0A),
|
||||
Bold: boolPtr(true),
|
||||
Underline: boolPtr(true),
|
||||
},
|
||||
NameConstant: ansi.StylePrimitive{
|
||||
Color: hex(S.Base09),
|
||||
},
|
||||
NameDecorator: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0C),
|
||||
},
|
||||
NameFunction: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0D),
|
||||
},
|
||||
LiteralNumber: ansi.StylePrimitive{
|
||||
Color: hex(S.Base09),
|
||||
},
|
||||
LiteralString: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0B),
|
||||
},
|
||||
LiteralStringEscape: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0C),
|
||||
},
|
||||
GenericDeleted: ansi.StylePrimitive{
|
||||
Color: hex(S.Base08),
|
||||
},
|
||||
GenericEmph: ansi.StylePrimitive{
|
||||
Italic: boolPtr(true),
|
||||
},
|
||||
GenericInserted: ansi.StylePrimitive{
|
||||
Color: hex(S.Base0B),
|
||||
},
|
||||
GenericStrong: ansi.StylePrimitive{
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
GenericSubheading: ansi.StylePrimitive{
|
||||
Color: hex(S.Base04),
|
||||
},
|
||||
Background: ansi.StylePrimitive{
|
||||
BackgroundColor: hex(S.Base01),
|
||||
},
|
||||
},
|
||||
},
|
||||
Table: ansi.StyleTable{
|
||||
StyleBlock: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{},
|
||||
},
|
||||
},
|
||||
DefinitionDescription: ansi.StylePrimitive{
|
||||
BlockPrefix: "\n> ",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package ilovetui
|
||||
package style
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
@@ -44,12 +44,20 @@ type Styles struct {
|
||||
Bold lipgloss.Style
|
||||
Faint lipgloss.Style
|
||||
|
||||
// Pre-built panel styles (rounded border)
|
||||
// User preferences, read from config
|
||||
NerdFonts bool
|
||||
BorderType lipgloss.Border
|
||||
|
||||
// Pre-built panel styles, bordered with BorderType
|
||||
Panel lipgloss.Style
|
||||
PanelFocused lipgloss.Style
|
||||
|
||||
// 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] != '#' {
|
||||
@@ -75,6 +83,8 @@ func newStyles(c colorsYAML) Styles {
|
||||
b0E := lc(c.Base0E)
|
||||
b0F := lc(c.Base0F)
|
||||
|
||||
borderType := resolveBorderType(borderName)
|
||||
|
||||
return Styles{
|
||||
Base00: b00, Base01: b01, Base02: b02, Base03: b03,
|
||||
Base04: b04, Base05: b05, Base06: b06, Base07: b07,
|
||||
@@ -95,12 +105,18 @@ 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(),
|
||||
PagerDotInactive: lipgloss.NewStyle().Foreground(b03).SetString("•").String(),
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// Package style provides a shared Base16 color theme for bubbletea/lipgloss
|
||||
// applications. The theme is loaded automatically on import from
|
||||
// ~/.config/ilovetui/config.yaml (falling back to the embedded
|
||||
// default config). Access colors and styles via the package-level variable S.
|
||||
//
|
||||
// import "github.com/anotherhadi/ilovetui/style"
|
||||
//
|
||||
// s := lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
// box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h)
|
||||
package style
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed default.yaml
|
||||
var DefaultConfig []byte
|
||||
|
||||
// S is the active theme. It is populated automatically at import time and can
|
||||
// be reloaded at any point by calling Init, InitFrom, or InitFromBytes.
|
||||
var S Styles
|
||||
|
||||
func init() {
|
||||
path := DefaultConfigPath()
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if s, err := stylesFromBytes(data); err == nil {
|
||||
S = s
|
||||
return
|
||||
}
|
||||
}
|
||||
// Silent fallback: embedded default always works.
|
||||
s, _ := stylesFromBytes(DefaultConfig)
|
||||
S = s
|
||||
}
|
||||
|
||||
// Init reloads S from the user config file, falling back to the embedded
|
||||
// default if the file is missing. Returns an error only on parse failures.
|
||||
func Init() error {
|
||||
path := DefaultConfigPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
s, e := stylesFromBytes(DefaultConfig)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
S = s
|
||||
return nil
|
||||
}
|
||||
return InitFromBytes(data)
|
||||
}
|
||||
|
||||
// InitFrom reloads S from an explicit file path.
|
||||
func InitFrom(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("style: read config: %w", err)
|
||||
}
|
||||
return InitFromBytes(data)
|
||||
}
|
||||
|
||||
// InitFromBytes reloads S from raw YAML. Accepts hex strings with or without
|
||||
// the leading '#'.
|
||||
func InitFromBytes(data []byte) error {
|
||||
s, err := stylesFromBytes(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
S = s
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteDefaultConfig writes the embedded default config to path, creating
|
||||
// parent directories as needed. No-op if the file already exists.
|
||||
func WriteDefaultConfig(path string) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("style: create config dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, DefaultConfig, 0o600); err != nil {
|
||||
return fmt.Errorf("style: write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultConfigPath returns the canonical user config path,
|
||||
// respecting $XDG_CONFIG_HOME.
|
||||
func DefaultConfigPath() string {
|
||||
return filepath.Join(configDir(), "ilovetui", "config.yaml")
|
||||
}
|
||||
|
||||
func stylesFromBytes(data []byte) (Styles, error) {
|
||||
var base configYAML
|
||||
if err := yaml.Unmarshal(DefaultConfig, &base); err != nil {
|
||||
return Styles{}, fmt.Errorf("style: parse default config: %w", err)
|
||||
}
|
||||
var user configYAML
|
||||
if err := yaml.Unmarshal(data, &user); err != nil {
|
||||
return Styles{}, fmt.Errorf("style: parse config: %w", err)
|
||||
}
|
||||
merged := mergeConfig(base, user)
|
||||
return newStyles(merged.Colors, merged.NerdFonts, merged.Border), nil
|
||||
}
|
||||
|
||||
func configDir() string {
|
||||
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config")
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# tabs
|
||||
|
||||
A horizontal tab bar, styled from `style.S`. Switches between a set of items with
|
||||
`left`/`right`/`h`/`l`/`tab`/`shift+tab`, wrapping around at either end by default. Draws its own
|
||||
frame (tab bar + a `Content` box below it) that follows the theme's configured border family
|
||||
(`style.S.BorderType`) and reads as one continuous box.
|
||||
|
||||
`tabs` only renders the bar and the frame around the active item's content - it never runs the
|
||||
content itself; that's the host's job, same as any other custom component in this repo.
|
||||
|
||||
## Concepts
|
||||
|
||||
- **`Tab`** is what each tab shows: `Init() tea.Cmd`, `Update(tea.Msg) (Tab, tea.Cmd)`,
|
||||
`View() string` - the same shape used by every other custom component in this repo.
|
||||
- **`Item`** pairs a `Tab` with the `Title` shown on its tab.
|
||||
- **`Model`** is the running tab bar: the item list, which one is active, focus state, size, and
|
||||
styles. Build one with `tabs.New(items, opts...)`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/tabs"
|
||||
)
|
||||
|
||||
func main() {
|
||||
items := []tabs.Item{
|
||||
{Title: "First", Model: newPane("First")},
|
||||
{Title: "Second", Model: newPane("Second")},
|
||||
{Title: "Third", Model: newPane("Third")},
|
||||
}
|
||||
m := model{tabs: tabs.New(items)}
|
||||
|
||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See `examples/tabs` for the full `model`/`Tab` implementation, including sizing.
|
||||
|
||||
## Writing a Tab
|
||||
|
||||
```go
|
||||
type Tab interface {
|
||||
Init() tea.Cmd
|
||||
Update(tea.Msg) (Tab, tea.Cmd)
|
||||
View() string
|
||||
}
|
||||
```
|
||||
|
||||
`tabs.Update` routes every message that isn't a Next/Prev key press straight to the active item's
|
||||
`Update`, so a `Tab` behaves like any other bubbletea model - it just never sees messages while a
|
||||
different tab is active.
|
||||
|
||||
## Sizing
|
||||
|
||||
`tabs` has no generic way to size an arbitrary `Tab` itself (the interface is intentionally
|
||||
minimal), so a host building a fullscreen app sizes the whole component, then forwards the actual
|
||||
content area back into it:
|
||||
|
||||
```go
|
||||
m.tabs.SetSize(width, height)
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.tabs, cmd = m.tabs.Update(tea.WindowSizeMsg{
|
||||
Width: m.tabs.ContentWidth(),
|
||||
Height: m.tabs.ContentHeight(),
|
||||
})
|
||||
```
|
||||
|
||||
The tab bar itself always keeps its intrinsic width (the sum of its tab labels); only `Content`
|
||||
stretches to fill `Width`, so the bar never looks artificially stretched. `ContentWidth`/
|
||||
`ContentHeight` report the usable inner area once `Width`/`Height` are set (`Content`'s box size
|
||||
minus its own border and padding) - forward that to whatever `Tab` implementation needs to know
|
||||
its own size, exactly as you'd size any other nested bubbles component.
|
||||
|
||||
When there are more tabs than fit `Width`, `tabs` collapses the overflow into a single trailing
|
||||
`+N` badge, keeping a contiguous window around the active tab.
|
||||
|
||||
## Focus vs. active tab
|
||||
|
||||
Two independent things:
|
||||
|
||||
- **`Focused()`/`Focus()`/`Blur()`/`WithFocus(bool)`** (on by default) control the frame's border
|
||||
color: `style.S.Primary` when focused, `style.S.Subtle` when blurred. Meant for host apps with
|
||||
several panes that toggle focus between them (e.g. alongside `layout`) - the border color never
|
||||
depends on which tab is active, only on whether `tabs` itself currently has keyboard focus.
|
||||
- **Which item is active** is shown only by the tab's title style (`Styles.ActiveTitle` vs.
|
||||
`InactiveTitle`), not by border color.
|
||||
|
||||
## Navigation
|
||||
|
||||
```go
|
||||
km := tabs.DefaultKeyMap()
|
||||
km.Next = key.NewBinding(key.WithKeys("right"), key.WithHelp("→", "next"))
|
||||
m := tabs.New(items, tabs.WithKeyMap(km))
|
||||
```
|
||||
|
||||
`WithLoop(false)` clamps at either end instead of wrapping; `WithActive(i)` sets the initial tab.
|
||||
|
||||
## Examples
|
||||
|
||||
- `examples/tabs` - a full fullscreen app: sizing, per-tab independent state, `+`/counter demo.
|
||||
+533
@@ -0,0 +1,533 @@
|
||||
package tabs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
type Tab interface {
|
||||
Init() tea.Cmd
|
||||
Update(tea.Msg) (Tab, tea.Cmd)
|
||||
View() string
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Title string
|
||||
Model Tab
|
||||
}
|
||||
|
||||
type KeyMap struct {
|
||||
Next key.Binding
|
||||
Prev key.Binding
|
||||
}
|
||||
|
||||
func DefaultKeyMap() KeyMap {
|
||||
return KeyMap{
|
||||
Next: key.NewBinding(
|
||||
key.WithKeys("right", "l", "tab"),
|
||||
key.WithHelp("→/tab", "next tab"),
|
||||
),
|
||||
Prev: key.NewBinding(
|
||||
key.WithKeys("left", "h", "shift+tab"),
|
||||
key.WithHelp("←/shift+tab", "previous tab"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type Styles struct {
|
||||
// Border shape/padding for the tab boxes, color-less: the actual border
|
||||
// color is picked at render time from FocusedBorder/BlurredBorder
|
||||
// depending on Model.Focused(), so the whole tabs+content frame always
|
||||
// reads as one continuous, single-colored box.
|
||||
ActiveTab lipgloss.Style
|
||||
InactiveTab lipgloss.Style
|
||||
// Title text styles: this is what actually distinguishes the active tab
|
||||
// from the others.
|
||||
ActiveTitle lipgloss.Style
|
||||
InactiveTitle lipgloss.Style
|
||||
// Border shape/padding for the content pane, same color-less rule as
|
||||
// above.
|
||||
Content lipgloss.Style
|
||||
|
||||
// The border family (rounded, normal, thick...) tabs and Content are
|
||||
// built from, snapshotted from style.S.BorderType at DefaultStyles()
|
||||
// time. Kept around so renderBar can pick the right per-position notch
|
||||
// glyph (corner vs. T-junction) for that same family at render time.
|
||||
BorderType lipgloss.Border
|
||||
|
||||
FocusedBorder color.Color
|
||||
BlurredBorder color.Color
|
||||
}
|
||||
|
||||
func DefaultStyles() Styles {
|
||||
bt := style.S.BorderType
|
||||
inactiveBorder, activeBorder := tabBorders(bt)
|
||||
|
||||
return Styles{
|
||||
InactiveTab: lipgloss.NewStyle().
|
||||
Border(inactiveBorder, true).
|
||||
Padding(0, 1),
|
||||
ActiveTab: lipgloss.NewStyle().
|
||||
Border(activeBorder, true).
|
||||
Padding(0, 1),
|
||||
|
||||
ActiveTitle: lipgloss.NewStyle().Foreground(style.S.Primary).Bold(true),
|
||||
InactiveTitle: lipgloss.NewStyle().Foreground(style.S.Subtle),
|
||||
|
||||
Content: lipgloss.NewStyle().
|
||||
Border(bt).
|
||||
UnsetBorderTop().
|
||||
Padding(1, 2),
|
||||
|
||||
BorderType: bt,
|
||||
|
||||
FocusedBorder: style.S.Primary,
|
||||
BlurredBorder: style.S.Subtle,
|
||||
}
|
||||
}
|
||||
|
||||
// tabBorders derives the inactive/active tab border shapes from a border
|
||||
// family, using its own junction glyphs (MiddleBottom, MiddleLeft,
|
||||
// MiddleRight...) instead of hardcoded characters, so tabs follow
|
||||
// style.S.BorderType instead of always looking rounded regardless of config.
|
||||
//
|
||||
// Inactive tabs get a plain "┴"-style bottom (a Content has UnsetBorderTop,
|
||||
// so this line is what actually separates the bar from Content below).
|
||||
// The active tab's bottom is left open (blank) with its corners swapped to
|
||||
// the family's own BottomLeft/BottomRight glyphs, so its sides appear to
|
||||
// flow straight down into Content.
|
||||
func tabBorders(bt lipgloss.Border) (inactive, active lipgloss.Border) {
|
||||
inactive = bt
|
||||
inactive.BottomLeft = bt.MiddleBottom
|
||||
inactive.BottomRight = bt.MiddleBottom
|
||||
|
||||
active = bt
|
||||
active.Bottom = " "
|
||||
active.BottomLeft = bt.BottomRight
|
||||
active.BottomRight = bt.BottomLeft
|
||||
|
||||
return inactive, active
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
items []Item
|
||||
active int
|
||||
focused bool
|
||||
loop bool
|
||||
width int
|
||||
height int
|
||||
|
||||
styles Styles
|
||||
keyMap KeyMap
|
||||
}
|
||||
|
||||
type Option func(*Model)
|
||||
|
||||
func WithStyles(s Styles) Option {
|
||||
return func(m *Model) {
|
||||
m.styles = s
|
||||
}
|
||||
}
|
||||
|
||||
func WithKeyMap(k KeyMap) Option {
|
||||
return func(m *Model) {
|
||||
m.keyMap = k
|
||||
}
|
||||
}
|
||||
|
||||
func WithActive(i int) Option {
|
||||
return func(m *Model) {
|
||||
m.active = i
|
||||
}
|
||||
}
|
||||
|
||||
// WithLoop sets whether Next/Prev navigation wraps around: Next from the
|
||||
// last tab goes to the first, Prev from the first tab goes to the last.
|
||||
// On by default; pass false to clamp at either end instead.
|
||||
func WithLoop(l bool) Option {
|
||||
return func(m *Model) {
|
||||
m.loop = l
|
||||
}
|
||||
}
|
||||
|
||||
// WithFocus sets the initial focus state. A focused tabs bar renders its
|
||||
// border in the accent color, a blurred one in the muted color, letting a
|
||||
// host app with several panes show which one is currently active.
|
||||
func WithFocus(f bool) Option {
|
||||
return func(m *Model) {
|
||||
m.focused = f
|
||||
}
|
||||
}
|
||||
|
||||
func New(items []Item, opts ...Option) Model {
|
||||
m := Model{
|
||||
items: items,
|
||||
focused: true,
|
||||
loop: true,
|
||||
styles: DefaultStyles(),
|
||||
keyMap: DefaultKeyMap(),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
|
||||
m.active = clamp(m.active, 0, len(m.items)-1)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
cmds := make([]tea.Cmd, len(m.items))
|
||||
for i, item := range m.items {
|
||||
cmds[i] = item.Model.Init()
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m Model) Active() int {
|
||||
return m.active
|
||||
}
|
||||
|
||||
func (m *Model) SetActive(i int) {
|
||||
m.active = clamp(i, 0, len(m.items)-1)
|
||||
}
|
||||
|
||||
func (m Model) Items() []Item {
|
||||
return m.items
|
||||
}
|
||||
|
||||
func (m Model) ActiveItem() Item {
|
||||
return m.items[m.active]
|
||||
}
|
||||
|
||||
func (m Model) Focused() bool {
|
||||
return m.focused
|
||||
}
|
||||
|
||||
func (m *Model) Focus() {
|
||||
m.focused = true
|
||||
}
|
||||
|
||||
func (m *Model) Blur() {
|
||||
m.focused = false
|
||||
}
|
||||
|
||||
func (m Model) Loop() bool {
|
||||
return m.loop
|
||||
}
|
||||
|
||||
func (m *Model) SetLoop(l bool) {
|
||||
m.loop = l
|
||||
}
|
||||
|
||||
func (m Model) Width() int {
|
||||
return m.width
|
||||
}
|
||||
|
||||
// SetWidth sets the target outer width for the whole component. The tab bar
|
||||
// itself always keeps its intrinsic width (the sum of its tab labels);
|
||||
// Content stretches to Width if that's wider, so a host building a
|
||||
// fullscreen app can make the content pane fill the terminal without the tab
|
||||
// bar itself looking artificially stretched.
|
||||
func (m *Model) SetWidth(w int) {
|
||||
m.width = w
|
||||
}
|
||||
|
||||
func (m Model) Height() int {
|
||||
return m.height
|
||||
}
|
||||
|
||||
// SetHeight sets the target outer height for the whole component. Content's
|
||||
// height is Height minus the bar's own (fixed) height; SetHeight is a no-op
|
||||
// on the render until called, so the zero-value Model keeps auto-sizing
|
||||
// Content to whatever the active item's View() returns.
|
||||
func (m *Model) SetHeight(h int) {
|
||||
m.height = h
|
||||
}
|
||||
|
||||
// SetSize is a shorthand for SetWidth followed by SetHeight.
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
}
|
||||
|
||||
// ContentWidth and ContentHeight report the usable inner area available to
|
||||
// the active item's View() once Width/Height are set: Content's box size
|
||||
// minus its own border and padding. tabs has no generic way to size an
|
||||
// arbitrary Tab itself (the interface is intentionally minimal), so a host
|
||||
// building a fullscreen app calls these after SetSize and forwards the
|
||||
// result to its own Tab implementations, exactly as it would size any other
|
||||
// nested bubbles component. ContentHeight returns 0 until SetHeight has been
|
||||
// called (see SetHeight).
|
||||
func (m Model) ContentWidth() int {
|
||||
if len(m.items) == 0 {
|
||||
return 0
|
||||
}
|
||||
w := lipgloss.Width(m.renderBar(m.collapsedSegments(m.width), m.styles.BlurredBorder, false))
|
||||
if m.width > w {
|
||||
w = m.width
|
||||
}
|
||||
if inner := w - m.styles.Content.GetHorizontalFrameSize(); inner > 0 {
|
||||
return inner
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m Model) ContentHeight() int {
|
||||
if len(m.items) == 0 || m.height <= 0 {
|
||||
return 0
|
||||
}
|
||||
barHeight := lipgloss.Height(m.renderBar(m.collapsedSegments(m.width), m.styles.BlurredBorder, false))
|
||||
if inner := m.height - barHeight - m.styles.Content.GetVerticalFrameSize(); inner > 0 {
|
||||
return inner
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
|
||||
switch {
|
||||
case key.Matches(keyMsg, m.keyMap.Next):
|
||||
m.active = m.step(1)
|
||||
return m, nil
|
||||
case key.Matches(keyMsg, m.keyMap.Prev):
|
||||
m.active = m.step(-1)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(m.items) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.items[m.active].Model, cmd = m.items[m.active].Model.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// tabSegment is a single box drawn on the bar: either a real item, or the
|
||||
// synthetic "+N" segment standing in for tabs collapsed by collapsedSegments.
|
||||
// It's never active and never has a backing Item.
|
||||
type tabSegment struct {
|
||||
title string
|
||||
isActive bool
|
||||
isMore bool
|
||||
}
|
||||
|
||||
func (m Model) segments() []tabSegment {
|
||||
segs := make([]tabSegment, len(m.items))
|
||||
for i, item := range m.items {
|
||||
segs[i] = tabSegment{title: item.Title, isActive: i == m.active}
|
||||
}
|
||||
return segs
|
||||
}
|
||||
|
||||
// segmentWidth measures a segment as it would actually render, without
|
||||
// needing a border color (color doesn't affect measured width).
|
||||
func (m Model) segmentWidth(seg tabSegment) int {
|
||||
tabStyle := m.styles.InactiveTab
|
||||
titleStyle := m.styles.InactiveTitle
|
||||
if seg.isActive {
|
||||
tabStyle = m.styles.ActiveTab
|
||||
titleStyle = m.styles.ActiveTitle
|
||||
}
|
||||
return lipgloss.Width(tabStyle.Render(titleStyle.Render(seg.title)))
|
||||
}
|
||||
|
||||
// collapsedSegments returns the full segment list unchanged if it already
|
||||
// fits within budget (or budget is unset). Otherwise it keeps a contiguous
|
||||
// window of tabs that always includes the active one - grown outward from
|
||||
// active, alternating backward/forward, as far as it fits - and folds
|
||||
// everything left out of that window into a single trailing "+N" segment.
|
||||
func (m Model) collapsedSegments(budget int) []tabSegment {
|
||||
segs := m.segments()
|
||||
if budget <= 0 {
|
||||
return segs
|
||||
}
|
||||
|
||||
widths := make([]int, len(segs))
|
||||
total := 0
|
||||
for i, seg := range segs {
|
||||
widths[i] = m.segmentWidth(seg)
|
||||
total += widths[i]
|
||||
}
|
||||
if total <= budget {
|
||||
return segs
|
||||
}
|
||||
|
||||
moreWidth := m.segmentWidth(tabSegment{title: fmt.Sprintf("+%d", len(segs)-1), isMore: true})
|
||||
fitBudget := budget - moreWidth
|
||||
if fitBudget < widths[m.active] {
|
||||
// Not even room for active + the badge: guarantee active alone
|
||||
// fits, even if that leaves the badge slightly cramped.
|
||||
fitBudget = widths[m.active]
|
||||
}
|
||||
|
||||
start, end := m.active, m.active
|
||||
used := widths[m.active]
|
||||
for {
|
||||
grew := false
|
||||
if start > 0 && used+widths[start-1] <= fitBudget {
|
||||
start--
|
||||
used += widths[start]
|
||||
grew = true
|
||||
}
|
||||
if end < len(segs)-1 && used+widths[end+1] <= fitBudget {
|
||||
end++
|
||||
used += widths[end]
|
||||
grew = true
|
||||
}
|
||||
if !grew {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
hidden := len(segs) - (end - start + 1)
|
||||
if hidden <= 0 {
|
||||
return segs
|
||||
}
|
||||
|
||||
visible := append([]tabSegment{}, segs[start:end+1]...)
|
||||
visible = append(visible, tabSegment{title: fmt.Sprintf("+%d", hidden), isMore: true})
|
||||
return visible
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if len(m.items) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
borderColor := m.styles.BlurredBorder
|
||||
if m.focused {
|
||||
borderColor = m.styles.FocusedBorder
|
||||
}
|
||||
|
||||
segs := m.collapsedSegments(m.width)
|
||||
|
||||
bar := m.renderBar(segs, borderColor, false)
|
||||
contentWidth := lipgloss.Width(bar)
|
||||
|
||||
if m.width > contentWidth {
|
||||
// Re-render with the last tab's right edge treated as an interior
|
||||
// junction instead of the widget's outer edge, since the cap line
|
||||
// now continues past it into the extension.
|
||||
bar = m.extendBarCap(m.renderBar(segs, borderColor, true), m.width, borderColor)
|
||||
contentWidth = m.width
|
||||
}
|
||||
contentStyle := m.styles.Content.
|
||||
BorderForeground(borderColor).
|
||||
Width(contentWidth)
|
||||
if m.height > 0 {
|
||||
if h := m.height - lipgloss.Height(bar); h > 0 {
|
||||
contentStyle = contentStyle.Height(h)
|
||||
}
|
||||
}
|
||||
|
||||
content := contentStyle.Render(m.items[m.active].Model.View())
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, bar, content)
|
||||
}
|
||||
|
||||
// renderBar builds the tab bar. extendCap should be true when the caller
|
||||
// already knows the cap line will be stretched past the last tab (see
|
||||
// extendBarCap): in that case the last tab's right edge is drawn as an
|
||||
// interior junction (bt.MiddleLeft) rather than the widget's outer edge,
|
||||
// since the horizontal line continues past it instead of terminating there.
|
||||
func (m Model) renderBar(segs []tabSegment, borderColor color.Color, extendCap bool) string {
|
||||
rendered := make([]string, len(segs))
|
||||
|
||||
for i, seg := range segs {
|
||||
isFirst, isLast := i == 0, i == len(segs)-1
|
||||
|
||||
tabStyle := m.styles.InactiveTab
|
||||
titleStyle := m.styles.InactiveTitle
|
||||
if seg.isActive {
|
||||
tabStyle = m.styles.ActiveTab
|
||||
titleStyle = m.styles.ActiveTitle
|
||||
}
|
||||
tabStyle = tabStyle.BorderForeground(borderColor)
|
||||
|
||||
bt := m.styles.BorderType
|
||||
border, _, _, _, _ := tabStyle.GetBorder()
|
||||
switch {
|
||||
case isFirst && seg.isActive:
|
||||
border.BottomLeft = bt.Left
|
||||
case isFirst && !seg.isActive:
|
||||
border.BottomLeft = bt.MiddleLeft
|
||||
case isLast && seg.isActive && !extendCap:
|
||||
border.BottomRight = bt.Right
|
||||
case isLast && !seg.isActive && !extendCap:
|
||||
border.BottomRight = bt.MiddleRight
|
||||
}
|
||||
// extendCap: no BottomRight override at all, so the last tab falls
|
||||
// back to its type's plain default (already set in DefaultStyles:
|
||||
// bt.MiddleBottom for inactive, the swap-trick corner for active) -
|
||||
// same as every other, non-edge tab. The isLast-specific corners
|
||||
// above only make sense when this really is the widget's edge and
|
||||
// Content's own border aligns right below it; once the cap extends
|
||||
// past it, that's no longer true.
|
||||
tabStyle = tabStyle.Border(border)
|
||||
|
||||
rendered[i] = tabStyle.Render(titleStyle.Render(seg.title))
|
||||
}
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
|
||||
}
|
||||
|
||||
// extendBarCap stretches only the bar's bottom row out to width. That row
|
||||
// doubles as Content's own top border (Content has UnsetBorderTop), so when
|
||||
// Content is wider than the bar's natural width, it needs to reach all the
|
||||
// way across or the frame looks broken open above the extra space.
|
||||
// lipgloss.JoinVertical would otherwise pad the shorter bar rows with plain
|
||||
// spaces, not border characters.
|
||||
func (m Model) extendBarCap(bar string, width int, borderColor color.Color) string {
|
||||
gap := width - lipgloss.Width(bar)
|
||||
if gap <= 0 {
|
||||
return bar
|
||||
}
|
||||
|
||||
bt := m.styles.BorderType
|
||||
fill := lipgloss.NewStyle().
|
||||
Foreground(borderColor).
|
||||
Render(strings.Repeat(bt.Bottom, gap-1) + bt.TopRight)
|
||||
|
||||
lines := strings.Split(bar, "\n")
|
||||
lines[len(lines)-1] += fill
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// step moves the active index by delta (+1 for Next, -1 for Prev). With Loop
|
||||
// it wraps around at either end; otherwise it just clamps, so Next on the
|
||||
// last tab (or Prev on the first) is a no-op.
|
||||
func (m Model) step(delta int) int {
|
||||
n := len(m.items)
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
if m.loop {
|
||||
return ((m.active+delta)%n + n) % n
|
||||
}
|
||||
return clamp(m.active+delta, 0, n-1)
|
||||
}
|
||||
|
||||
func clamp(v, low, high int) int {
|
||||
if high < low {
|
||||
return low
|
||||
}
|
||||
if v < low {
|
||||
return low
|
||||
}
|
||||
if v > high {
|
||||
return high
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user