mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 03:55:48 +02:00
Change style package, new components, ...
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -1,92 +1,33 @@
|
||||
# Ilovetui
|
||||
# I Love TUI
|
||||
|
||||
A minimal Go library that provides a shared [Base16](https://github.com/tinted-theming/home) color theme for terminal UIs built with [bubbletea](https://github.com/charmbracelet/bubbletea) and [lipgloss](https://github.com/charmbracelet/lipgloss).
|
||||
A shared [Base16](https://github.com/tinted-theming/home) theme, a themed wrapper around every official component, and a small set of custom Bubble Tea components, in one Go module, so every TUI built with it shares one config file and looks consistent.
|
||||
|
||||
The idea is simple: instead of every TUI app managing its own colors, they all share one theme file so the user customizes once and every app looks consistent.
|
||||
|
||||
## How it works
|
||||
|
||||
On import, `ilovetui` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (respecting `$XDG_CONFIG_HOME`).
|
||||
If no config exists, it falls back to the embedded default. The active theme is exposed as the package-level variable `S`.
|
||||
|
||||
```go
|
||||
import "github.com/anotherhadi/ilovetui"
|
||||
|
||||
// Use colors directly
|
||||
style := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||
|
||||
// Use pre-built panel styles
|
||||
box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Title", content, w, h)
|
||||
```
|
||||
|
||||
No setup required — just import and use.
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
```sh
|
||||
go get github.com/anotherhadi/ilovetui
|
||||
```
|
||||
|
||||
## Theme
|
||||
## Packages
|
||||
|
||||
The theme follows the [Base16](https://github.com/tinted-theming/home) standard (16 colors). The library exposes both the raw palette and semantic aliases:
|
||||
- [`style`](style/README.md): the theme itself. Colors, pre-built panel styles, config loading.
|
||||
- [`bubbles`](bubbles/README.md): themed constructors for official `bubbles/v2` components (`help`, `textarea`, `textinput`, `viewport`, ...).
|
||||
- [`tabs`](tabs/README.md), [`modal`](modal/README.md), [`drawer`](drawer/README.md), [`notification`](notification/README.md), [`helpbar`](helpbar/README.md): custom components not found in the official `bubbles` library, styled from the same theme.
|
||||
|
||||
| Alias | Base16 | Meaning |
|
||||
| ------------ | ------ | --------------------------------------- |
|
||||
| `Background` | Base00 | Background |
|
||||
| `SubtleBg` | Base01 | Lighter Background / Status Bars |
|
||||
| `Selection` | Base02 | Selection Background |
|
||||
| `Subtle` | Base03 | Comments / Invisibles |
|
||||
| `Muted` | Base04 | Dark Foreground / Status Bars |
|
||||
| `Text` | Base05 | Default Foreground |
|
||||
| `Primary` | Base0D | Functions / Methods / Headings / Accent |
|
||||
| `Success` | Base0B | Strings / Success / Diff Inserted |
|
||||
| `Warning` | Base09 | Integers / Constants / Booleans |
|
||||
| `Error` | Base08 | Variables / Errors / Diff Deleted |
|
||||
Each package has its own README and a runnable example under `examples/<package>`.
|
||||
|
||||
The default theme is `./default.yaml`. Copy it and edit to customize:
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/ilovetui
|
||||
cp $(go env GOPATH)/pkg/mod/github.com/anotherhadi/ilovetui*/default.yaml ~/.config/ilovetui/config.yaml
|
||||
```
|
||||
|
||||
Or let your app write it on first run:
|
||||
On import, `style` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (embedded default as fallback), exposed as the package-level `S`:
|
||||
|
||||
```go
|
||||
ilovetui.WriteDefaultConfig(ilovetui.DefaultConfigPath())
|
||||
import "github.com/anotherhadi/ilovetui/style"
|
||||
|
||||
s := lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h)
|
||||
```
|
||||
|
||||
## Pre-built styles
|
||||
|
||||
`S` ships with a few ready-to-use lipgloss styles:
|
||||
|
||||
| Field | Description |
|
||||
| ---------------- | ---------------------------------------- |
|
||||
| `S.Bold` | Bold text |
|
||||
| `S.Faint` | Muted / dimmed text |
|
||||
| `S.Panel` | Rounded border, unfocused (Subtle color) |
|
||||
| `S.PanelFocused` | Rounded border, focused (Primary color) |
|
||||
|
||||
## Helpers
|
||||
|
||||
```go
|
||||
// Inner usable height of a bordered panel with outer height h
|
||||
inner := ilovetui.ContentHeight(h)
|
||||
|
||||
// Render a box with a title embedded in the top border
|
||||
box := ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, "Header", content, w, h)
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```go
|
||||
ilovetui.Init() // Reload from default config path
|
||||
ilovetui.InitFrom(path string) // Reload from a custom path
|
||||
ilovetui.InitFromBytes(data []byte) // Parse raw YAML
|
||||
ilovetui.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml
|
||||
ilovetui.WriteDefaultConfig(path) // Write default config if missing
|
||||
```
|
||||
No setup required. See [`style/README.md`](style/README.md) for config details.
|
||||
|
||||
## Projects using ilovetui
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# bubbles
|
||||
|
||||
Themed constructors for every official `bubbles/v2` component: `help`, `textarea`, `textinput`,
|
||||
`list`, `table`, `filepicker`, `spinner`, `progress`, `paginator`, `viewport`. Each wraps the
|
||||
official `New`, then applies `style.S` colors on top.
|
||||
|
||||
- `spinner`, `table`, `progress` forward any `opts` to the upstream `New` before the theme is
|
||||
applied, so the theme's colors always win over anything conflicting in `opts`.
|
||||
- `ViewportView` renders a `viewport.Model` with a themed scrollbar thumb in the left gutter,
|
||||
shown only when content overflows.
|
||||
- `NewList`/`NewDefaultDelegate` theme both the list chrome and the selection/filter colors of its
|
||||
default delegate.
|
||||
|
||||
Custom components in this repo (`tabs`, `modal`, `drawer`, `notification`, `helpbar`) build any
|
||||
official component they need through here, never through `charm.land/bubbles/v2` directly.
|
||||
|
||||
See `examples/bubbles`.
|
||||
@@ -0,0 +1 @@
|
||||
package bubbles
|
||||
@@ -0,0 +1,25 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/filepicker"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
func NewFilePicker() filepicker.Model {
|
||||
f := filepicker.New()
|
||||
s := filepicker.DefaultStyles()
|
||||
s.Cursor = s.Cursor.Foreground(style.S.Primary)
|
||||
s.DisabledCursor = s.DisabledCursor.Foreground(style.S.Subtle)
|
||||
s.Symlink = s.Symlink.Foreground(style.S.Warning)
|
||||
s.Directory = s.Directory.Foreground(style.S.Primary)
|
||||
s.File = s.File.Foreground(style.S.Text)
|
||||
s.DisabledFile = s.DisabledFile.Foreground(style.S.Subtle)
|
||||
s.Permission = s.Permission.Foreground(style.S.Muted)
|
||||
s.Selected = s.Selected.Foreground(style.S.Primary)
|
||||
s.DisabledSelected = s.DisabledSelected.Foreground(style.S.Subtle)
|
||||
s.FileSize = s.FileSize.Foreground(style.S.Muted)
|
||||
s.EmptyDirectory = s.EmptyDirectory.Foreground(style.S.Subtle)
|
||||
f.Styles = s
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
func NewHelp() help.Model {
|
||||
h := help.New()
|
||||
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
|
||||
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
h.Styles.FullKey = lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
|
||||
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/list"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
func NewList(items []list.Item, width, height int) list.Model {
|
||||
m := list.New(items, NewDefaultDelegate(), width, height)
|
||||
m.Styles = themedListStyles()
|
||||
return m
|
||||
}
|
||||
|
||||
func themedListStyles() list.Styles {
|
||||
s := list.DefaultStyles(true)
|
||||
s.Title = s.Title.Background(style.S.Primary).Foreground(style.S.Background)
|
||||
s.Spinner = s.Spinner.Foreground(style.S.Primary)
|
||||
s.Filter = themedTextInputStyles()
|
||||
s.DefaultFilterCharacterMatch = s.DefaultFilterCharacterMatch.Foreground(style.S.Primary)
|
||||
s.StatusBar = s.StatusBar.Foreground(style.S.Muted)
|
||||
s.StatusEmpty = s.StatusEmpty.Foreground(style.S.Subtle)
|
||||
s.StatusBarActiveFilter = s.StatusBarActiveFilter.Foreground(style.S.Text)
|
||||
s.StatusBarFilterCount = s.StatusBarFilterCount.Foreground(style.S.Subtle)
|
||||
s.NoItems = s.NoItems.Foreground(style.S.Subtle)
|
||||
s.ArabicPagination = s.ArabicPagination.Foreground(style.S.Subtle)
|
||||
s.ActivePaginationDot = s.ActivePaginationDot.Foreground(style.S.Primary)
|
||||
s.InactivePaginationDot = s.InactivePaginationDot.Foreground(style.S.Subtle)
|
||||
s.DividerDot = s.DividerDot.Foreground(style.S.Subtle)
|
||||
return s
|
||||
}
|
||||
|
||||
func NewDefaultDelegate() list.DefaultDelegate {
|
||||
d := list.NewDefaultDelegate()
|
||||
d.Styles.NormalTitle = d.Styles.NormalTitle.Foreground(style.S.Text)
|
||||
d.Styles.NormalDesc = d.Styles.NormalDesc.Foreground(style.S.Muted)
|
||||
d.Styles.SelectedTitle = d.Styles.SelectedTitle.
|
||||
BorderForeground(style.S.Primary).
|
||||
Foreground(style.S.Primary)
|
||||
d.Styles.SelectedDesc = d.Styles.SelectedDesc.
|
||||
BorderForeground(style.S.Primary).
|
||||
Foreground(style.S.Primary)
|
||||
d.Styles.DimmedTitle = d.Styles.DimmedTitle.Foreground(style.S.Subtle)
|
||||
d.Styles.DimmedDesc = d.Styles.DimmedDesc.Foreground(style.S.SubtleBg)
|
||||
d.Styles.FilterMatch = d.Styles.FilterMatch.Foreground(style.S.Primary)
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/paginator"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
func NewPaginator() paginator.Model {
|
||||
p := paginator.New()
|
||||
p.Type = paginator.Dots
|
||||
p.ActiveDot = lipgloss.NewStyle().Foreground(style.S.Primary).Render("•")
|
||||
p.InactiveDot = lipgloss.NewStyle().Foreground(style.S.Subtle).Render("•")
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/progress"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
func NewProgress(opts ...progress.Option) progress.Model {
|
||||
allOpts := append([]progress.Option{
|
||||
progress.WithColors(style.S.Subtle, style.S.Primary),
|
||||
}, opts...)
|
||||
p := progress.New(allOpts...)
|
||||
p.EmptyColor = style.S.SubtleBg
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
func NewSpinner(opts ...spinner.Option) spinner.Model {
|
||||
s := spinner.New(opts...)
|
||||
s.Style = lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/table"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
func NewTable(opts ...table.Option) table.Model {
|
||||
t := table.New(opts...)
|
||||
s := table.DefaultStyles()
|
||||
s.Header = s.Header.Foreground(style.S.Primary)
|
||||
s.Cell = s.Cell.Foreground(style.S.Text)
|
||||
s.Selected = s.Selected.Foreground(style.S.Primary)
|
||||
t.SetStyles(s)
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
func NewTextarea(showLineNumbers bool) textarea.Model {
|
||||
ta := textarea.New()
|
||||
ta.Prompt = ""
|
||||
ta.ShowLineNumbers = showLineNumbers
|
||||
ta.CharLimit = 0
|
||||
ta.EndOfBufferCharacter = '~'
|
||||
ts := ta.Styles()
|
||||
ts.Focused.Base = lipgloss.NewStyle()
|
||||
ts.Blurred.Base = lipgloss.NewStyle()
|
||||
ts.Focused.Text = lipgloss.NewStyle().Foreground(style.S.Text)
|
||||
ts.Focused.CursorLine = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Text)
|
||||
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Primary).Bold(true)
|
||||
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
|
||||
ts.Blurred.Text = lipgloss.NewStyle().Foreground(style.S.Muted)
|
||||
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
|
||||
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
|
||||
ta.SetStyles(ts)
|
||||
return ta
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
func NewTextInput() textinput.Model {
|
||||
t := textinput.New()
|
||||
t.SetStyles(themedTextInputStyles())
|
||||
return t
|
||||
}
|
||||
|
||||
func themedTextInputStyles() textinput.Styles {
|
||||
s := textinput.DefaultStyles(true)
|
||||
s.Focused.Text = s.Focused.Text.Foreground(style.S.Text)
|
||||
s.Focused.Placeholder = s.Focused.Placeholder.Foreground(style.S.Subtle)
|
||||
s.Focused.Suggestion = s.Focused.Suggestion.Foreground(style.S.Subtle)
|
||||
s.Focused.Prompt = s.Focused.Prompt.Foreground(style.S.Primary)
|
||||
s.Blurred.Text = s.Blurred.Text.Foreground(style.S.Muted)
|
||||
s.Blurred.Placeholder = s.Blurred.Placeholder.Foreground(style.S.Subtle)
|
||||
s.Blurred.Suggestion = s.Blurred.Suggestion.Foreground(style.S.Subtle)
|
||||
s.Blurred.Prompt = s.Blurred.Prompt.Foreground(style.S.Subtle)
|
||||
s.Cursor.Color = style.S.Primary
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package bubbles
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
const ViewportGutterWidth = 2
|
||||
|
||||
func NewViewport() viewport.Model {
|
||||
vp := viewport.New()
|
||||
vp.MouseWheelEnabled = false
|
||||
return vp
|
||||
}
|
||||
|
||||
func ViewportView(vp *viewport.Model) string {
|
||||
height := vp.Height()
|
||||
total := vp.TotalLineCount()
|
||||
blank := lipgloss.NewStyle().Width(ViewportGutterWidth).Render("")
|
||||
|
||||
if height <= 0 || total <= height {
|
||||
vp.LeftGutterFunc = func(viewport.GutterContext) string { return blank }
|
||||
return vp.View()
|
||||
}
|
||||
|
||||
yOffset := vp.YOffset()
|
||||
thumbSize := max(1, height*height/total)
|
||||
thumbStart := 0
|
||||
if maxOffset := total - height; maxOffset > 0 {
|
||||
thumbStart = yOffset * (height - thumbSize) / maxOffset
|
||||
}
|
||||
|
||||
trackStyle := lipgloss.NewStyle().Foreground(style.S.Subtle)
|
||||
thumbStyle := lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
|
||||
vp.LeftGutterFunc = func(ctx viewport.GutterContext) string {
|
||||
pos := ctx.Index - yOffset
|
||||
if pos >= thumbStart && pos < thumbStart+thumbSize {
|
||||
return thumbStyle.Render("█") + " "
|
||||
}
|
||||
return trackStyle.Render("│") + " "
|
||||
}
|
||||
|
||||
return vp.View()
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package ilovetui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/paginator"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// NewHelp returns a help.Model styled with the active theme.
|
||||
func NewHelp() help.Model {
|
||||
h := help.New()
|
||||
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(S.Primary)
|
||||
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(S.Muted)
|
||||
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
|
||||
h.Styles.FullKey = lipgloss.NewStyle().Foreground(S.Primary)
|
||||
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(S.Muted)
|
||||
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
|
||||
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(S.Subtle)
|
||||
return h
|
||||
}
|
||||
|
||||
// NewTextarea returns a textarea.Model styled with the active theme.
|
||||
// Set showLineNumbers to true to display line numbers in the gutter.
|
||||
func NewTextarea(showLineNumbers bool) textarea.Model {
|
||||
ta := textarea.New()
|
||||
ta.Prompt = ""
|
||||
ta.ShowLineNumbers = showLineNumbers
|
||||
ta.CharLimit = 0
|
||||
ta.EndOfBufferCharacter = '~'
|
||||
ts := ta.Styles()
|
||||
ts.Focused.Base = lipgloss.NewStyle()
|
||||
ts.Blurred.Base = lipgloss.NewStyle()
|
||||
ts.Focused.Text = lipgloss.NewStyle().Foreground(S.Text)
|
||||
ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text)
|
||||
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Primary).Bold(true)
|
||||
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(S.Subtle)
|
||||
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
|
||||
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
|
||||
ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.Muted)
|
||||
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(S.SubtleBg)
|
||||
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
|
||||
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
|
||||
ta.SetStyles(ts)
|
||||
return ta
|
||||
}
|
||||
|
||||
// NewPaginator returns a dot-style paginator.Model styled with the active theme.
|
||||
func NewPaginator() paginator.Model {
|
||||
p := paginator.New()
|
||||
p.Type = paginator.Dots
|
||||
p.ActiveDot = S.PagerDotActive
|
||||
p.InactiveDot = S.PagerDotInactive
|
||||
return p
|
||||
}
|
||||
|
||||
// SplitH splits totalHeight into top and bottom sections, accounting for the
|
||||
// height of statusBar (measured by newline count).
|
||||
func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
|
||||
statusH := strings.Count(statusBar, "\n") + 1
|
||||
available := totalHeight - statusH
|
||||
top = int(float64(available) * ratio)
|
||||
bottom = available - top
|
||||
return
|
||||
}
|
||||
|
||||
// NewViewport returns a viewport.Model with mouse wheel disabled.
|
||||
func NewViewport() viewport.Model {
|
||||
vp := viewport.New()
|
||||
vp.MouseWheelEnabled = false
|
||||
return vp
|
||||
}
|
||||
|
||||
// ViewportView renders the viewport and appends a subtle scroll indicator
|
||||
// on the last visible line when the user has not reached the bottom.
|
||||
func ViewportView(vp *viewport.Model) string {
|
||||
v := vp.View()
|
||||
if vp.AtBottom() {
|
||||
return v
|
||||
}
|
||||
lines := strings.Split(v, "\n")
|
||||
if len(lines) == 0 {
|
||||
return v
|
||||
}
|
||||
arrow := lipgloss.NewStyle().Foreground(S.Subtle).Render("↓")
|
||||
arrowW := lipgloss.Width(arrow)
|
||||
inner := vp.Width() - 2*arrowW
|
||||
if inner < 0 {
|
||||
inner = 0
|
||||
}
|
||||
lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# drawer
|
||||
|
||||
A full-height panel flush against the left or right edge of an already-rendered background, on top
|
||||
of it dimmed. The sidebar/drawer equivalent of [`modal`](../modal/README.md), which it otherwise
|
||||
mirrors closely: same `tea.Msg`-triggered stack (`ShowMsg`/`Show`), same composite-over-a-string
|
||||
`Render`, same rule that content is a `tea.Model`.
|
||||
|
||||
- `WithSide(Left|Right)` and `WithWidth` are per-drawer `Show` options; width otherwise shrinks to
|
||||
fit content, capped by the `Model`'s `WithMaxWidth`.
|
||||
- The stack is a plain LIFO. Only the topmost drawer is updated; `Close()` closes it.
|
||||
- `View(width, height)` renders on a blank background of that size, for use as a standalone pane.
|
||||
|
||||
See `examples/drawer`.
|
||||
@@ -0,0 +1,61 @@
|
||||
package drawer
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
type Side int
|
||||
|
||||
const (
|
||||
Left Side = iota
|
||||
Right
|
||||
)
|
||||
|
||||
type Drawer struct {
|
||||
Title string
|
||||
Content tea.Model
|
||||
Side Side
|
||||
Width int
|
||||
Style *Styles
|
||||
}
|
||||
|
||||
type DrawerOption func(*Drawer)
|
||||
|
||||
func WithSide(s Side) DrawerOption {
|
||||
return func(d *Drawer) { d.Side = s }
|
||||
}
|
||||
|
||||
func WithWidth(w int) DrawerOption {
|
||||
return func(d *Drawer) { d.Width = w }
|
||||
}
|
||||
|
||||
func WithDrawerStyle(s Styles) DrawerOption {
|
||||
return func(d *Drawer) { d.Style = &s }
|
||||
}
|
||||
|
||||
func newDrawer(title string, content tea.Model, opts ...DrawerOption) Drawer {
|
||||
d := Drawer{Title: title, Content: content}
|
||||
for _, opt := range opts {
|
||||
opt(&d)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type ShowMsg struct{ Drawer Drawer }
|
||||
|
||||
func Show(title string, content tea.Model, opts ...DrawerOption) tea.Cmd {
|
||||
d := newDrawer(title, content, opts...)
|
||||
return func() tea.Msg { return ShowMsg{Drawer: d} }
|
||||
}
|
||||
|
||||
type DismissMsg struct{}
|
||||
|
||||
func Close() tea.Cmd {
|
||||
return func() tea.Msg { return DismissMsg{} }
|
||||
}
|
||||
|
||||
type text string
|
||||
|
||||
func (t text) Init() tea.Cmd { return nil }
|
||||
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
|
||||
func (t text) View() tea.View { return tea.NewView(string(t)) }
|
||||
|
||||
func Text(s string) tea.Model { return text(s) }
|
||||
@@ -0,0 +1,75 @@
|
||||
package drawer
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
type Model struct {
|
||||
drawers []Drawer
|
||||
nextID int
|
||||
maxWidth int
|
||||
styles Styles
|
||||
}
|
||||
|
||||
type Option func(*Model)
|
||||
|
||||
func WithMaxWidth(w int) Option {
|
||||
return func(m *Model) { m.maxWidth = w }
|
||||
}
|
||||
|
||||
func WithStyles(s Styles) Option {
|
||||
return func(m *Model) { m.styles = s }
|
||||
}
|
||||
|
||||
func New(opts ...Option) Model {
|
||||
m := Model{
|
||||
maxWidth: 30,
|
||||
styles: DefaultStyles(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case ShowMsg:
|
||||
return m.show(msg.Drawer)
|
||||
case DismissMsg:
|
||||
return m.pop(), nil
|
||||
}
|
||||
return m.updateTop(msg)
|
||||
}
|
||||
|
||||
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
|
||||
i := len(m.drawers) - 1
|
||||
if i < 0 || m.drawers[i].Content == nil {
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.drawers[i].Content, cmd = m.drawers[i].Content.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) Open() bool { return len(m.drawers) > 0 }
|
||||
|
||||
func (m Model) show(d Drawer) (Model, tea.Cmd) {
|
||||
m.drawers = append(m.drawers, d)
|
||||
return m, initContent(d)
|
||||
}
|
||||
|
||||
func initContent(d Drawer) tea.Cmd {
|
||||
if d.Content == nil {
|
||||
return nil
|
||||
}
|
||||
return d.Content.Init()
|
||||
}
|
||||
|
||||
func (m Model) pop() Model {
|
||||
if len(m.drawers) == 0 {
|
||||
return m
|
||||
}
|
||||
m.drawers = m.drawers[:len(m.drawers)-1]
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package drawer
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
func (m Model) Render(background string) string {
|
||||
if len(m.drawers) == 0 {
|
||||
return background
|
||||
}
|
||||
|
||||
result := background
|
||||
for _, d := range m.drawers {
|
||||
w, h := lipgloss.Width(result), lipgloss.Height(result)
|
||||
if w <= 0 || h <= 0 {
|
||||
return result
|
||||
}
|
||||
result = m.renderOne(d, result, w, h)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m Model) View(width, height int) string {
|
||||
return m.Render(blank(width, height))
|
||||
}
|
||||
|
||||
func (m Model) renderOne(d Drawer, background string, w, h int) string {
|
||||
s := m.styles
|
||||
if d.Style != nil {
|
||||
s = *d.Style
|
||||
}
|
||||
|
||||
box := m.renderBox(d, s, w, h)
|
||||
bw := lipgloss.Width(box)
|
||||
x := 0
|
||||
if d.Side == Right {
|
||||
x = max(w-bw, 0)
|
||||
}
|
||||
|
||||
compositor := lipgloss.NewCompositor(
|
||||
lipgloss.NewLayer(dim(background, s.DimColor)),
|
||||
lipgloss.NewLayer(box).X(x).Y(0).Z(1),
|
||||
)
|
||||
return compositor.Render()
|
||||
}
|
||||
|
||||
func dim(s string, c color.Color) string {
|
||||
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
|
||||
}
|
||||
|
||||
func (m Model) renderBox(d Drawer, s Styles, bgW, bgH int) string {
|
||||
widthCap := m.maxWidth
|
||||
if d.Width > 0 {
|
||||
widthCap = d.Width
|
||||
}
|
||||
maxW := effectiveMax(widthCap, bgW)
|
||||
|
||||
body := contentView(d)
|
||||
inner := contentWidth(d, body, maxW)
|
||||
content := s.Content.Width(inner).Render(body)
|
||||
|
||||
boxWidth := inner + 4
|
||||
|
||||
return style.RenderWithTitle(s.Border, s.Title.Render(d.Title), content, boxWidth, bgH)
|
||||
}
|
||||
|
||||
func contentView(d Drawer) string {
|
||||
if d.Content == nil {
|
||||
return ""
|
||||
}
|
||||
return d.Content.View().Content
|
||||
}
|
||||
|
||||
func contentWidth(d Drawer, body string, maxWidth int) int {
|
||||
capped := max(maxWidth-4, 1)
|
||||
if d.Width > 0 {
|
||||
return capped
|
||||
}
|
||||
natural := max(naturalWidth(body), lipgloss.Width(d.Title), 1)
|
||||
return min(natural, capped)
|
||||
}
|
||||
|
||||
func naturalWidth(content string) int {
|
||||
w := 0
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if lw := lipgloss.Width(line); lw > w {
|
||||
w = lw
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func effectiveMax(configured, fits int) int {
|
||||
if fits < 1 {
|
||||
fits = 1
|
||||
}
|
||||
if configured > 0 && configured < fits {
|
||||
return configured
|
||||
}
|
||||
return fits
|
||||
}
|
||||
|
||||
func blank(width, height int) string {
|
||||
if width <= 0 || height <= 0 {
|
||||
return ""
|
||||
}
|
||||
line := strings.Repeat(" ", width)
|
||||
lines := make([]string, height)
|
||||
for i := range lines {
|
||||
lines[i] = line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package drawer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func background(w, h int) string {
|
||||
return blank(w, h)
|
||||
}
|
||||
|
||||
func TestRenderLeftFlushToLeftEdge(t *testing.T) {
|
||||
m := New(WithMaxWidth(10))
|
||||
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"), WithSide(Left))})
|
||||
|
||||
out := m.Render(background(40, 10))
|
||||
lines := strings.Split(out, "\n")
|
||||
if len(lines) != 10 {
|
||||
t.Fatalf("expected 10 lines, got %d", len(lines))
|
||||
}
|
||||
if lipgloss.Width(out) == 0 {
|
||||
t.Fatalf("expected non-empty render")
|
||||
}
|
||||
|
||||
if len([]rune(lines[0])) == 0 {
|
||||
t.Fatalf("expected a rendered top border line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRightFlushToRightEdge(t *testing.T) {
|
||||
m := New(WithMaxWidth(10))
|
||||
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Inspector", Text("hi"), WithSide(Right))})
|
||||
|
||||
out := m.Render(background(40, 10))
|
||||
if lipgloss.Width(out) != 40 {
|
||||
t.Fatalf("expected full background width 40, got %d", lipgloss.Width(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpansFullBackgroundHeight(t *testing.T) {
|
||||
m := New()
|
||||
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"))})
|
||||
|
||||
out := m.Render(background(40, 12))
|
||||
if lipgloss.Height(out) != 12 {
|
||||
t.Fatalf("expected full height 12, got %d", lipgloss.Height(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedWidthHonored(t *testing.T) {
|
||||
m := New(WithMaxWidth(50))
|
||||
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("x"), WithWidth(20))})
|
||||
|
||||
box := m.renderBox(m.drawers[0], m.styles, 80, 10)
|
||||
if got := lipgloss.Width(box); got != 20 {
|
||||
t.Fatalf("expected fixed box width 20, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoDrawersReturnsBackgroundUnchanged(t *testing.T) {
|
||||
m := New()
|
||||
bg := background(10, 5)
|
||||
if got := m.Render(bg); got != bg {
|
||||
t.Fatalf("expected background unchanged when no drawer is open")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package drawer
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
type Styles struct {
|
||||
Border lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
Content lipgloss.Style
|
||||
DimColor color.Color
|
||||
}
|
||||
|
||||
func DefaultStyles() Styles {
|
||||
return Styles{
|
||||
Border: style.S.PanelFocused.Padding(0, 1),
|
||||
Title: lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary),
|
||||
Content: lipgloss.NewStyle().Foreground(style.S.Text),
|
||||
DimColor: style.S.Subtle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/bubbles"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
input textinput.Model
|
||||
spinner spinner.Model
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
ti := bubbles.NewTextInput()
|
||||
ti.Placeholder = "type something"
|
||||
ti.Focus()
|
||||
return model{input: ti, spinner: bubbles.NewSpinner()}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.spinner.Tick }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
var inputCmd, spinnerCmd tea.Cmd
|
||||
m.input, inputCmd = m.input.Update(msg)
|
||||
m.spinner, spinnerCmd = m.spinner.Update(msg)
|
||||
return m, tea.Batch(inputCmd, spinnerCmd)
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
return tea.NewView(lipgloss.JoinHorizontal(lipgloss.Center, m.spinner.View(), " ", m.input.View()))
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/drawer"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
d drawer.Model
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newModel() model { return model{d: drawer.New()} }
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.d.Init() }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
case "l":
|
||||
return m, drawer.Show("Nav", drawer.Text("Home\nSettings"), drawer.WithSide(drawer.Left))
|
||||
case "esc":
|
||||
if m.d.Open() {
|
||||
return m, drawer.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.d, cmd = m.d.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "l: open q: quit")
|
||||
view := tea.NewView(m.d.Render(background))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/helpbar"
|
||||
)
|
||||
|
||||
type keyMap struct {
|
||||
Inc, Help, Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeyMap() keyMap {
|
||||
return keyMap{
|
||||
Inc: key.NewBinding(key.WithKeys("+"), key.WithHelp("+", "increment")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
|
||||
}
|
||||
}
|
||||
|
||||
type model struct {
|
||||
help helpbar.Model
|
||||
keys keyMap
|
||||
n, w int
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
keys := defaultKeyMap()
|
||||
return model{
|
||||
keys: keys,
|
||||
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.Quit)),
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.w = msg.Width
|
||||
m.help.SetWidth(m.w)
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Inc):
|
||||
m.n++
|
||||
default:
|
||||
m.help, _ = m.help.Update(msg)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
bar := m.help.View(m.keys.Inc)
|
||||
body := lipgloss.Place(m.w, 1, lipgloss.Center, lipgloss.Top, fmt.Sprintf("count: %d", m.n))
|
||||
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, bar))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/modal"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
m modal.Model
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newModel() model { return model{m: modal.New()} }
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.m.Init() }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
case "o":
|
||||
return m, modal.Show("Hello", modal.Text("This is a modal.\n\nesc: close"))
|
||||
case "esc":
|
||||
if m.m.Open() {
|
||||
return m, modal.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.m, cmd = m.m.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "o: open q: quit")
|
||||
view := tea.NewView(m.m.Render(background))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/notification"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
notif notification.Model
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newModel() model { return model{notif: notification.New()} }
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.notif.Init() }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
case "s":
|
||||
return m, notification.Show("Saved", "Config written to disk.", notification.Success)
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.notif, cmd = m.notif.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "s: show q: quit")
|
||||
view := tea.NewView(m.notif.Render(background))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
type model struct{ focused bool }
|
||||
|
||||
func (m model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
case "tab":
|
||||
m.focused = !m.focused
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
box := style.S.Panel
|
||||
if m.focused {
|
||||
box = style.S.PanelFocused
|
||||
}
|
||||
return tea.NewView(style.RenderWithTitle(box, "Panel", "tab: toggle focus q: quit", 30, 5))
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(model{}).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/tabs"
|
||||
)
|
||||
|
||||
type pane string
|
||||
|
||||
func (p pane) Init() tea.Cmd { return nil }
|
||||
func (p pane) Update(tea.Msg) (tabs.Tab, tea.Cmd) { return p, nil }
|
||||
func (p pane) View() string { return string(p) }
|
||||
|
||||
type model struct{ tabs tabs.Model }
|
||||
|
||||
func newModel() model {
|
||||
items := []tabs.Item{
|
||||
{Title: "First", Model: pane("first content")},
|
||||
{Title: "Second", Model: pane("second content")},
|
||||
{Title: "Third", Model: pane("third content")},
|
||||
}
|
||||
return model{tabs: tabs.New(items)}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.tabs.Init() }
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "q" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if s, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.tabs.SetSize(s.Width, s.Height)
|
||||
msg = tea.WindowSizeMsg{Width: m.tabs.ContentWidth(), Height: m.tabs.ContentHeight()}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.tabs, cmd = m.tabs.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
view := tea.NewView(m.tabs.View())
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func main() {
|
||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "";
|
||||
description = "A shared Base16 theme, a themed wrapper around every official component, and a small set of custom Bubble Tea components, in one Go module, so every TUI built with it shares one config file and looks consistent.";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
@@ -4,19 +4,20 @@ go 1.25.8
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.2
|
||||
charm.land/glamour/v2 v2.0.0
|
||||
charm.land/lipgloss/v2 v2.0.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/bubbletea/v2 v2.0.2 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
@@ -24,12 +25,14 @@ require (
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
|
||||
@@ -22,6 +22,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
@@ -42,10 +44,14 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
@@ -56,6 +62,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# helpbar
|
||||
|
||||
A responsive help bar: one line of key bindings that expands, on toggle, into a multi-column view
|
||||
reflowed to use as many columns as the available width allows.
|
||||
|
||||
- Bindings render in order: the `WithToggle` binding first, then `WithGlobal` bindings (set once),
|
||||
then contextual bindings passed to `View` at render time.
|
||||
- Disabled bindings (`key.Binding.SetEnabled(false)`) are dropped before layout, so the reflow never
|
||||
budgets width for something that won't be drawn.
|
||||
- `Height(contextual...)` always matches `lipgloss.Height` of `View` for the same arguments, so a
|
||||
host can reserve exactly the right amount of space. An empty bar renders `""` and takes 0 rows.
|
||||
|
||||
See `examples/helpbar`.
|
||||
@@ -0,0 +1,118 @@
|
||||
package helpbar
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/bubbles"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
ShowAll bool
|
||||
|
||||
help help.Model
|
||||
global []key.Binding
|
||||
toggle key.Binding
|
||||
width int
|
||||
}
|
||||
|
||||
type Option func(*Model)
|
||||
|
||||
func WithGlobal(bindings ...key.Binding) Option {
|
||||
return func(m *Model) { m.global = bindings }
|
||||
}
|
||||
|
||||
func WithToggle(b key.Binding) Option {
|
||||
return func(m *Model) { m.toggle = b }
|
||||
}
|
||||
|
||||
func WithStyles(s help.Styles) Option {
|
||||
return func(m *Model) { m.help.Styles = s }
|
||||
}
|
||||
|
||||
func New(opts ...Option) Model {
|
||||
m := Model{help: bubbles.NewHelp()}
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Model) SetWidth(w int) {
|
||||
m.width = w
|
||||
m.help.SetWidth(w)
|
||||
}
|
||||
|
||||
func (m Model) Width() int { return m.width }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
if keyMsg, ok := msg.(tea.KeyPressMsg); ok && key.Matches(keyMsg, m.toggle) {
|
||||
m.ShowAll = !m.ShowAll
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View(contextual ...key.Binding) string {
|
||||
bindings := m.bindings(contextual)
|
||||
if len(bindings) == 0 || m.width <= 0 {
|
||||
return ""
|
||||
}
|
||||
if !m.ShowAll {
|
||||
return m.help.ShortHelpView(bindings)
|
||||
}
|
||||
return m.help.FullHelpView(m.columns(bindings))
|
||||
}
|
||||
|
||||
func (m Model) Height(contextual ...key.Binding) int {
|
||||
view := m.View(contextual...)
|
||||
if view == "" {
|
||||
return 0
|
||||
}
|
||||
return lipgloss.Height(view)
|
||||
}
|
||||
|
||||
func (m Model) bindings(contextual []key.Binding) []key.Binding {
|
||||
all := make([]key.Binding, 0, 1+len(m.global)+len(contextual))
|
||||
if m.toggle.Enabled() {
|
||||
all = append(all, m.toggle)
|
||||
}
|
||||
for _, b := range append(append([]key.Binding{}, m.global...), contextual...) {
|
||||
if b.Enabled() {
|
||||
all = append(all, b)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m Model) columns(bindings []key.Binding) [][]key.Binding {
|
||||
if m.width <= 0 {
|
||||
return [][]key.Binding{bindings}
|
||||
}
|
||||
for rows := 1; rows < len(bindings); rows++ {
|
||||
groups := chunkColumns(bindings, rows)
|
||||
if m.renderedWidth(groups) <= m.width {
|
||||
return groups
|
||||
}
|
||||
}
|
||||
|
||||
return chunkColumns(bindings, len(bindings))
|
||||
}
|
||||
|
||||
func (m Model) renderedWidth(groups [][]key.Binding) int {
|
||||
unbounded := m.help
|
||||
unbounded.SetWidth(0)
|
||||
return lipgloss.Width(unbounded.FullHelpView(groups))
|
||||
}
|
||||
|
||||
func chunkColumns(bindings []key.Binding, rows int) [][]key.Binding {
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
groups := make([][]key.Binding, 0, (len(bindings)+rows-1)/rows)
|
||||
for i := 0; i < len(bindings); i += rows {
|
||||
groups = append(groups, bindings[i:min(i+rows, len(bindings))])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
-115
@@ -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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# modal
|
||||
|
||||
A centered popup box on top of a dimmed background, triggered from anywhere in a bubbletea program
|
||||
via an exported `tea.Msg` (`ShowMsg`/`Show`), not a direct reference to the `Model` that renders it.
|
||||
Composites over an already-rendered string, so it makes no assumption about how the host builds
|
||||
that string.
|
||||
|
||||
- A modal's content is a `tea.Model`, not a string: it gets `Init`/`Update` while it's on top, and
|
||||
can report back with its own `tea.Msg` (see `modal.Text` for a plain, non-interactive content).
|
||||
- The stack is a plain LIFO with no identity. Only the topmost modal is updated; `Close()` always
|
||||
closes it.
|
||||
- Showing a second modal while one is open pushes it on top, nesting confirmations naturally.
|
||||
- `WithMaxWidth`/`WithMaxHeight` cap growth; a modal narrower than the cap shrinks to fit instead.
|
||||
|
||||
See `examples/modal`.
|
||||
@@ -0,0 +1,46 @@
|
||||
package modal
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
type Modal struct {
|
||||
Title string
|
||||
|
||||
Content tea.Model
|
||||
|
||||
Style *Styles
|
||||
}
|
||||
|
||||
type ModalOption func(*Modal)
|
||||
|
||||
func WithModalStyle(s Styles) ModalOption {
|
||||
return func(mo *Modal) { mo.Style = &s }
|
||||
}
|
||||
|
||||
func newModal(title string, content tea.Model, opts ...ModalOption) Modal {
|
||||
mo := Modal{Title: title, Content: content}
|
||||
for _, opt := range opts {
|
||||
opt(&mo)
|
||||
}
|
||||
return mo
|
||||
}
|
||||
|
||||
type ShowMsg struct{ Modal Modal }
|
||||
|
||||
func Show(title string, content tea.Model, opts ...ModalOption) tea.Cmd {
|
||||
mo := newModal(title, content, opts...)
|
||||
return func() tea.Msg { return ShowMsg{Modal: mo} }
|
||||
}
|
||||
|
||||
type DismissMsg struct{}
|
||||
|
||||
func Close() tea.Cmd {
|
||||
return func() tea.Msg { return DismissMsg{} }
|
||||
}
|
||||
|
||||
type text string
|
||||
|
||||
func (t text) Init() tea.Cmd { return nil }
|
||||
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
|
||||
func (t text) View() tea.View { return tea.NewView(string(t)) }
|
||||
|
||||
func Text(s string) tea.Model { return text(s) }
|
||||
@@ -0,0 +1,80 @@
|
||||
package modal
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
type Model struct {
|
||||
modals []Modal
|
||||
maxWidth int
|
||||
maxHeight int
|
||||
styles Styles
|
||||
}
|
||||
|
||||
type Option func(*Model)
|
||||
|
||||
func WithMaxWidth(w int) Option {
|
||||
return func(m *Model) { m.maxWidth = w }
|
||||
}
|
||||
|
||||
func WithMaxHeight(h int) Option {
|
||||
return func(m *Model) { m.maxHeight = h }
|
||||
}
|
||||
|
||||
func WithStyles(s Styles) Option {
|
||||
return func(m *Model) { m.styles = s }
|
||||
}
|
||||
|
||||
func New(opts ...Option) Model {
|
||||
m := Model{
|
||||
maxWidth: 60,
|
||||
maxHeight: 20,
|
||||
styles: DefaultStyles(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case ShowMsg:
|
||||
return m.show(msg.Modal)
|
||||
case DismissMsg:
|
||||
return m.pop(), nil
|
||||
}
|
||||
return m.updateTop(msg)
|
||||
}
|
||||
|
||||
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
|
||||
i := len(m.modals) - 1
|
||||
if i < 0 || m.modals[i].Content == nil {
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.modals[i].Content, cmd = m.modals[i].Content.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) Open() bool { return len(m.modals) > 0 }
|
||||
|
||||
func (m Model) show(mo Modal) (Model, tea.Cmd) {
|
||||
m.modals = append(m.modals, mo)
|
||||
return m, initContent(mo)
|
||||
}
|
||||
|
||||
func initContent(mo Modal) tea.Cmd {
|
||||
if mo.Content == nil {
|
||||
return nil
|
||||
}
|
||||
return mo.Content.Init()
|
||||
}
|
||||
|
||||
func (m Model) pop() Model {
|
||||
if len(m.modals) == 0 {
|
||||
return m
|
||||
}
|
||||
m.modals = m.modals[:len(m.modals)-1]
|
||||
return m
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package modal
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
const margin = 2
|
||||
|
||||
func (m Model) Render(background string) string {
|
||||
if len(m.modals) == 0 {
|
||||
return background
|
||||
}
|
||||
|
||||
result := background
|
||||
for _, mo := range m.modals {
|
||||
w, h := lipgloss.Width(result), lipgloss.Height(result)
|
||||
if w <= 0 || h <= 0 {
|
||||
return result
|
||||
}
|
||||
result = m.renderOne(mo, result, w, h)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m Model) View(width, height int) string {
|
||||
return m.Render(blank(width, height))
|
||||
}
|
||||
|
||||
func (m Model) renderOne(mo Modal, background string, w, h int) string {
|
||||
s := m.styles
|
||||
if mo.Style != nil {
|
||||
s = *mo.Style
|
||||
}
|
||||
|
||||
box := m.renderBox(mo, s, w, h)
|
||||
bw, bh := lipgloss.Width(box), lipgloss.Height(box)
|
||||
x, y := max((w-bw)/2, 0), max((h-bh)/2, 0)
|
||||
|
||||
compositor := lipgloss.NewCompositor(
|
||||
lipgloss.NewLayer(dim(background, s.DimColor)),
|
||||
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
|
||||
)
|
||||
return compositor.Render()
|
||||
}
|
||||
|
||||
func dim(s string, c color.Color) string {
|
||||
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
|
||||
}
|
||||
|
||||
func (m Model) renderBox(mo Modal, s Styles, bgW, bgH int) string {
|
||||
maxW := effectiveMax(m.maxWidth, bgW-2*margin)
|
||||
maxH := effectiveMax(m.maxHeight, bgH-2*margin)
|
||||
|
||||
body := contentView(mo)
|
||||
inner := contentWidth(body, mo.Title, maxW)
|
||||
content := s.Content.Width(inner).Render(body)
|
||||
|
||||
boxWidth := inner + 4
|
||||
boxHeight := min(lipgloss.Height(content)+2, maxH)
|
||||
|
||||
return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight)
|
||||
}
|
||||
|
||||
func contentView(mo Modal) string {
|
||||
if mo.Content == nil {
|
||||
return ""
|
||||
}
|
||||
return mo.Content.View().Content
|
||||
}
|
||||
|
||||
func contentWidth(body, title string, maxWidth int) int {
|
||||
natural := max(naturalWidth(body), lipgloss.Width(title), 1)
|
||||
capped := max(maxWidth-4, 1)
|
||||
return min(natural, capped)
|
||||
}
|
||||
|
||||
func naturalWidth(content string) int {
|
||||
w := 0
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if lw := lipgloss.Width(line); lw > w {
|
||||
w = lw
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func effectiveMax(configured, fits int) int {
|
||||
if fits < 1 {
|
||||
fits = 1
|
||||
}
|
||||
if configured > 0 && configured < fits {
|
||||
return configured
|
||||
}
|
||||
return fits
|
||||
}
|
||||
|
||||
func blank(width, height int) string {
|
||||
if width <= 0 || height <= 0 {
|
||||
return ""
|
||||
}
|
||||
line := strings.Repeat(" ", width)
|
||||
lines := make([]string, height)
|
||||
for i := range lines {
|
||||
lines[i] = line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package modal
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
type Styles struct {
|
||||
Border lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
Content lipgloss.Style
|
||||
DimColor color.Color
|
||||
}
|
||||
|
||||
func DefaultStyles() Styles {
|
||||
return Styles{
|
||||
Border: style.S.PanelFocused.Padding(0, 1),
|
||||
Title: lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary),
|
||||
Content: lipgloss.NewStyle().Foreground(style.S.Text),
|
||||
DimColor: style.S.Subtle,
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@
|
||||
# notification
|
||||
|
||||
Toast notifications, triggered from anywhere in a bubbletea program via an exported `tea.Msg`
|
||||
(`ShowMsg`/`Show`), not a direct reference to the `Model` that renders them. Composites over an
|
||||
already-rendered string, so it makes no assumption about how the host builds that string.
|
||||
|
||||
- Four kinds: `Info`, `Success`, `Warning`, `Error`, each with its own `style.S` color preset.
|
||||
- Auto-dismisses after `DefaultDuration` (3s) unless shown with `WithDuration(0)`, which makes it
|
||||
sticky; a sticky toast needs `WithID` so `Dismiss(id)` can remove it later.
|
||||
- Six anchors (`Top`, `TopLeft`, `TopRight`, `Bottom`, `BottomLeft`, `BottomRight`). Toasts stack
|
||||
along the anchored edge, newest closest to it.
|
||||
- `WithMaxWidth` caps growth; a toast narrower than the cap shrinks to fit instead.
|
||||
|
||||
See `examples/notification`.
|
||||
@@ -0,0 +1,93 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
toasts []Toast
|
||||
nextID int
|
||||
position Position
|
||||
maxWidth int
|
||||
styles Styles
|
||||
}
|
||||
|
||||
type Option func(*Model)
|
||||
|
||||
func WithPosition(p Position) Option {
|
||||
return func(m *Model) { m.position = p }
|
||||
}
|
||||
|
||||
func WithMaxWidth(w int) Option {
|
||||
return func(m *Model) { m.maxWidth = w }
|
||||
}
|
||||
|
||||
func WithStyles(s Styles) Option {
|
||||
return func(m *Model) { m.styles = s }
|
||||
}
|
||||
|
||||
func New(opts ...Option) Model {
|
||||
m := Model{
|
||||
position: TopRight,
|
||||
maxWidth: 40,
|
||||
styles: DefaultStyles(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case ShowMsg:
|
||||
return m.show(msg.Toast)
|
||||
case DismissMsg:
|
||||
return m.remove(msg.ID), nil
|
||||
case expireMsg:
|
||||
return m.remove(msg.id), nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) show(t Toast) (Model, tea.Cmd) {
|
||||
if t.ID == "" {
|
||||
t.ID = fmt.Sprintf("toast-%d", m.nextID)
|
||||
m.nextID++
|
||||
}
|
||||
|
||||
replaced := false
|
||||
for i, existing := range m.toasts {
|
||||
if existing.ID == t.ID {
|
||||
m.toasts[i] = t
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
m.toasts = append(m.toasts, t)
|
||||
}
|
||||
|
||||
if t.Duration <= 0 {
|
||||
return m, nil
|
||||
}
|
||||
id := t.ID
|
||||
return m, tea.Tick(t.Duration, func(time.Time) tea.Msg {
|
||||
return expireMsg{id: id}
|
||||
})
|
||||
}
|
||||
|
||||
func (m Model) remove(id string) Model {
|
||||
for i, t := range m.toasts {
|
||||
if t.ID == id {
|
||||
m.toasts = append(m.toasts[:i], m.toasts[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package notification
|
||||
|
||||
type Position int
|
||||
|
||||
const (
|
||||
Top Position = iota
|
||||
TopLeft
|
||||
TopRight
|
||||
Bottom
|
||||
BottomLeft
|
||||
BottomRight
|
||||
)
|
||||
|
||||
const margin = 1
|
||||
|
||||
func placement(pos Position, w, h, sw, sh int) (x, y int) {
|
||||
switch pos {
|
||||
case Top:
|
||||
x = (w - sw) / 2
|
||||
y = margin
|
||||
case TopLeft:
|
||||
x = margin
|
||||
y = margin
|
||||
case TopRight:
|
||||
x = w - sw - margin
|
||||
y = margin
|
||||
case Bottom:
|
||||
x = (w - sw) / 2
|
||||
y = h - sh - margin
|
||||
case BottomLeft:
|
||||
x = margin
|
||||
y = h - sh - margin
|
||||
case BottomRight:
|
||||
x = w - sw - margin
|
||||
y = h - sh - margin
|
||||
}
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
}
|
||||
return x, y
|
||||
}
|
||||
|
||||
func (pos Position) anchoredTop() bool {
|
||||
return pos == Top || pos == TopLeft || pos == TopRight
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
func (m Model) Render(background string) string {
|
||||
if len(m.toasts) == 0 {
|
||||
return background
|
||||
}
|
||||
w, h := lipgloss.Width(background), lipgloss.Height(background)
|
||||
if w <= 0 || h <= 0 {
|
||||
return background
|
||||
}
|
||||
|
||||
stack := clipToHeight(m.renderStack(effectiveMaxWidth(m.maxWidth, w)), h-2*margin, m.position.anchoredTop())
|
||||
if stack == "" {
|
||||
return background
|
||||
}
|
||||
sw, sh := lipgloss.Width(stack), lipgloss.Height(stack)
|
||||
x, y := placement(m.position, w, h, sw, sh)
|
||||
|
||||
compositor := lipgloss.NewCompositor(
|
||||
lipgloss.NewLayer(background),
|
||||
lipgloss.NewLayer(stack).X(x).Y(y).Z(1),
|
||||
)
|
||||
return compositor.Render()
|
||||
}
|
||||
|
||||
func (m Model) View(width, height int) string {
|
||||
return m.Render(blank(width, height))
|
||||
}
|
||||
|
||||
func (m Model) renderStack(maxWidth int) string {
|
||||
ordered := m.orderedToasts()
|
||||
parts := make([]string, 0, len(ordered)*2-1)
|
||||
for i, t := range ordered {
|
||||
if i > 0 {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
parts = append(parts, m.renderToast(t, maxWidth))
|
||||
}
|
||||
return lipgloss.JoinVertical(stackAlign(m.position), parts...)
|
||||
}
|
||||
|
||||
func (m Model) orderedToasts() []Toast {
|
||||
if !m.position.anchoredTop() {
|
||||
return m.toasts
|
||||
}
|
||||
ordered := make([]Toast, len(m.toasts))
|
||||
for i, t := range m.toasts {
|
||||
ordered[len(m.toasts)-1-i] = t
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func stackAlign(pos Position) lipgloss.Position {
|
||||
switch pos {
|
||||
case TopLeft, BottomLeft:
|
||||
return lipgloss.Left
|
||||
case TopRight, BottomRight:
|
||||
return lipgloss.Right
|
||||
default:
|
||||
return lipgloss.Center
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderToast(t Toast, maxWidth int) string {
|
||||
k := m.styles.forKind(t)
|
||||
|
||||
inner := contentWidth(t, maxWidth)
|
||||
message := k.Message.Width(inner).Render(t.Message)
|
||||
|
||||
boxWidth := inner + 4
|
||||
boxHeight := lipgloss.Height(message) + 2
|
||||
|
||||
return style.RenderWithTitle(k.Border, k.Title.Render(t.Title), message, boxWidth, boxHeight)
|
||||
}
|
||||
|
||||
func contentWidth(t Toast, maxWidth int) int {
|
||||
natural := max(lipgloss.Width(t.Title), lipgloss.Width(t.Message), 1)
|
||||
if maxWidth <= 0 {
|
||||
return natural
|
||||
}
|
||||
capped := max(maxWidth-4, 1)
|
||||
return min(natural, capped)
|
||||
}
|
||||
|
||||
func effectiveMaxWidth(configured, bgWidth int) int {
|
||||
fits := max(bgWidth-2*margin, 1)
|
||||
if configured > 0 && configured < fits {
|
||||
return configured
|
||||
}
|
||||
return fits
|
||||
}
|
||||
|
||||
func clipToHeight(stack string, maxHeight int, anchoredTop bool) string {
|
||||
lines := strings.Split(stack, "\n")
|
||||
if len(lines) <= maxHeight {
|
||||
return stack
|
||||
}
|
||||
if maxHeight <= 0 {
|
||||
return ""
|
||||
}
|
||||
if anchoredTop {
|
||||
lines = lines[:maxHeight]
|
||||
} else {
|
||||
lines = lines[len(lines)-maxHeight:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func blank(width, height int) string {
|
||||
if width <= 0 || height <= 0 {
|
||||
return ""
|
||||
}
|
||||
line := strings.Repeat(" ", width)
|
||||
lines := make([]string, height)
|
||||
for i := range lines {
|
||||
lines[i] = line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
type KindStyle struct {
|
||||
Border lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
Message lipgloss.Style
|
||||
}
|
||||
|
||||
type Styles struct {
|
||||
Info KindStyle
|
||||
Success KindStyle
|
||||
Warning KindStyle
|
||||
Error KindStyle
|
||||
}
|
||||
|
||||
func DefaultStyles() Styles {
|
||||
return Styles{
|
||||
Info: kindStyle(style.S.Primary),
|
||||
Success: kindStyle(style.S.Success),
|
||||
Warning: kindStyle(style.S.Warning),
|
||||
Error: kindStyle(style.S.Error),
|
||||
}
|
||||
}
|
||||
|
||||
func kindStyle(c color.Color) KindStyle {
|
||||
return KindStyle{
|
||||
Border: lipgloss.NewStyle().
|
||||
Border(style.S.BorderType).
|
||||
BorderForeground(c).
|
||||
Padding(0, 1),
|
||||
Title: lipgloss.NewStyle().Bold(true).Foreground(c),
|
||||
Message: lipgloss.NewStyle().Foreground(style.S.Text),
|
||||
}
|
||||
}
|
||||
|
||||
func (s Styles) forKind(t Toast) KindStyle {
|
||||
if t.Style != nil {
|
||||
return *t.Style
|
||||
}
|
||||
switch t.Kind {
|
||||
case Success:
|
||||
return s.Success
|
||||
case Warning:
|
||||
return s.Warning
|
||||
case Error:
|
||||
return s.Error
|
||||
default:
|
||||
return s.Info
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
Info Kind = iota
|
||||
Success
|
||||
Warning
|
||||
Error
|
||||
)
|
||||
|
||||
const DefaultDuration = 3 * time.Second
|
||||
|
||||
type Toast struct {
|
||||
ID string
|
||||
Title string
|
||||
Message string
|
||||
Kind Kind
|
||||
|
||||
Duration time.Duration
|
||||
|
||||
Style *KindStyle
|
||||
}
|
||||
|
||||
type ToastOption func(*Toast)
|
||||
|
||||
func WithID(id string) ToastOption {
|
||||
return func(t *Toast) { t.ID = id }
|
||||
}
|
||||
|
||||
func WithDuration(d time.Duration) ToastOption {
|
||||
return func(t *Toast) { t.Duration = d }
|
||||
}
|
||||
|
||||
func WithToastStyle(s KindStyle) ToastOption {
|
||||
return func(t *Toast) { t.Style = &s }
|
||||
}
|
||||
|
||||
func newToast(title, message string, kind Kind, opts ...ToastOption) Toast {
|
||||
t := Toast{
|
||||
Title: title,
|
||||
Message: message,
|
||||
Kind: kind,
|
||||
Duration: DefaultDuration,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&t)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
type ShowMsg struct{ Toast Toast }
|
||||
|
||||
func Show(title, message string, kind Kind, opts ...ToastOption) tea.Cmd {
|
||||
t := newToast(title, message, kind, opts...)
|
||||
return func() tea.Msg { return ShowMsg{Toast: t} }
|
||||
}
|
||||
|
||||
type DismissMsg struct{ ID string }
|
||||
|
||||
func Dismiss(id string) tea.Cmd {
|
||||
return func() tea.Msg { return DismissMsg{ID: id} }
|
||||
}
|
||||
|
||||
type expireMsg struct{ id string }
|
||||
@@ -0,0 +1,18 @@
|
||||
# style
|
||||
|
||||
Shared Base16 theming for bubbletea/lipgloss TUIs. Loaded automatically on import from
|
||||
`~/.config/ilovetui/config.yaml` (embedded default as fallback), exposed as the package-level
|
||||
`style.S`.
|
||||
|
||||
- `S.NerdFonts` and `S.BorderType` come from the same config as the colors. This package has no
|
||||
icon registry: components that want icons read `NerdFonts` and pick their own glyphs.
|
||||
- `RenderWithTitle` renders a bordered box with a title embedded in the top border, following
|
||||
`S.BorderType`.
|
||||
- Never imports `charm.land/bubbles/v2/*`. Anything that needs to know about a specific component
|
||||
belongs in `bubbles/` instead.
|
||||
|
||||
## Config
|
||||
|
||||
Copy [`default.yaml`](default.yaml) to `~/.config/ilovetui/config.yaml` and edit it.
|
||||
|
||||
See `examples/style`.
|
||||
+23
-10
@@ -1,4 +1,4 @@
|
||||
package ilovetui
|
||||
package style
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -6,7 +6,22 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// ContentHeight returns the usable inner height for a bordered panel of totalH rows.
|
||||
var borderTypes = map[string]lipgloss.Border{
|
||||
"rounded": lipgloss.RoundedBorder(),
|
||||
"normal": lipgloss.NormalBorder(),
|
||||
"thick": lipgloss.ThickBorder(),
|
||||
"double": lipgloss.DoubleBorder(),
|
||||
"hidden": lipgloss.HiddenBorder(),
|
||||
"ascii": lipgloss.ASCIIBorder(),
|
||||
}
|
||||
|
||||
func resolveBorderType(name string) lipgloss.Border {
|
||||
if b, ok := borderTypes[strings.ToLower(strings.TrimSpace(name))]; ok {
|
||||
return b
|
||||
}
|
||||
return lipgloss.RoundedBorder()
|
||||
}
|
||||
|
||||
func ContentHeight(totalH int) int {
|
||||
h := totalH - 2
|
||||
if h < 0 {
|
||||
@@ -15,12 +30,6 @@ func ContentHeight(totalH int) int {
|
||||
return h
|
||||
}
|
||||
|
||||
// RenderWithTitle renders a bordered box with a title embedded in the top border.
|
||||
// title may contain ANSI color codes. width and height are the total outer dimensions.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// box := ilovetui.RenderWithTitle(theme.Styles.PanelFocused, "Header", content, w, h)
|
||||
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
|
||||
boxH := height - 1
|
||||
if contentH := boxH - 1; contentH > 0 {
|
||||
@@ -33,12 +42,16 @@ func RenderWithTitle(border lipgloss.Style, title, content string, width, height
|
||||
|
||||
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
|
||||
titleW := lipgloss.Width(title)
|
||||
fillW := boxWidth - titleW - 4 // 4 = "╭ " + " " + "╮"
|
||||
|
||||
b, _, _, _, _ := border.GetBorder()
|
||||
topLeft, top, topRight := b.TopLeft, b.Top, b.TopRight
|
||||
|
||||
fillW := boxWidth - titleW - lipgloss.Width(topLeft) - lipgloss.Width(topRight) - 2
|
||||
if fillW < 0 {
|
||||
fillW = 0
|
||||
}
|
||||
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
|
||||
topLine := bc.Render("╭ ") + bc.Render(title) + bc.Render(" "+strings.Repeat("─", fillW)+"╮")
|
||||
topLine := bc.Render(topLeft+" ") + bc.Render(title) + bc.Render(" "+strings.Repeat(top, fillW)+topRight)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package style
|
||||
|
||||
type colorsYAML struct {
|
||||
Base00 string `yaml:"base00"`
|
||||
Base01 string `yaml:"base01"`
|
||||
Base02 string `yaml:"base02"`
|
||||
Base03 string `yaml:"base03"`
|
||||
Base04 string `yaml:"base04"`
|
||||
Base05 string `yaml:"base05"`
|
||||
Base06 string `yaml:"base06"`
|
||||
Base07 string `yaml:"base07"`
|
||||
Base08 string `yaml:"base08"`
|
||||
Base09 string `yaml:"base09"`
|
||||
Base0A string `yaml:"base0a"`
|
||||
Base0B string `yaml:"base0b"`
|
||||
Base0C string `yaml:"base0c"`
|
||||
Base0D string `yaml:"base0d"`
|
||||
Base0E string `yaml:"base0e"`
|
||||
Base0F string `yaml:"base0f"`
|
||||
}
|
||||
|
||||
type configYAML struct {
|
||||
Colors colorsYAML `yaml:"colors"`
|
||||
NerdFonts bool `yaml:"nerd_fonts"`
|
||||
Border string `yaml:"border"`
|
||||
}
|
||||
|
||||
func pickString(base, user string) string {
|
||||
if user != "" {
|
||||
return user
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func mergeConfig(base, user configYAML) configYAML {
|
||||
return configYAML{
|
||||
Colors: mergeColors(base.Colors, user.Colors),
|
||||
|
||||
NerdFonts: base.NerdFonts || user.NerdFonts,
|
||||
Border: pickString(base.Border, user.Border),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeColors(base, user colorsYAML) colorsYAML {
|
||||
return colorsYAML{
|
||||
Base00: pickString(base.Base00, user.Base00),
|
||||
Base01: pickString(base.Base01, user.Base01),
|
||||
Base02: pickString(base.Base02, user.Base02),
|
||||
Base03: pickString(base.Base03, user.Base03),
|
||||
Base04: pickString(base.Base04, user.Base04),
|
||||
Base05: pickString(base.Base05, user.Base05),
|
||||
Base06: pickString(base.Base06, user.Base06),
|
||||
Base07: pickString(base.Base07, user.Base07),
|
||||
Base08: pickString(base.Base08, user.Base08),
|
||||
Base09: pickString(base.Base09, user.Base09),
|
||||
Base0A: pickString(base.Base0A, user.Base0A),
|
||||
Base0B: pickString(base.Base0B, user.Base0B),
|
||||
Base0C: pickString(base.Base0C, user.Base0C),
|
||||
Base0D: pickString(base.Base0D, user.Base0D),
|
||||
Base0E: pickString(base.Base0E, user.Base0E),
|
||||
Base0F: pickString(base.Base0F, user.Base0F),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
# ilovetui default theme
|
||||
# ilovetui default config
|
||||
# Copy to ~/.config/ilovetui/config.yaml and edit to customize.
|
||||
|
||||
# Whether components may use Nerd Font glyphs (requires a patched font).
|
||||
# Leave false for maximum terminal/font compatibility.
|
||||
nerd_fonts: false
|
||||
|
||||
# Border style used by panels across all ilovetui-based TUIs.
|
||||
# One of: rounded, normal, thick, double, hidden, ascii.
|
||||
border: rounded
|
||||
|
||||
colors:
|
||||
base00: "#110F12" # Background
|
||||
base01: "#1C1920" # Lighter Background / Status Bars
|
||||
@@ -1,4 +1,4 @@
|
||||
package ilovetui
|
||||
package style
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -13,7 +13,6 @@ func hexColor(c color.Color) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// GlamourStyleConfig returns a glamour ansi.StyleConfig using the active theme.
|
||||
func GlamourStyleConfig() ansi.StyleConfig {
|
||||
str := func(s string) *string { return &s }
|
||||
boolPtr := func(b bool) *bool { return &b }
|
||||
+12
-11
@@ -1,4 +1,4 @@
|
||||
package ilovetui
|
||||
package style
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
@@ -7,10 +7,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// Styles holds both the raw Base16 palette and ready-to-use semantic colors
|
||||
// and lipgloss styles. Access via the package-level variable S.
|
||||
type Styles struct {
|
||||
// Raw Base16 palette
|
||||
Base00 color.Color
|
||||
Base01 color.Color
|
||||
Base02 color.Color
|
||||
@@ -28,7 +25,6 @@ type Styles struct {
|
||||
Base0E color.Color
|
||||
Base0F color.Color
|
||||
|
||||
// Semantic color aliases
|
||||
Background color.Color
|
||||
SubtleBg color.Color
|
||||
Selection color.Color
|
||||
@@ -40,20 +36,20 @@ type Styles struct {
|
||||
Warning color.Color
|
||||
Error color.Color
|
||||
|
||||
// Pre-built text styles
|
||||
Bold lipgloss.Style
|
||||
Faint lipgloss.Style
|
||||
|
||||
// Pre-built panel styles (rounded border)
|
||||
NerdFonts bool
|
||||
BorderType lipgloss.Border
|
||||
|
||||
Panel lipgloss.Style
|
||||
PanelFocused lipgloss.Style
|
||||
|
||||
// Pre-rendered pager dot strings
|
||||
PagerDotActive string
|
||||
PagerDotInactive string
|
||||
}
|
||||
|
||||
func newStyles(c colorsYAML) Styles {
|
||||
func newStyles(c colorsYAML, nerdFonts bool, borderName string) Styles {
|
||||
lc := func(s string) color.Color {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" && s[0] != '#' {
|
||||
@@ -79,6 +75,8 @@ func newStyles(c colorsYAML) Styles {
|
||||
b0E := lc(c.Base0E)
|
||||
b0F := lc(c.Base0F)
|
||||
|
||||
borderType := resolveBorderType(borderName)
|
||||
|
||||
return Styles{
|
||||
Base00: b00, Base01: b01, Base02: b02, Base03: b03,
|
||||
Base04: b04, Base05: b05, Base06: b06, Base07: b07,
|
||||
@@ -99,12 +97,15 @@ func newStyles(c colorsYAML) Styles {
|
||||
Bold: lipgloss.NewStyle().Bold(true),
|
||||
Faint: lipgloss.NewStyle().Foreground(b03).Faint(true),
|
||||
|
||||
NerdFonts: nerdFonts,
|
||||
BorderType: borderType,
|
||||
|
||||
Panel: lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
Border(borderType).
|
||||
BorderForeground(b03),
|
||||
|
||||
PanelFocused: lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
Border(borderType).
|
||||
BorderForeground(b0D),
|
||||
|
||||
PagerDotActive: lipgloss.NewStyle().Foreground(b0D).SetString("•").String(),
|
||||
@@ -0,0 +1,84 @@
|
||||
package style
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed default.yaml
|
||||
var DefaultConfig []byte
|
||||
|
||||
var S Styles
|
||||
|
||||
func init() {
|
||||
path := DefaultConfigPath()
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if s, err := stylesFromBytes(data); err == nil {
|
||||
S = s
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s, _ := stylesFromBytes(DefaultConfig)
|
||||
S = s
|
||||
}
|
||||
|
||||
func Init() error {
|
||||
path := DefaultConfigPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
s, e := stylesFromBytes(DefaultConfig)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
S = s
|
||||
return nil
|
||||
}
|
||||
return InitFromBytes(data)
|
||||
}
|
||||
|
||||
func InitFrom(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("style: read config: %w", err)
|
||||
}
|
||||
return InitFromBytes(data)
|
||||
}
|
||||
|
||||
func InitFromBytes(data []byte) error {
|
||||
s, err := stylesFromBytes(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
S = s
|
||||
return nil
|
||||
}
|
||||
|
||||
func DefaultConfigPath() string {
|
||||
return filepath.Join(configDir(), "ilovetui", "config.yaml")
|
||||
}
|
||||
|
||||
func stylesFromBytes(data []byte) (Styles, error) {
|
||||
var base configYAML
|
||||
if err := yaml.Unmarshal(DefaultConfig, &base); err != nil {
|
||||
return Styles{}, fmt.Errorf("style: parse default config: %w", err)
|
||||
}
|
||||
var user configYAML
|
||||
if err := yaml.Unmarshal(data, &user); err != nil {
|
||||
return Styles{}, fmt.Errorf("style: parse config: %w", err)
|
||||
}
|
||||
merged := mergeConfig(base, user)
|
||||
return newStyles(merged.Colors, merged.NerdFonts, merged.Border), nil
|
||||
}
|
||||
|
||||
func configDir() string {
|
||||
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config")
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# tabs
|
||||
|
||||
A horizontal tab bar styled from `style.S`. Renders the bar and the `Content` frame around the
|
||||
active item; the content itself runs and renders through the host, same as any other custom
|
||||
component in this repo.
|
||||
|
||||
- `Focused()` controls the frame's border color, independent of which tab is active (that's shown
|
||||
only by the title style).
|
||||
- Tabs collapse into a trailing `+N` badge when they don't fit `Width`, keeping the active one
|
||||
always visible.
|
||||
- `WithLoop(false)` clamps navigation at either end instead of wrapping.
|
||||
- The frame follows `style.S.BorderType`: corners and junctions are derived from the border's own
|
||||
glyphs, not hardcoded.
|
||||
|
||||
See `examples/tabs`.
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
package tabs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
type Tab interface {
|
||||
Init() tea.Cmd
|
||||
Update(tea.Msg) (Tab, tea.Cmd)
|
||||
View() string
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Title string
|
||||
Model Tab
|
||||
}
|
||||
|
||||
type KeyMap struct {
|
||||
Next key.Binding
|
||||
Prev key.Binding
|
||||
}
|
||||
|
||||
func DefaultKeyMap() KeyMap {
|
||||
return KeyMap{
|
||||
Next: key.NewBinding(
|
||||
key.WithKeys("right", "l", "tab"),
|
||||
key.WithHelp("→/tab", "next tab"),
|
||||
),
|
||||
Prev: key.NewBinding(
|
||||
key.WithKeys("left", "h", "shift+tab"),
|
||||
key.WithHelp("←/shift+tab", "previous tab"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type Styles struct {
|
||||
ActiveTab lipgloss.Style
|
||||
InactiveTab lipgloss.Style
|
||||
|
||||
ActiveTitle lipgloss.Style
|
||||
InactiveTitle lipgloss.Style
|
||||
|
||||
Content lipgloss.Style
|
||||
|
||||
BorderType lipgloss.Border
|
||||
|
||||
FocusedBorder color.Color
|
||||
BlurredBorder color.Color
|
||||
}
|
||||
|
||||
func DefaultStyles() Styles {
|
||||
bt := style.S.BorderType
|
||||
inactiveBorder, activeBorder := tabBorders(bt)
|
||||
|
||||
return Styles{
|
||||
InactiveTab: lipgloss.NewStyle().
|
||||
Border(inactiveBorder, true).
|
||||
Padding(0, 1),
|
||||
ActiveTab: lipgloss.NewStyle().
|
||||
Border(activeBorder, true).
|
||||
Padding(0, 1),
|
||||
|
||||
ActiveTitle: lipgloss.NewStyle().Foreground(style.S.Primary).Bold(true),
|
||||
InactiveTitle: lipgloss.NewStyle().Foreground(style.S.Subtle),
|
||||
|
||||
Content: lipgloss.NewStyle().
|
||||
Border(bt).
|
||||
UnsetBorderTop().
|
||||
Padding(1, 2),
|
||||
|
||||
BorderType: bt,
|
||||
|
||||
FocusedBorder: style.S.Primary,
|
||||
BlurredBorder: style.S.Subtle,
|
||||
}
|
||||
}
|
||||
|
||||
func tabBorders(bt lipgloss.Border) (inactive, active lipgloss.Border) {
|
||||
inactive = bt
|
||||
inactive.BottomLeft = bt.MiddleBottom
|
||||
inactive.BottomRight = bt.MiddleBottom
|
||||
|
||||
active = bt
|
||||
active.Bottom = " "
|
||||
active.BottomLeft = bt.BottomRight
|
||||
active.BottomRight = bt.BottomLeft
|
||||
|
||||
return inactive, active
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
items []Item
|
||||
active int
|
||||
focused bool
|
||||
loop bool
|
||||
width int
|
||||
height int
|
||||
|
||||
styles Styles
|
||||
keyMap KeyMap
|
||||
}
|
||||
|
||||
type Option func(*Model)
|
||||
|
||||
func WithStyles(s Styles) Option {
|
||||
return func(m *Model) {
|
||||
m.styles = s
|
||||
}
|
||||
}
|
||||
|
||||
func WithKeyMap(k KeyMap) Option {
|
||||
return func(m *Model) {
|
||||
m.keyMap = k
|
||||
}
|
||||
}
|
||||
|
||||
func WithActive(i int) Option {
|
||||
return func(m *Model) {
|
||||
m.active = i
|
||||
}
|
||||
}
|
||||
|
||||
func WithLoop(l bool) Option {
|
||||
return func(m *Model) {
|
||||
m.loop = l
|
||||
}
|
||||
}
|
||||
|
||||
func WithFocus(f bool) Option {
|
||||
return func(m *Model) {
|
||||
m.focused = f
|
||||
}
|
||||
}
|
||||
|
||||
func New(items []Item, opts ...Option) Model {
|
||||
m := Model{
|
||||
items: items,
|
||||
focused: true,
|
||||
loop: true,
|
||||
styles: DefaultStyles(),
|
||||
keyMap: DefaultKeyMap(),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
|
||||
m.active = clamp(m.active, 0, len(m.items)-1)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
cmds := make([]tea.Cmd, len(m.items))
|
||||
for i, item := range m.items {
|
||||
cmds[i] = item.Model.Init()
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m Model) Active() int {
|
||||
return m.active
|
||||
}
|
||||
|
||||
func (m *Model) SetActive(i int) {
|
||||
m.active = clamp(i, 0, len(m.items)-1)
|
||||
}
|
||||
|
||||
func (m Model) Items() []Item {
|
||||
return m.items
|
||||
}
|
||||
|
||||
func (m Model) ActiveItem() Item {
|
||||
return m.items[m.active]
|
||||
}
|
||||
|
||||
func (m Model) Focused() bool {
|
||||
return m.focused
|
||||
}
|
||||
|
||||
func (m *Model) Focus() {
|
||||
m.focused = true
|
||||
}
|
||||
|
||||
func (m *Model) Blur() {
|
||||
m.focused = false
|
||||
}
|
||||
|
||||
func (m Model) Loop() bool {
|
||||
return m.loop
|
||||
}
|
||||
|
||||
func (m *Model) SetLoop(l bool) {
|
||||
m.loop = l
|
||||
}
|
||||
|
||||
func (m Model) Width() int {
|
||||
return m.width
|
||||
}
|
||||
|
||||
func (m *Model) SetWidth(w int) {
|
||||
m.width = w
|
||||
}
|
||||
|
||||
func (m Model) Height() int {
|
||||
return m.height
|
||||
}
|
||||
|
||||
func (m *Model) SetHeight(h int) {
|
||||
m.height = h
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
}
|
||||
|
||||
func (m Model) ContentWidth() int {
|
||||
if len(m.items) == 0 {
|
||||
return 0
|
||||
}
|
||||
w := lipgloss.Width(m.renderBar(m.collapsedSegments(m.width), m.styles.BlurredBorder, false))
|
||||
if m.width > w {
|
||||
w = m.width
|
||||
}
|
||||
if inner := w - m.styles.Content.GetHorizontalFrameSize(); inner > 0 {
|
||||
return inner
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m Model) ContentHeight() int {
|
||||
if len(m.items) == 0 || m.height <= 0 {
|
||||
return 0
|
||||
}
|
||||
barHeight := lipgloss.Height(m.renderBar(m.collapsedSegments(m.width), m.styles.BlurredBorder, false))
|
||||
if inner := m.height - barHeight - m.styles.Content.GetVerticalFrameSize(); inner > 0 {
|
||||
return inner
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
|
||||
switch {
|
||||
case key.Matches(keyMsg, m.keyMap.Next):
|
||||
m.active = m.step(1)
|
||||
return m, nil
|
||||
case key.Matches(keyMsg, m.keyMap.Prev):
|
||||
m.active = m.step(-1)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(m.items) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.items[m.active].Model, cmd = m.items[m.active].Model.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
type tabSegment struct {
|
||||
title string
|
||||
isActive bool
|
||||
isMore bool
|
||||
}
|
||||
|
||||
func (m Model) segments() []tabSegment {
|
||||
segs := make([]tabSegment, len(m.items))
|
||||
for i, item := range m.items {
|
||||
segs[i] = tabSegment{title: item.Title, isActive: i == m.active}
|
||||
}
|
||||
return segs
|
||||
}
|
||||
|
||||
func (m Model) segmentWidth(seg tabSegment) int {
|
||||
tabStyle := m.styles.InactiveTab
|
||||
titleStyle := m.styles.InactiveTitle
|
||||
if seg.isActive {
|
||||
tabStyle = m.styles.ActiveTab
|
||||
titleStyle = m.styles.ActiveTitle
|
||||
}
|
||||
return lipgloss.Width(tabStyle.Render(titleStyle.Render(seg.title)))
|
||||
}
|
||||
|
||||
func (m Model) collapsedSegments(budget int) []tabSegment {
|
||||
segs := m.segments()
|
||||
if budget <= 0 {
|
||||
return segs
|
||||
}
|
||||
|
||||
widths := make([]int, len(segs))
|
||||
total := 0
|
||||
for i, seg := range segs {
|
||||
widths[i] = m.segmentWidth(seg)
|
||||
total += widths[i]
|
||||
}
|
||||
if total <= budget {
|
||||
return segs
|
||||
}
|
||||
|
||||
moreWidth := m.segmentWidth(tabSegment{title: fmt.Sprintf("+%d", len(segs)-1), isMore: true})
|
||||
fitBudget := budget - moreWidth
|
||||
if fitBudget < widths[m.active] {
|
||||
fitBudget = widths[m.active]
|
||||
}
|
||||
|
||||
start, end := m.active, m.active
|
||||
used := widths[m.active]
|
||||
for {
|
||||
grew := false
|
||||
if start > 0 && used+widths[start-1] <= fitBudget {
|
||||
start--
|
||||
used += widths[start]
|
||||
grew = true
|
||||
}
|
||||
if end < len(segs)-1 && used+widths[end+1] <= fitBudget {
|
||||
end++
|
||||
used += widths[end]
|
||||
grew = true
|
||||
}
|
||||
if !grew {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
hidden := len(segs) - (end - start + 1)
|
||||
if hidden <= 0 {
|
||||
return segs
|
||||
}
|
||||
|
||||
visible := append([]tabSegment{}, segs[start:end+1]...)
|
||||
visible = append(visible, tabSegment{title: fmt.Sprintf("+%d", hidden), isMore: true})
|
||||
return visible
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if len(m.items) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
borderColor := m.styles.BlurredBorder
|
||||
if m.focused {
|
||||
borderColor = m.styles.FocusedBorder
|
||||
}
|
||||
|
||||
segs := m.collapsedSegments(m.width)
|
||||
|
||||
bar := m.renderBar(segs, borderColor, false)
|
||||
contentWidth := lipgloss.Width(bar)
|
||||
|
||||
if m.width > contentWidth {
|
||||
bar = m.extendBarCap(m.renderBar(segs, borderColor, true), m.width, borderColor)
|
||||
contentWidth = m.width
|
||||
}
|
||||
contentStyle := m.styles.Content.
|
||||
BorderForeground(borderColor).
|
||||
Width(contentWidth)
|
||||
if m.height > 0 {
|
||||
if h := m.height - lipgloss.Height(bar); h > 0 {
|
||||
contentStyle = contentStyle.Height(h)
|
||||
}
|
||||
}
|
||||
|
||||
content := contentStyle.Render(m.items[m.active].Model.View())
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, bar, content)
|
||||
}
|
||||
|
||||
func (m Model) renderBar(segs []tabSegment, borderColor color.Color, extendCap bool) string {
|
||||
rendered := make([]string, len(segs))
|
||||
|
||||
for i, seg := range segs {
|
||||
isFirst, isLast := i == 0, i == len(segs)-1
|
||||
|
||||
tabStyle := m.styles.InactiveTab
|
||||
titleStyle := m.styles.InactiveTitle
|
||||
if seg.isActive {
|
||||
tabStyle = m.styles.ActiveTab
|
||||
titleStyle = m.styles.ActiveTitle
|
||||
}
|
||||
tabStyle = tabStyle.BorderForeground(borderColor)
|
||||
|
||||
bt := m.styles.BorderType
|
||||
border, _, _, _, _ := tabStyle.GetBorder()
|
||||
switch {
|
||||
case isFirst && seg.isActive:
|
||||
border.BottomLeft = bt.Left
|
||||
case isFirst && !seg.isActive:
|
||||
border.BottomLeft = bt.MiddleLeft
|
||||
case isLast && seg.isActive && !extendCap:
|
||||
border.BottomRight = bt.Right
|
||||
case isLast && !seg.isActive && !extendCap:
|
||||
border.BottomRight = bt.MiddleRight
|
||||
}
|
||||
|
||||
tabStyle = tabStyle.Border(border)
|
||||
|
||||
rendered[i] = tabStyle.Render(titleStyle.Render(seg.title))
|
||||
}
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
|
||||
}
|
||||
|
||||
func (m Model) extendBarCap(bar string, width int, borderColor color.Color) string {
|
||||
gap := width - lipgloss.Width(bar)
|
||||
if gap <= 0 {
|
||||
return bar
|
||||
}
|
||||
|
||||
bt := m.styles.BorderType
|
||||
fill := lipgloss.NewStyle().
|
||||
Foreground(borderColor).
|
||||
Render(strings.Repeat(bt.Bottom, gap-1) + bt.TopRight)
|
||||
|
||||
lines := strings.Split(bar, "\n")
|
||||
lines[len(lines)-1] += fill
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m Model) step(delta int) int {
|
||||
n := len(m.items)
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
if m.loop {
|
||||
return ((m.active+delta)%n + n) % n
|
||||
}
|
||||
return clamp(m.active+delta, 0, n-1)
|
||||
}
|
||||
|
||||
func clamp(v, low, high int) int {
|
||||
if high < low {
|
||||
return low
|
||||
}
|
||||
if v < low {
|
||||
return low
|
||||
}
|
||||
if v > high {
|
||||
return high
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user