rewrite bubbletea -> opentui

This commit is contained in:
Hadi
2026-09-01 10:23:42 +02:00
parent 000f9997fd
commit 12305ba1f2
95 changed files with 1612 additions and 3711 deletions
-1
View File
@@ -1 +0,0 @@
use flake
-24
View File
@@ -1,24 +0,0 @@
version: 2
before:
hooks:
- go mod tidy
builds:
- skip: true
archives:
- formats:
- tar.gz
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
checksum:
name_template: checksums.txt
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
-10
View File
@@ -1,10 +0,0 @@
# Contributing
Everybody is invited and welcome to contribute. There is a lot to do... Check the issues!
The process is straight-forward.
- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1)
- Fork this git repository
- Write your changes (bug fixes, new features, ...).
- Create a Pull Request against the main branch.
-1
View File
@@ -1 +0,0 @@
custom: ["https://hadi.icu/donate"]
-28
View File
@@ -1,28 +0,0 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean --config .github/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2 -4
View File
@@ -1,4 +1,2 @@
.claude/ node_modules/
CLAUDE.md dist/
result/
.pre-commit-config.yaml
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Hadi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10 -24
View File
@@ -1,36 +1,22 @@
# I Love TUI # I Love TUI
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. Shared OpenTUI/Solid theme, components, and config helpers for my TUIs.
## Install ## Install
```sh ```bash
go get github.com/anotherhadi/ilovetui bun add github:anotherhadi/ilovetui
``` ```
## Packages Peer dependencies, install whichever you actually use: `@opentui/core`, `@opentui/solid`, `@opentui/keymap`, `solid-js`, `opentui-spinner`.
- [`style`](style/README.md): the theme itself. Colors, pre-built panel styles, config loading. ## Usage
- [`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.
Each package has its own README and a runnable example under `examples/<package>`. ```ts
import { theme, presets } from "ilovetui";
## Quick start import "ilovetui/solid";
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
import "github.com/anotherhadi/ilovetui/style"
s := lipgloss.NewStyle().Foreground(style.S.Primary)
box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h)
``` ```
No setup required. See [`style/README.md`](style/README.md) for config details. Entry points: `.`, `./solid`, `./context`, `./project-config`, `./keymap`, `./components`, `./components/help`, `./components/spinner`. See the source for what each exports.
## Projects using ilovetui The theme is base16 and user-overridable via `~/.config/ilovetui/config.yaml`. Copy [`src/default.yaml`](https://github.com/anotherhadi/ilovetui/blob/main/src/default.yaml) there as a starting point.
- [anotherhadi/spilltea](https://github.com/anotherhadi/spilltea): A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players. Think Burp Suite or Caido, but entirely in your terminal.
- [anotherhadi/usbguard-tui](https://github.com/anotherhadi/usbguard-tui): A terminal UI for managing USB devices via usbguard. TUI built with golang & bubbletea.
- [anotherhadi/jwt-tui](https://github.com/anotherhadi/jwt-tui): A terminal UI for inspecting, editing, and signing JSON Web Tokens (JWTs).
-55
View File
@@ -1,55 +0,0 @@
package app
import (
"image/color"
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
func RenderToggle(on bool) string {
if !style.S.NerdFonts {
if on {
return lipgloss.NewStyle().
Foreground(style.S.Success).
Bold(true).
Render("on")
}
return lipgloss.NewStyle().
Foreground(style.S.Subtle).
Bold(true).
Render("off")
}
if on {
return RenderBadge(" ●", style.S.Success)
}
return RenderBadge("● ", style.S.Subtle)
}
func RenderBadge(label string, fill color.Color) string {
body := lipgloss.NewStyle().
Background(fill).
Foreground(style.S.Background).
Bold(true).
Render(label)
cap := lipgloss.NewStyle().Foreground(fill).Background(style.S.Background)
return cap.Render("") + body + cap.Render("")
}
func SignalBars(signal int) string {
bars := []rune("▁▃▅▇")
level := signal * len(bars) / 100
var b strings.Builder
for i, ch := range bars {
s := lipgloss.NewStyle().Foreground(style.S.Subtle)
if i < level {
s = lipgloss.NewStyle().Foreground(style.S.Text)
}
b.WriteString(s.Render(string(ch)))
}
return b.String()
}
-33
View File
@@ -1,33 +0,0 @@
package app
import tea "charm.land/bubbletea/v2"
type QuitMsg struct{}
func Quit() tea.Cmd {
return func() tea.Msg { return QuitMsg{} }
}
type FocusMsg struct{}
func Focus() tea.Cmd {
return func() tea.Msg { return FocusMsg{} }
}
type BlurMsg struct{}
func Blur() tea.Cmd {
return func() tea.Msg { return BlurMsg{} }
}
type RequestFocusMsg struct{}
func RequestFocus() tea.Cmd {
return func() tea.Msg { return RequestFocusMsg{} }
}
type SetTitleMsg struct{ Title string }
func SetTitle(title string) tea.Cmd {
return func() tea.Msg { return SetTitleMsg{Title: title} }
}
-17
View File
@@ -1,17 +0,0 @@
# bubbles
Themed constructors for every official `bubbles/v2` component: `help`, `textarea`, `textinput`,
`list`, `table`, `filepicker`, `spinner`, `progress`, `paginator`, `viewport`. Each wraps the
official `New`, then applies `style.S` colors on top.
- `spinner`, `table`, `progress` forward any `opts` to the upstream `New` before the theme is
applied, so the theme's colors always win over anything conflicting in `opts`.
- `ViewportView` renders a `viewport.Model` with a themed scrollbar thumb in the left gutter,
shown only when content overflows.
- `NewList`/`NewDefaultDelegate` theme both the list chrome and the selection/filter colors of its
default delegate.
Custom components in this repo (`tabs`, `modal`, `drawer`, `notification`, `helpbar`) build any
official component they need through here, never through `charm.land/bubbles/v2` directly.
See `examples/bubbles`.
-1
View File
@@ -1 +0,0 @@
package bubbles
-25
View File
@@ -1,25 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/filepicker"
"github.com/anotherhadi/ilovetui/style"
)
func NewFilePicker() filepicker.Model {
f := filepicker.New()
s := filepicker.DefaultStyles()
s.Cursor = s.Cursor.Foreground(style.S.Primary)
s.DisabledCursor = s.DisabledCursor.Foreground(style.S.Subtle)
s.Symlink = s.Symlink.Foreground(style.S.Warning)
s.Directory = s.Directory.Foreground(style.S.Primary)
s.File = s.File.Foreground(style.S.Text)
s.DisabledFile = s.DisabledFile.Foreground(style.S.Subtle)
s.Permission = s.Permission.Foreground(style.S.Muted)
s.Selected = s.Selected.Foreground(style.S.Primary)
s.DisabledSelected = s.DisabledSelected.Foreground(style.S.Subtle)
s.FileSize = s.FileSize.Foreground(style.S.Muted)
s.EmptyDirectory = s.EmptyDirectory.Foreground(style.S.Subtle)
f.Styles = s
return f
}
-20
View File
@@ -1,20 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/help"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
func NewHelp() help.Model {
h := help.New()
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(style.S.Primary)
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
h.Styles.FullKey = lipgloss.NewStyle().Foreground(style.S.Primary)
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(style.S.Subtle)
return h
}
-47
View File
@@ -1,47 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/list"
"github.com/anotherhadi/ilovetui/style"
)
func NewList(items []list.Item, width, height int) list.Model {
m := list.New(items, NewDefaultDelegate(), width, height)
m.Styles = themedListStyles()
return m
}
func themedListStyles() list.Styles {
s := list.DefaultStyles(true)
s.Title = s.Title.Background(style.S.Primary).Foreground(style.S.Background)
s.Spinner = s.Spinner.Foreground(style.S.Primary)
s.Filter = themedTextInputStyles()
s.DefaultFilterCharacterMatch = s.DefaultFilterCharacterMatch.Foreground(style.S.Primary)
s.StatusBar = s.StatusBar.Foreground(style.S.Muted)
s.StatusEmpty = s.StatusEmpty.Foreground(style.S.Subtle)
s.StatusBarActiveFilter = s.StatusBarActiveFilter.Foreground(style.S.Text)
s.StatusBarFilterCount = s.StatusBarFilterCount.Foreground(style.S.Subtle)
s.NoItems = s.NoItems.Foreground(style.S.Subtle)
s.ArabicPagination = s.ArabicPagination.Foreground(style.S.Subtle)
s.ActivePaginationDot = s.ActivePaginationDot.Foreground(style.S.Primary)
s.InactivePaginationDot = s.InactivePaginationDot.Foreground(style.S.Subtle)
s.DividerDot = s.DividerDot.Foreground(style.S.Subtle)
return s
}
func NewDefaultDelegate() list.DefaultDelegate {
d := list.NewDefaultDelegate()
d.Styles.NormalTitle = d.Styles.NormalTitle.Foreground(style.S.Text)
d.Styles.NormalDesc = d.Styles.NormalDesc.Foreground(style.S.Muted)
d.Styles.SelectedTitle = d.Styles.SelectedTitle.
BorderForeground(style.S.Primary).
Foreground(style.S.Primary)
d.Styles.SelectedDesc = d.Styles.SelectedDesc.
BorderForeground(style.S.Primary).
Foreground(style.S.Primary)
d.Styles.DimmedTitle = d.Styles.DimmedTitle.Foreground(style.S.Subtle)
d.Styles.DimmedDesc = d.Styles.DimmedDesc.Foreground(style.S.SubtleBg)
d.Styles.FilterMatch = d.Styles.FilterMatch.Foreground(style.S.Primary)
return d
}
-16
View File
@@ -1,16 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/paginator"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
func NewPaginator() paginator.Model {
p := paginator.New()
p.Type = paginator.Dots
p.ActiveDot = lipgloss.NewStyle().Foreground(style.S.Primary).Render("•")
p.InactiveDot = lipgloss.NewStyle().Foreground(style.S.Subtle).Render("•")
return p
}
-16
View File
@@ -1,16 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/progress"
"github.com/anotherhadi/ilovetui/style"
)
func NewProgress(opts ...progress.Option) progress.Model {
allOpts := append([]progress.Option{
progress.WithColors(style.S.Subtle, style.S.Primary),
}, opts...)
p := progress.New(allOpts...)
p.EmptyColor = style.S.SubtleBg
return p
}
-14
View File
@@ -1,14 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
func NewSpinner(opts ...spinner.Option) spinner.Model {
s := spinner.New(opts...)
s.Style = lipgloss.NewStyle().Foreground(style.S.Primary)
return s
}
-17
View File
@@ -1,17 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/table"
"github.com/anotherhadi/ilovetui/style"
)
func NewTable(opts ...table.Option) table.Model {
t := table.New(opts...)
s := table.DefaultStyles()
s.Header = s.Header.Foreground(style.S.Primary)
s.Cell = s.Cell.Foreground(style.S.Text)
s.Selected = s.Selected.Foreground(style.S.Primary)
t.SetStyles(s)
return t
}
-31
View File
@@ -1,31 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/textarea"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
func NewTextarea(showLineNumbers bool) textarea.Model {
ta := textarea.New()
ta.Prompt = ""
ta.ShowLineNumbers = showLineNumbers
ta.CharLimit = 0
ta.EndOfBufferCharacter = '~'
ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.Text = lipgloss.NewStyle().Foreground(style.S.Text)
ts.Focused.CursorLine = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Text)
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Primary).Bold(true)
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(style.S.Subtle)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(style.S.Muted)
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
ta.SetStyles(ts)
return ta
}
-27
View File
@@ -1,27 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/textinput"
"github.com/anotherhadi/ilovetui/style"
)
func NewTextInput() textinput.Model {
t := textinput.New()
t.SetStyles(themedTextInputStyles())
return t
}
func themedTextInputStyles() textinput.Styles {
s := textinput.DefaultStyles(true)
s.Focused.Text = s.Focused.Text.Foreground(style.S.Text)
s.Focused.Placeholder = s.Focused.Placeholder.Foreground(style.S.Subtle)
s.Focused.Suggestion = s.Focused.Suggestion.Foreground(style.S.Subtle)
s.Focused.Prompt = s.Focused.Prompt.Foreground(style.S.Primary)
s.Blurred.Text = s.Blurred.Text.Foreground(style.S.Muted)
s.Blurred.Placeholder = s.Blurred.Placeholder.Foreground(style.S.Subtle)
s.Blurred.Suggestion = s.Blurred.Suggestion.Foreground(style.S.Subtle)
s.Blurred.Prompt = s.Blurred.Prompt.Foreground(style.S.Subtle)
s.Cursor.Color = style.S.Primary
return s
}
-47
View File
@@ -1,47 +0,0 @@
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()
}
+271
View File
@@ -0,0 +1,271 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "ilovetui-opentui",
"dependencies": {
"yaml": "^2.8.1",
"zod": "^4.5.4",
},
"devDependencies": {
"@opentui/keymap": "^0.5.9",
"@opentui/solid": "^0.5.9",
"@types/bun": "^1.3.14",
"opentui-spinner": "^0.0.7",
"solid-js": "1.9.12",
"typescript": "^5.9.3",
},
"peerDependencies": {
"@opentui/core": ">=0.5.0",
"@opentui/keymap": ">=0.5.0",
"@opentui/solid": ">=0.5.0",
"opentui-spinner": ">=0.0.7",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"@opentui/keymap",
"@opentui/solid",
"opentui-spinner",
"solid-js",
],
},
},
"packages": {
"@ampproject/remapping": ["@ampproject/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-annotate-as-pure": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-create-class-features-plugin": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="],
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-member-expression-to-functions": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-optimise-call-expression": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="],
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-replace-supers": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="],
"@babel/helper-skip-transparent-expression-wrappers": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="],
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/plugin-syntax-jsx": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
"@babel/plugin-syntax-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="],
"@babel/plugin-transform-modules-commonjs": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="],
"@babel/plugin-transform-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="],
"@babel/preset-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@opentui/core": ["@opentui/[email protected]", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
"@opentui/core-darwin-arm64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
"@opentui/core-darwin-x64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
"@opentui/core-linux-arm64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
"@opentui/core-linux-x64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
"@opentui/core-linux-x64-musl": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
"@opentui/core-win32-arm64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
"@opentui/core-win32-x64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
"@opentui/keymap": ["@opentui/[email protected]", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
"@opentui/solid": ["@opentui/[email protected]", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
"ansi-regex": ["[email protected]", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
"babel-plugin-jsx-dom-expressions": ["[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA=="],
"babel-plugin-module-resolver": ["[email protected]", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="],
"babel-preset-solid": ["[email protected]", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw=="],
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
"bun-ffi-structs": ["[email protected]", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"cli-spinners": ["[email protected]", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"diff": ["[email protected]", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-4T+DTDWuMPM4aHlHwWdAVCVWwp7LDilnhzkj+c/Lbj91XSQrLuOmZSLtS9Q4iIqjlPUbPOnC624zDVVHCHaolQ=="],
"emoji-regex": ["[email protected]", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"entities": ["[email protected]", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"find-babel-config": ["[email protected]", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
"find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
"fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-east-asian-width": ["[email protected]", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"glob": ["[email protected]", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"html-entities": ["[email protected]", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
"minipass": ["[email protected]", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-releases": ["[email protected]", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="],
"opentui-spinner": ["[email protected]", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="],
"p-limit": ["[email protected]", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["[email protected]", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
"p-try": ["[email protected]", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"parse5": ["[email protected]", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"path-exists": ["[email protected]", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-scurry": ["[email protected]", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pkg-up": ["[email protected]", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
"reselect": ["[email protected]", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
"resolve": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"s-js": ["[email protected]", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["[email protected]", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="],
"seroval-plugins": ["[email protected]", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="],
"solid-js": ["[email protected]", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
"string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="],
"web-tree-sitter": ["[email protected]", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["[email protected]", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"zod": ["[email protected]", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"parse5/entities": ["[email protected]", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"path-scurry/lru-cache": ["[email protected]", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"path-scurry/minipass": ["[email protected]", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
}
}
-13
View File
@@ -1,13 +0,0 @@
# drawer
A full-height panel flush against the left or right edge of an already-rendered background, on top
of it dimmed. The sidebar/drawer equivalent of [`modal`](../modal/README.md), which it otherwise
mirrors closely: same `tea.Msg`-triggered stack (`ShowMsg`/`Show`), same composite-over-a-string
`Render`, same rule that content is a `tea.Model`.
- `WithSide(Left|Right)` and `WithWidth` are per-drawer `Show` options; width otherwise shrinks to
fit content, capped by the `Model`'s `WithMaxWidth`.
- The stack is a plain LIFO. Only the topmost drawer is updated; `Close()` closes it.
- `View(width, height)` renders on a blank background of that size, for use as a standalone pane.
See `examples/drawer`.
-61
View File
@@ -1,61 +0,0 @@
package drawer
import tea "charm.land/bubbletea/v2"
type Side int
const (
Left Side = iota
Right
)
type Drawer struct {
Title string
Content tea.Model
Side Side
Width int
Style *Styles
}
type DrawerOption func(*Drawer)
func WithSide(s Side) DrawerOption {
return func(d *Drawer) { d.Side = s }
}
func WithWidth(w int) DrawerOption {
return func(d *Drawer) { d.Width = w }
}
func WithDrawerStyle(s Styles) DrawerOption {
return func(d *Drawer) { d.Style = &s }
}
func newDrawer(title string, content tea.Model, opts ...DrawerOption) Drawer {
d := Drawer{Title: title, Content: content}
for _, opt := range opts {
opt(&d)
}
return d
}
type ShowMsg struct{ Drawer Drawer }
func Show(title string, content tea.Model, opts ...DrawerOption) tea.Cmd {
d := newDrawer(title, content, opts...)
return func() tea.Msg { return ShowMsg{Drawer: d} }
}
type DismissMsg struct{}
func Close() tea.Cmd {
return func() tea.Msg { return DismissMsg{} }
}
type text string
func (t text) Init() tea.Cmd { return nil }
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
func (t text) View() tea.View { return tea.NewView(string(t)) }
func Text(s string) tea.Model { return text(s) }
-75
View File
@@ -1,75 +0,0 @@
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
}
-115
View File
@@ -1,115 +0,0 @@
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)
}
return style.Overlay(dim(background, s.DimColor), box, x, 0)
}
func dim(s string, c color.Color) string {
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
}
func (m Model) renderBox(d Drawer, s Styles, bgW, bgH int) string {
widthCap := m.maxWidth
if d.Width > 0 {
widthCap = d.Width
}
maxW := effectiveMax(widthCap, bgW)
body := contentView(d)
inner := contentWidth(d, body, maxW)
content := s.Content.Width(inner).Render(body)
boxWidth := inner + 4
return style.RenderWithTitle(s.Border, s.Title.Render(d.Title), content, boxWidth, bgH)
}
func contentView(d Drawer) string {
if d.Content == nil {
return ""
}
return d.Content.View().Content
}
func contentWidth(d Drawer, body string, maxWidth int) int {
capped := max(maxWidth-4, 1)
if d.Width > 0 {
return capped
}
natural := max(naturalWidth(body), lipgloss.Width(d.Title), 1)
return min(natural, capped)
}
func naturalWidth(content string) int {
w := 0
for _, line := range strings.Split(content, "\n") {
if lw := lipgloss.Width(line); lw > w {
w = lw
}
}
return w
}
func effectiveMax(configured, fits int) int {
if fits < 1 {
fits = 1
}
if configured > 0 && configured < fits {
return configured
}
return fits
}
func blank(width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
line := strings.Repeat(" ", width)
lines := make([]string, height)
for i := range lines {
lines[i] = line
}
return strings.Join(lines, "\n")
}
-68
View File
@@ -1,68 +0,0 @@
package drawer
import (
"strings"
"testing"
"charm.land/lipgloss/v2"
)
func background(w, h int) string {
return blank(w, h)
}
func TestRenderLeftFlushToLeftEdge(t *testing.T) {
m := New(WithMaxWidth(10))
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"), WithSide(Left))})
out := m.Render(background(40, 10))
lines := strings.Split(out, "\n")
if len(lines) != 10 {
t.Fatalf("expected 10 lines, got %d", len(lines))
}
if lipgloss.Width(out) == 0 {
t.Fatalf("expected non-empty render")
}
if len([]rune(lines[0])) == 0 {
t.Fatalf("expected a rendered top border line")
}
}
func TestRenderRightFlushToRightEdge(t *testing.T) {
m := New(WithMaxWidth(10))
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Inspector", Text("hi"), WithSide(Right))})
out := m.Render(background(40, 10))
if lipgloss.Width(out) != 40 {
t.Fatalf("expected full background width 40, got %d", lipgloss.Width(out))
}
}
func TestSpansFullBackgroundHeight(t *testing.T) {
m := New()
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"))})
out := m.Render(background(40, 12))
if lipgloss.Height(out) != 12 {
t.Fatalf("expected full height 12, got %d", lipgloss.Height(out))
}
}
func TestFixedWidthHonored(t *testing.T) {
m := New(WithMaxWidth(50))
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("x"), WithWidth(20))})
box := m.renderBox(m.drawers[0], m.styles, 80, 10)
if got := lipgloss.Width(box); got != 20 {
t.Fatalf("expected fixed box width 20, got %d", got)
}
}
func TestNoDrawersReturnsBackgroundUnchanged(t *testing.T) {
m := New()
bg := background(10, 5)
if got := m.Render(bg); got != bg {
t.Fatalf("expected background unchanged when no drawer is open")
}
}
-25
View File
@@ -1,25 +0,0 @@
package drawer
import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
type Styles struct {
Border lipgloss.Style
Title lipgloss.Style
Content lipgloss.Style
DimColor color.Color
}
func DefaultStyles() Styles {
return Styles{
Border: style.S.PanelFocused.Padding(0, 1),
Title: lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary),
Content: lipgloss.NewStyle().Foreground(style.S.Text),
DimColor: style.S.Subtle,
}
}
-48
View File
@@ -1,48 +0,0 @@
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
)
type model struct {
input textinput.Model
spinner spinner.Model
}
func newModel() model {
ti := bubbles.NewTextInput()
ti.Placeholder = "type something"
ti.Focus()
return model{input: ti, spinner: bubbles.NewSpinner()}
}
func (m model) Init() tea.Cmd { return m.spinner.Tick }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "ctrl+c" {
return m, tea.Quit
}
var inputCmd, spinnerCmd tea.Cmd
m.input, inputCmd = m.input.Update(msg)
m.spinner, spinnerCmd = m.spinner.Update(msg)
return m, tea.Batch(inputCmd, spinnerCmd)
}
func (m model) View() tea.View {
return tea.NewView(lipgloss.JoinHorizontal(lipgloss.Center, m.spinner.View(), " ", m.input.View()))
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/drawer"
)
type model struct {
d drawer.Model
width, height int
}
func newModel() model { return model{d: drawer.New()} }
func (m model) Init() tea.Cmd { return m.d.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
switch msg.String() {
case "q":
return m, tea.Quit
case "l":
return m, drawer.Show("Nav", drawer.Text("Home\nSettings"), drawer.WithSide(drawer.Left))
case "esc":
if m.d.Open() {
return m, drawer.Close()
}
}
}
var cmd tea.Cmd
m.d, cmd = m.d.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "l: open q: quit")
view := tea.NewView(m.d.Render(background))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-73
View File
@@ -1,73 +0,0 @@
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/helpbar"
)
type keyMap struct {
Inc, Help, Quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
Inc: key.NewBinding(key.WithKeys("+"), key.WithHelp("+", "increment")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
}
}
type model struct {
help helpbar.Model
keys keyMap
n, w int
}
func newModel() model {
keys := defaultKeyMap()
return model{
keys: keys,
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.Quit)),
}
}
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.w = msg.Width
m.help.SetWidth(m.w)
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Inc):
m.n++
default:
m.help, _ = m.help.Update(msg)
}
}
return m, nil
}
func (m model) View() tea.View {
bar := m.help.View(m.keys.Inc)
body := lipgloss.Place(m.w, 1, lipgloss.Center, lipgloss.Top, fmt.Sprintf("count: %d", m.n))
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, bar))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/modal"
)
type model struct {
m modal.Model
width, height int
}
func newModel() model { return model{m: modal.New()} }
func (m model) Init() tea.Cmd { return m.m.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
switch msg.String() {
case "q":
return m, tea.Quit
case "o":
return m, modal.Show("Hello", modal.Text("This is a modal.\n\nesc: close"))
case "esc":
if m.m.Open() {
return m, modal.Close()
}
}
}
var cmd tea.Cmd
m.m, cmd = m.m.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "o: open q: quit")
view := tea.NewView(m.m.Render(background))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/notification"
)
type model struct {
notif notification.Model
width, height int
}
func newModel() model { return model{notif: notification.New()} }
func (m model) Init() tea.Cmd { return m.notif.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
switch msg.String() {
case "q":
return m, tea.Quit
case "s":
return m, notification.Show("Saved", "Config written to disk.", notification.Success)
}
}
var cmd tea.Cmd
m.notif, cmd = m.notif.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "s: show q: quit")
view := tea.NewView(m.notif.Render(background))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-42
View File
@@ -1,42 +0,0 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/style"
)
type model struct{ focused bool }
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "q":
return m, tea.Quit
case "tab":
m.focused = !m.focused
}
}
return m, nil
}
func (m model) View() tea.View {
box := style.S.Panel
if m.focused {
box = style.S.PanelFocused
}
return tea.NewView(style.RenderWithTitle(box, "Panel", "tab: toggle focus q: quit", 30, 5))
}
func main() {
if _, err := tea.NewProgram(model{}).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-55
View File
@@ -1,55 +0,0 @@
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)
}
}
Generated
-87
View File
@@ -1,87 +0,0 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1778507602,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-30
View File
@@ -1,30 +0,0 @@
{
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";
git-hooks = {
url = "github:cachix/git-hooks.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
self,
nixpkgs,
git-hooks,
}: let
supportedSystems = ["x86_64-linux" "aarch64-linux"];
forAllSystems = f:
nixpkgs.lib.genAttrs supportedSystems
(system: f system (import nixpkgs {inherit system;}));
in {
devShells = forAllSystems (system: pkgs: {
default = import ./nix/shell.nix {
inherit pkgs;
gitHooksLib = git-hooks.lib.${system};
};
});
};
}
-43
View File
@@ -1,43 +0,0 @@
module github.com/anotherhadi/ilovetui
go 1.25.8
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.2
charm.land/glamour/v2 v2.0.0
charm.land/lipgloss/v2 v2.0.3
github.com/charmbracelet/x/ansi v0.11.7
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
-87
View File
@@ -1,87 +0,0 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-13
View File
@@ -1,13 +0,0 @@
# 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`.
-132
View File
@@ -1,132 +0,0 @@
package helpbar
import (
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"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 ""
}
var view string
if !m.ShowAll {
view = m.help.ShortHelpView(bindings)
} else {
view = m.help.FullHelpView(m.columns(bindings))
}
return m.clampWidth(view)
}
func (m Model) clampWidth(view string) string {
lines := strings.Split(view, "\n")
for i, line := range lines {
lines[i] = ansi.Truncate(line, m.width, "")
}
return strings.Join(lines, "\n")
}
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
}
-1
View File
@@ -1 +0,0 @@
package ilovetui
-60
View File
@@ -1,60 +0,0 @@
package minsize
import (
"fmt"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
type Model struct {
MinWidth int
MinHeight int
Style lipgloss.Style
}
type Option func(*Model)
func WithStyle(s lipgloss.Style) Option {
return func(m *Model) { m.Style = s }
}
func New(minWidth, minHeight int, opts ...Option) Model {
m := Model{
MinWidth: minWidth,
MinHeight: minHeight,
Style: lipgloss.NewStyle().Foreground(style.S.Muted),
}
for _, opt := range opts {
opt(&m)
}
return m
}
func (m Model) Fits(width, height int) bool {
return width >= m.MinWidth && height >= m.MinHeight
}
func (m Model) View(width, height int) string {
msg := m.Style.Render(m.message(width, height))
if width < 1 || height < 1 {
return msg
}
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, msg)
}
func (m Model) message(width, height int) string {
tooNarrow := width < m.MinWidth
tooShort := height < m.MinHeight
switch {
case tooNarrow && tooShort:
return fmt.Sprintf("minimum is %dx%d", m.MinWidth, m.MinHeight)
case tooNarrow:
return fmt.Sprintf("minimum width: %d", m.MinWidth)
case tooShort:
return fmt.Sprintf("minimum height: %d", m.MinHeight)
default:
return ""
}
}
-15
View File
@@ -1,15 +0,0 @@
# modal
A centered popup box on top of a dimmed background, triggered from anywhere in a bubbletea program
via an exported `tea.Msg` (`ShowMsg`/`Show`), not a direct reference to the `Model` that renders it.
Composites over an already-rendered string, so it makes no assumption about how the host builds
that string.
- A modal's content is a `tea.Model`, not a string: it gets `Init`/`Update` while it's on top, and
can report back with its own `tea.Msg` (see `modal.Text` for a plain, non-interactive content).
- The stack is a plain LIFO with no identity. Only the topmost modal is updated; `Close()` always
closes it.
- Showing a second modal while one is open pushes it on top, nesting confirmations naturally.
- `WithMaxWidth`/`WithMaxHeight` cap growth; a modal narrower than the cap shrinks to fit instead.
See `examples/modal`.
-46
View File
@@ -1,46 +0,0 @@
package modal
import tea "charm.land/bubbletea/v2"
type Modal struct {
Title string
Content tea.Model
Style *Styles
}
type ModalOption func(*Modal)
func WithModalStyle(s Styles) ModalOption {
return func(mo *Modal) { mo.Style = &s }
}
func newModal(title string, content tea.Model, opts ...ModalOption) Modal {
mo := Modal{Title: title, Content: content}
for _, opt := range opts {
opt(&mo)
}
return mo
}
type ShowMsg struct{ Modal Modal }
func Show(title string, content tea.Model, opts ...ModalOption) tea.Cmd {
mo := newModal(title, content, opts...)
return func() tea.Msg { return ShowMsg{Modal: mo} }
}
type DismissMsg struct{}
func Close() tea.Cmd {
return func() tea.Msg { return DismissMsg{} }
}
type text string
func (t text) Init() tea.Cmd { return nil }
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
func (t text) View() tea.View { return tea.NewView(string(t)) }
func Text(s string) tea.Model { return text(s) }
-80
View File
@@ -1,80 +0,0 @@
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
}
-109
View File
@@ -1,109 +0,0 @@
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)
return style.Overlay(dim(background, s.DimColor), box, x, y)
}
func dim(s string, c color.Color) string {
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
}
func (m Model) renderBox(mo Modal, s Styles, bgW, bgH int) string {
maxW := effectiveMax(m.maxWidth, bgW-2*margin)
maxH := effectiveMax(m.maxHeight, bgH-2*margin)
body := contentView(mo)
inner := contentWidth(body, mo.Title, maxW)
content := s.Content.Width(inner).Render(body)
boxWidth := inner + 4
boxHeight := min(lipgloss.Height(content)+2, maxH)
return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight)
}
func contentView(mo Modal) string {
if mo.Content == nil {
return ""
}
return mo.Content.View().Content
}
func contentWidth(body, title string, maxWidth int) int {
natural := max(naturalWidth(body), lipgloss.Width(title), 1)
capped := max(maxWidth-4, 1)
return min(natural, capped)
}
func naturalWidth(content string) int {
w := 0
for _, line := range strings.Split(content, "\n") {
if lw := lipgloss.Width(line); lw > w {
w = lw
}
}
return w
}
func effectiveMax(configured, fits int) int {
if fits < 1 {
fits = 1
}
if configured > 0 && configured < fits {
return configured
}
return fits
}
func blank(width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
line := strings.Repeat(" ", width)
lines := make([]string, height)
for i := range lines {
lines[i] = line
}
return strings.Join(lines, "\n")
}
-25
View File
@@ -1,25 +0,0 @@
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,
}
}
-23
View File
@@ -1,23 +0,0 @@
{
pkgs,
gitHooksLib,
}: let
hooks = gitHooksLib.run {
src = ../.;
hooks = {
gofmt.enable = true;
govet.enable = true;
};
};
in
pkgs.mkShell {
packages = with pkgs;
[
go
doctoc
(python3.withPackages (ps: [ps.pyte]))
]
++ hooks.enabledPackages;
shellHook = hooks.shellHook;
}
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env python3
"""Run a TUI binary in a virtual terminal (via pyte) and print its rendered
screen as plain text, for visual verification of bubbletea/lipgloss output.
Dev tooling only, provided by the nix-shell. Not part of the Go module.
Usage:
tui-snapshot.py [--width W] [--height H] [--wait SECONDS] [--keys 'k:delay,...'] -- <cmd> [args...]
Example:
tui-snapshot.py --keys 'l:0.2,l:0.2,q:0' -- go run ./examples/tabs
"""
import argparse
import fcntl
import os
import pty
import select
import struct
import sys
import termios
import time
import pyte
class RepeatAwareScreen(pyte.Screen):
"""pyte has no support for the ECMA-48 REP sequence (``CSI Ps b``,
"repeat the preceding graphic character Ps times"), which bubbletea's
renderer uses to compress runs of identical styled cells (e.g. long
border/padding runs). Left unhandled, pyte silently drops it, corrupting
the frame. This adds the missing handler."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._last_drawn = " "
def draw(self, data):
super().draw(data)
if data:
self._last_drawn = data[-1]
def repeat_last_character(self, count=1):
self.draw(self._last_drawn * (count or 1))
class RepeatAwareStream(pyte.Stream):
"""Also works around a pyte bug: it maps ECMA-48 HPA (Character Position
Absolute, "move cursor to column Ps") to "'" (apostrophe, 0x27) instead
of the actual standard character "`" (backtick, 0x60) - see pyte's
escape.py. Real terminal apps send the correct backtick, which pyte then
silently no-ops on since it's not in its dispatch table, leaving the
cursor stuck instead of moving it. bubbletea's renderer uses HPA (mixed
with CUF) to jump the cursor to a border's column after erasing padding
with ECH, so without this the trailing border character gets drawn right
after the leading one instead of at the far edge."""
csi = {
**pyte.Stream.csi,
"b": "repeat_last_character",
"`": "cursor_to_column",
}
KEYMAP = {
"enter": "\r",
"esc": "\x1b",
"tab": "\t",
"up": "\x1b[A",
"down": "\x1b[B",
"right": "\x1b[C",
"left": "\x1b[D",
"space": " ",
}
def encode_key(key):
"""Resolve a --keys token to the bytes to write to the pty. Falls back to
the literal string for a plain character (e.g. 'l'), but without this,
something like 'ctrl+l' would be sent as the 6 literal characters
'c','t','r','l','+','l' instead of the single 0x0C control byte a real
terminal would send for Ctrl-L - silently testing the wrong thing."""
if key in KEYMAP:
return KEYMAP[key]
if key.startswith("ctrl+") and len(key) == 6 and key[5].isalpha():
return chr(ord(key[5].lower()) - ord("a") + 1)
return key
def set_size(fd, rows, cols):
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--width", type=int, default=80)
parser.add_argument("--height", type=int, default=24)
parser.add_argument(
"--wait", type=float, default=1.0, help="seconds to let the app render before the snapshot"
)
parser.add_argument(
"--keys",
default="",
help="comma-separated key:delay pairs sent before the final snapshot, e.g. 'l:0.2,l:0.2,q:0'",
)
parser.add_argument(
"--colors",
action="store_true",
help="also print, per row, the foreground color of each non-blank cell (for border/color bugs "
"that don't show up in the plain-text dump)",
)
parser.add_argument("cmd", nargs=argparse.REMAINDER)
args = parser.parse_args()
cmd = args.cmd
if cmd and cmd[0] == "--":
cmd = cmd[1:]
if not cmd:
parser.error("missing command to run")
screen = RepeatAwareScreen(args.width, args.height)
stream = RepeatAwareStream(screen)
# Open the pty and set its size *before* forking, so the child never
# observes a stale/default size on its first render (pyte has no way to
# recover from a corrupted initial frame drawn at the wrong width).
master_fd, slave_fd = pty.openpty()
set_size(slave_fd, args.height, args.width)
pid = os.fork()
if pid == 0:
os.close(master_fd)
os.setsid()
fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
os.dup2(slave_fd, 0)
os.dup2(slave_fd, 1)
os.dup2(slave_fd, 2)
os.close(slave_fd)
os.execvp(cmd[0], cmd)
os._exit(1)
os.close(slave_fd)
fd = master_fd
def pump(duration):
end = time.time() + duration
while True:
remaining = end - time.time()
if remaining <= 0:
break
r, _, _ = select.select([fd], [], [], remaining)
if fd not in r:
continue
try:
data = os.read(fd, 65536)
except OSError:
break
if not data:
break
stream.feed(data.decode(errors="ignore"))
pump(args.wait)
for pair in filter(None, args.keys.split(",")):
key, _, delay = pair.partition(":")
try:
os.write(fd, encode_key(key).encode())
except OSError:
break
pump(float(delay) if delay else 0.3)
try:
os.kill(pid, 15)
os.waitpid(pid, 0)
except (ProcessLookupError, ChildProcessError):
pass
for y, line in enumerate(screen.display):
print(line.rstrip())
if args.colors:
row = screen.buffer[y]
cells = []
for x in sorted(row):
ch = row[x]
if ch.data.strip():
fg = ch.fg if ch.fg != "default" else "-"
cells.append(f"{x}:{ch.data!r}:{fg}")
if cells:
print(" " + " ".join(cells))
if __name__ == "__main__":
main()
-14
View File
@@ -1,14 +0,0 @@
# notification
Toast notifications, triggered from anywhere in a bubbletea program via an exported `tea.Msg`
(`ShowMsg`/`Show`), not a direct reference to the `Model` that renders them. Composites over an
already-rendered string, so it makes no assumption about how the host builds that string.
- Four kinds: `Info`, `Success`, `Warning`, `Error`, each with its own `style.S` color preset.
- Auto-dismisses after `DefaultDuration` (3s) unless shown with `WithDuration(0)`, which makes it
sticky; a sticky toast needs `WithID` so `Dismiss(id)` can remove it later.
- Six anchors (`Top`, `TopLeft`, `TopRight`, `Bottom`, `BottomLeft`, `BottomRight`). Toasts stack
along the anchored edge, newest closest to it.
- `WithMaxWidth` caps growth; a toast narrower than the cap shrinks to fit instead.
See `examples/notification`.
-93
View File
@@ -1,93 +0,0 @@
package notification
import (
"fmt"
"time"
tea "charm.land/bubbletea/v2"
)
type Model struct {
toasts []Toast
nextID int
position Position
maxWidth int
styles Styles
}
type Option func(*Model)
func WithPosition(p Position) Option {
return func(m *Model) { m.position = p }
}
func WithMaxWidth(w int) Option {
return func(m *Model) { m.maxWidth = w }
}
func WithStyles(s Styles) Option {
return func(m *Model) { m.styles = s }
}
func New(opts ...Option) Model {
m := Model{
position: TopRight,
maxWidth: 40,
styles: DefaultStyles(),
}
for _, opt := range opts {
opt(&m)
}
return m
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case ShowMsg:
return m.show(msg.Toast)
case DismissMsg:
return m.remove(msg.ID), nil
case expireMsg:
return m.remove(msg.id), nil
}
return m, nil
}
func (m Model) show(t Toast) (Model, tea.Cmd) {
if t.ID == "" {
t.ID = fmt.Sprintf("toast-%d", m.nextID)
m.nextID++
}
replaced := false
for i, existing := range m.toasts {
if existing.ID == t.ID {
m.toasts[i] = t
replaced = true
break
}
}
if !replaced {
m.toasts = append(m.toasts, t)
}
if t.Duration <= 0 {
return m, nil
}
id := t.ID
return m, tea.Tick(t.Duration, func(time.Time) tea.Msg {
return expireMsg{id: id}
})
}
func (m Model) remove(id string) Model {
for i, t := range m.toasts {
if t.ID == id {
m.toasts = append(m.toasts[:i], m.toasts[i+1:]...)
break
}
}
return m
}
-48
View File
@@ -1,48 +0,0 @@
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
}
-123
View File
@@ -1,123 +0,0 @@
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)
return style.Overlay(background, stack, x, y)
}
func (m Model) View(width, height int) string {
return m.Render(blank(width, height))
}
func (m Model) renderStack(maxWidth int) string {
ordered := m.orderedToasts()
parts := make([]string, 0, len(ordered)*2-1)
for i, t := range ordered {
if i > 0 {
parts = append(parts, "")
}
parts = append(parts, m.renderToast(t, maxWidth))
}
return lipgloss.JoinVertical(stackAlign(m.position), parts...)
}
func (m Model) orderedToasts() []Toast {
if !m.position.anchoredTop() {
return m.toasts
}
ordered := make([]Toast, len(m.toasts))
for i, t := range m.toasts {
ordered[len(m.toasts)-1-i] = t
}
return ordered
}
func stackAlign(pos Position) lipgloss.Position {
switch pos {
case TopLeft, BottomLeft:
return lipgloss.Left
case TopRight, BottomRight:
return lipgloss.Right
default:
return lipgloss.Center
}
}
func (m Model) renderToast(t Toast, maxWidth int) string {
k := m.styles.forKind(t)
inner := contentWidth(t, maxWidth)
message := k.Message.Width(inner).Render(t.Message)
boxWidth := inner + 4
boxHeight := lipgloss.Height(message) + 2
return style.RenderWithTitle(k.Border, k.Title.Render(t.Title), message, boxWidth, boxHeight)
}
func contentWidth(t Toast, maxWidth int) int {
natural := max(lipgloss.Width(t.Title), lipgloss.Width(t.Message), 1)
if maxWidth <= 0 {
return natural
}
capped := max(maxWidth-4, 1)
return min(natural, capped)
}
func effectiveMaxWidth(configured, bgWidth int) int {
fits := max(bgWidth-2*margin, 1)
if configured > 0 && configured < fits {
return configured
}
return fits
}
func clipToHeight(stack string, maxHeight int, anchoredTop bool) string {
lines := strings.Split(stack, "\n")
if len(lines) <= maxHeight {
return stack
}
if maxHeight <= 0 {
return ""
}
if anchoredTop {
lines = lines[:maxHeight]
} else {
lines = lines[len(lines)-maxHeight:]
}
return strings.Join(lines, "\n")
}
func blank(width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
line := strings.Repeat(" ", width)
lines := make([]string, height)
for i := range lines {
lines[i] = line
}
return strings.Join(lines, "\n")
}
-58
View File
@@ -1,58 +0,0 @@
package notification
import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
type KindStyle struct {
Border lipgloss.Style
Title lipgloss.Style
Message lipgloss.Style
}
type Styles struct {
Info KindStyle
Success KindStyle
Warning KindStyle
Error KindStyle
}
func DefaultStyles() Styles {
return Styles{
Info: kindStyle(style.S.Primary),
Success: kindStyle(style.S.Success),
Warning: kindStyle(style.S.Warning),
Error: kindStyle(style.S.Error),
}
}
func kindStyle(c color.Color) KindStyle {
return KindStyle{
Border: lipgloss.NewStyle().
Border(style.S.BorderType).
BorderForeground(c).
Padding(0, 1),
Title: lipgloss.NewStyle().Bold(true).Foreground(c),
Message: lipgloss.NewStyle().Foreground(style.S.Text),
}
}
func (s Styles) forKind(t Toast) KindStyle {
if t.Style != nil {
return *t.Style
}
switch t.Kind {
case Success:
return s.Success
case Warning:
return s.Warning
case Error:
return s.Error
default:
return s.Info
}
}
-71
View File
@@ -1,71 +0,0 @@
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 }
+61
View File
@@ -0,0 +1,61 @@
{
"name": "ilovetui",
"version": "0.1.0",
"module": "dist/index.js",
"type": "module",
"types": "./src/index.ts",
"exports": {
".": { "types": "./src/index.ts", "default": "./dist/index.js" },
"./solid": { "types": "./src/solid.ts", "default": "./dist/solid.js" },
"./context": { "types": "./src/context/index.ts", "default": "./dist/context/index.js" },
"./project-config": { "types": "./src/project-config.ts", "default": "./dist/project-config.js" },
"./keymap": { "types": "./src/keymap/provider.tsx", "default": "./dist/keymap/provider.js" },
"./components": { "types": "./src/components/index.ts", "default": "./dist/components/index.js" },
"./components/spinner": { "types": "./src/components/Spinner.tsx", "default": "./dist/components/Spinner.js" },
"./components/help": { "types": "./src/components/help.ts", "default": "./dist/components/help.js" }
},
"private": true,
"files": ["dist", "src"],
"scripts": {
"build": "bun run scripts/build.ts",
"prepare": "bun run scripts/build.ts",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@opentui/core": ">=0.5.0",
"@opentui/solid": ">=0.5.0",
"@opentui/keymap": ">=0.5.0",
"solid-js": ">=1.9.0",
"opentui-spinner": ">=0.0.7"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
},
"@opentui/keymap": {
"optional": true
},
"solid-js": {
"optional": true
},
"opentui-spinner": {
"optional": true
}
},
"dependencies": {
"yaml": "^2.8.1",
"zod": "^4.5.4"
},
"devDependencies": {
"@opentui/core": "^0.5.9",
"@opentui/keymap": "^0.5.9",
"@opentui/solid": "^0.5.9",
"@types/bun": "^1.3.14",
"opentui-spinner": "^0.0.7",
"solid-js": "1.9.12",
"typescript": "^5.9.3"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin";
const entrypoints = await Array.fromAsync(new Bun.Glob("**/*.{ts,tsx}").scan({ cwd: "src" }));
await Bun.$`rm -rf dist`;
const result = await Bun.build({
entrypoints: entrypoints.map((path) => `src/${path}`),
outdir: "dist",
root: "src",
target: "bun",
format: "esm",
splitting: true,
plugins: [createSolidTransformPlugin()],
external: ["@opentui/core", "@opentui/solid", "@opentui/keymap", "solid-js", "opentui-spinner", "yaml", "zod"],
});
if (!result.success) {
for (const message of result.logs) console.error(message);
process.exit(1);
}
await Bun.write("dist/default.yaml", Bun.file("src/default.yaml"));
console.log(`Built ${result.outputs.length} files to dist/`);
+40
View File
@@ -0,0 +1,40 @@
import { type ColorInput, TextAttributes } from "@opentui/core";
import { Show } from "solid-js";
import { theme } from "../index.ts";
const CAP_LEFT = "";
const CAP_RIGHT = "";
export interface BadgeProps {
label: string;
color: ColorInput;
textColor?: ColorInput;
backgroundColor?: ColorInput;
bold?: boolean;
withNerdfont?: boolean;
}
export function Badge(props: BadgeProps) {
const textColor = () => props.textColor ?? theme.background;
const backgroundColor = () => props.backgroundColor ?? theme.background;
const attributes = () => (props.bold === false ? undefined : TextAttributes.BOLD);
const nerdFonts = () => props.withNerdfont ?? theme.nerdFonts;
return (
<box flexDirection="row">
<Show when={nerdFonts()}>
<text fg={props.color} bg={backgroundColor()}>
{CAP_LEFT}
</text>
</Show>
<text fg={textColor()} bg={props.color} attributes={attributes()}>
{nerdFonts() ? props.label : ` ${props.label} `}
</text>
<Show when={nerdFonts()}>
<text fg={props.color} bg={backgroundColor()}>
{CAP_RIGHT}
</text>
</Show>
</box>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { type ColorInput, CliRenderEvents, TextAttributes, type BoxRenderable } from "@opentui/core";
import { onResize, useRenderer } from "@opentui/solid";
import { formatCommandBindings } from "@opentui/keymap/extras";
import { useKeymapSelector } from "@opentui/keymap/solid";
import { createMemo, createSignal, For } from "solid-js";
import { theme } from "../index.ts";
const KEY_DISPLAY = { up: "↑", down: "↓", left: "←", right: "→", enter: "Enter", escape: "Esc" };
const SEPARATOR = " ";
const ELLIPSIS = "…";
export interface HelpBarProps {
accentColor?: ColorInput;
mutedColor?: ColorInput;
}
interface HelpEntry {
command: string;
keys: string;
label: string;
}
export function HelpBar(props: HelpBarProps = {}) {
const accent = () => props.accentColor ?? theme.primary;
const muted = () => props.mutedColor ?? theme.muted;
const renderer = useRenderer();
const [width, setWidth] = createSignal(0);
let container: BoxRenderable | undefined;
const measure = () => {
if (!container) return;
setWidth(container.width);
};
const remeasureNextFrame = () => renderer.once(CliRenderEvents.FRAME, measure);
onResize(remeasureNextFrame);
const entries = useKeymapSelector((keymap): HelpEntry[] =>
keymap
.getCommandEntries({ visibility: "active" })
.filter((entry) => entry.command.shortHelp === true)
.map((entry) => ({
command: entry.command.name,
keys: formatCommandBindings(entry.bindings, { keyNameAliases: KEY_DISPLAY }) ?? "",
label: typeof entry.command.desc === "string" ? entry.command.desc : entry.command.name,
})),
);
const visible = createMemo((): HelpEntry[] => {
const available = width() - 2;
const shown: HelpEntry[] = [];
let used = 0;
for (const entry of entries()) {
const sepWidth = shown.length > 0 ? SEPARATOR.length : 0;
const entryWidth = entry.keys.length + 1 + entry.label.length;
if (used + sepWidth + entryWidth <= available) {
used += sepWidth + entryWidth;
shown.push(entry);
continue;
}
const remaining = available - used - sepWidth;
const prefix = `${entry.keys} `;
if (remaining > prefix.length + ELLIPSIS.length) {
const labelBudget = remaining - prefix.length - ELLIPSIS.length;
shown.push({ ...entry, label: `${entry.label.slice(0, labelBudget)}${ELLIPSIS}` });
}
break;
}
return shown;
});
return (
<box
flexDirection="row"
paddingLeft={1}
paddingRight={1}
overflow="hidden"
ref={(el) => {
container = el;
remeasureNextFrame();
}}
>
<For each={visible()}>
{(entry, i) => (
<>
{i() > 0 && <text fg={muted()}>{SEPARATOR}</text>}
<text fg={accent()} attributes={TextAttributes.BOLD}>
{entry.keys}
</text>
<text fg={muted()}>{` ${entry.label}`}</text>
</>
)}
</For>
</box>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { type ColorInput, TextAttributes } from "@opentui/core";
import { formatCommandBindings } from "@opentui/keymap/extras";
import { useKeymapSelector } from "@opentui/keymap/solid";
import { helpOpen, toggleHelp } from "../context/help.ts";
import { Modal } from "./Modal.tsx";
const KEY_DISPLAY = { up: "↑", down: "↓", left: "←", right: "→", enter: "Enter", escape: "Esc" };
export interface HelpModalProps {
accentColor?: ColorInput;
backgroundColor?: ColorInput;
backdropColor?: ColorInput;
width?: number;
}
interface HelpEntry {
command: string;
keys: string;
label: string;
}
export function HelpModal(props: HelpModalProps = {}) {
const entries = useKeymapSelector((keymap): HelpEntry[] =>
keymap.getCommandEntries({ visibility: "active" }).map((entry) => ({
command: entry.command.name,
keys: formatCommandBindings(entry.bindings, { keyNameAliases: KEY_DISPLAY }) ?? "",
label: typeof entry.command.desc === "string" ? entry.command.desc : entry.command.name,
})),
);
return (
<Modal
open={helpOpen()}
onDismiss={toggleHelp}
accentColor={props.accentColor}
backgroundColor={props.backgroundColor}
backdropColor={props.backdropColor}
width={props.width}
>
<text attributes={TextAttributes.BOLD}>Keybindings</text>
<text> </text>
<text>{entries().map((entry) => `${entry.keys.padEnd(10)}${entry.label}`).join("\n")}</text>
</Modal>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { ColorInput } from "@opentui/core";
import { useTerminalDimensions } from "@opentui/solid";
import { Match, Switch, type ParentProps } from "solid-js";
import { theme } from "../index.ts";
export interface MinSizeGuardProps extends ParentProps {
minWidth?: number;
minHeight?: number;
backgroundColor?: ColorInput;
textColor?: ColorInput;
}
export function MinSizeGuard(props: MinSizeGuardProps) {
const dimensions = useTerminalDimensions();
const minWidth = () => props.minWidth ?? 80;
const minHeight = () => props.minHeight ?? 24;
const tooSmall = () => dimensions().width < minWidth() || dimensions().height < minHeight();
return (
<Switch>
<Match when={tooSmall()}>
<box
width={dimensions().width}
height={dimensions().height}
alignItems="center"
justifyContent="center"
backgroundColor={props.backgroundColor ?? theme.background}
>
<text fg={props.textColor ?? theme.muted}>
{`Terminal too small\nMinimum size: ${minWidth()}x${minHeight()} — current: ${dimensions().width}x${dimensions().height}`}
</text>
</box>
</Match>
<Match when={!tooSmall()}>{props.children}</Match>
</Switch>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { type ColorInput, RGBA } from "@opentui/core";
import { Portal, useTerminalDimensions } from "@opentui/solid";
import { Show, type ParentProps } from "solid-js";
import { theme } from "../index.ts";
export interface ModalProps extends ParentProps {
open: boolean;
onDismiss?: () => void;
accentColor?: ColorInput;
backgroundColor?: ColorInput;
backdropColor?: ColorInput;
width?: number;
}
export function Modal(props: ModalProps) {
const dimensions = useTerminalDimensions();
return (
<Portal>
<Show when={props.open}>
<box
width={dimensions().width}
height={dimensions().height}
position="absolute"
top={0}
left={0}
alignItems="center"
paddingTop={Math.floor(dimensions().height / 4)}
backgroundColor={props.backdropColor ?? RGBA.fromInts(0, 0, 0, 150)}
zIndex={2000}
onMouseDown={theme.mouse ? props.onDismiss : undefined}
>
<box
width={props.width ?? 50}
border
borderStyle={theme.borderStyle}
borderColor={props.accentColor ?? theme.primary}
backgroundColor={props.backgroundColor ?? theme.background}
flexDirection="column"
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
{props.children}
</box>
</box>
</Show>
</Portal>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { ColorInput } from "@opentui/core";
import { Portal } from "@opentui/solid";
import { For } from "solid-js";
import { dismiss, notifications, type NotificationKind } from "../context/notifications.ts";
import { theme } from "../index.ts";
const KIND_LABEL: Record<NotificationKind, string> = {
info: "Info",
success: "Success",
warning: "Warning",
error: "Error",
};
export interface NotificationHostProps {
width?: number;
colors?: Partial<Record<NotificationKind, ColorInput>>;
}
export function NotificationHost(props: NotificationHostProps = {}) {
const defaultColor = (): Record<NotificationKind, ColorInput> => ({
info: theme.primary,
success: theme.success,
warning: theme.warning,
error: theme.error,
});
const colorFor = (kind: NotificationKind) => props.colors?.[kind] ?? defaultColor()[kind];
return (
<Portal>
<box position="absolute" top={1} right={1} flexDirection="column" zIndex={1000}>
<For each={notifications()}>
{(toast) => (
<box
border
borderStyle={theme.borderStyle}
borderColor={colorFor(toast.kind)}
width={props.width ?? 32}
marginBottom={1}
onMouseDown={theme.mouse ? () => dismiss(toast.id) : undefined}
>
<text fg={colorFor(toast.kind)}>{toast.title ?? KIND_LABEL[toast.kind]}</text>
<text>{toast.message}</text>
</box>
)}
</For>
</box>
</Portal>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { type ColorInput, parseColor, RGBA } from "@opentui/core";
import { createMemo, For } from "solid-js";
import { theme } from "../index.ts";
function sampleGradient(stops: ColorInput[], t: number): ColorInput {
if (stops.length === 1) return stops[0]!;
const clamped = Math.min(Math.max(t, 0), 1);
const segment = clamped * (stops.length - 1);
const i = Math.min(Math.floor(segment), stops.length - 2);
const localT = segment - i;
const [ar, ag, ab, aa] = parseColor(stops[i]!).toInts();
const [br, bg, bb, ba] = parseColor(stops[i + 1]!).toInts();
return RGBA.fromInts(
Math.round(ar + (br - ar) * localT),
Math.round(ag + (bg - ag) * localT),
Math.round(ab + (bb - ab) * localT),
Math.round(aa + (ba - aa) * localT),
);
}
export interface ProgressBarProps {
value: number;
width?: number;
color?: ColorInput | ColorInput[];
trackColor?: ColorInput;
fillChar?: string;
trackChar?: string;
}
export function ProgressBar(props: ProgressBarProps) {
const width = () => props.width ?? 20;
const stops = createMemo(() => (Array.isArray(props.color) ? props.color : [props.color ?? theme.primary]));
const filled = createMemo(() => Math.round((width() * Math.min(Math.max(props.value, 0), 100)) / 100));
const cells = createMemo(() => Array.from({ length: width() }, (_, i) => i));
return (
<box flexDirection="row">
<For each={cells()}>
{(i) => {
const isFilled = () => i < filled();
const t = width() > 1 ? i / (width() - 1) : 0;
return (
<text fg={isFilled() ? sampleGradient(stops(), t) : (props.trackColor ?? theme.muted)}>
{isFilled() ? (props.fillChar ?? "█") : (props.trackChar ?? "░")}
</text>
);
}}
</For>
</box>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { type ColorInput, type SelectOption, TextAttributes } from "@opentui/core";
import { theme } from "../index.ts";
export interface SidebarHandle {
moveUp(): void;
moveDown(): void;
selectCurrent(): void;
}
export interface SidebarProps {
title: string;
items: SelectOption[];
width?: number;
focused?: boolean;
onSelect: (option: SelectOption) => void;
onConfirm?: (option: SelectOption) => void;
ref?: (handle: SidebarHandle | null) => void;
accentColor?: ColorInput;
mutedColor?: ColorInput;
backgroundColor?: ColorInput;
textColor?: ColorInput;
focusedBackgroundColor?: ColorInput;
focusedTextColor?: ColorInput;
selectedBackgroundColor?: ColorInput;
selectedTextColor?: ColorInput;
descriptionColor?: ColorInput;
selectedDescriptionColor?: ColorInput;
}
export function Sidebar(props: SidebarProps) {
return (
<box
width={props.width ?? 24}
border
borderStyle={theme.borderStyle}
borderColor={props.focused ? (props.accentColor ?? theme.primary) : (props.mutedColor ?? theme.muted)}
flexDirection="column"
>
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<select
ref={(el) => {
props.ref?.(
el && {
moveUp: () => el.moveUp(),
moveDown: () => el.moveDown(),
selectCurrent: () => el.selectCurrent(),
},
);
}}
flexGrow={1}
focused={props.focused}
showScrollIndicator
options={props.items}
backgroundColor={props.backgroundColor}
textColor={props.textColor}
focusedBackgroundColor={props.focusedBackgroundColor}
focusedTextColor={props.focusedTextColor}
selectedBackgroundColor={props.selectedBackgroundColor}
selectedTextColor={props.selectedTextColor}
descriptionColor={props.descriptionColor}
selectedDescriptionColor={props.selectedDescriptionColor}
onChange={(_index, option) => {
if (!option) return;
props.onSelect(option);
}}
onSelect={(_index, option) => {
if (!option) return;
props.onConfirm?.(option);
}}
/>
</box>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ColorInput } from "@opentui/core";
import { For } from "solid-js";
import { theme } from "../index.ts";
const BARS = ["▁", "▃", "▅", "▇"] as const;
export interface SignalBarsProps {
signal: number;
filledColor?: ColorInput;
emptyColor?: ColorInput;
}
export function SignalBars(props: SignalBarsProps) {
const level = () => Math.floor((props.signal * BARS.length) / 100);
return (
<box flexDirection="row">
<For each={BARS}>
{(bar, i) => <text fg={i() < level() ? props.filledColor : (props.emptyColor ?? theme.muted)}>{bar}</text>}
</For>
</box>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ColorInput } from "@opentui/core";
import "opentui-spinner/solid";
import type { ColorGenerator, SpinnerOptions } from "opentui-spinner";
import { theme } from "../index.ts";
export interface SpinnerProps {
name?: SpinnerOptions["name"];
frames?: string[];
interval?: number;
autoplay?: boolean;
color?: ColorInput | ColorGenerator;
backgroundColor?: ColorInput;
}
export function Spinner(props: SpinnerProps) {
return (
<spinner
name={props.name}
frames={props.frames}
interval={props.interval}
autoplay={props.autoplay ?? !theme.reducedMotion}
color={props.color ?? theme.primary}
backgroundColor={props.backgroundColor}
/>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { type ColorInput, CliRenderEvents, TextAttributes, type BoxRenderable } from "@opentui/core";
import { onResize, useRenderer } from "@opentui/solid";
import { createMemo, createSignal, For } from "solid-js";
import { theme } from "../index.ts";
export interface TabItem {
value: string;
label: string;
}
export interface TabsProps {
items: TabItem[];
value: string;
onChange: (value: string) => void;
focused?: boolean;
accentColor?: ColorInput;
mutedColor?: ColorInput;
}
export function stepTabValue(items: TabItem[], value: string, delta: number): string {
const index = items.findIndex((t) => t.value === value);
if (index === -1) return value;
return items[(index + delta + items.length) % items.length]!.value;
}
function innerWidth(label: string): number {
return label.length + 2;
}
function segmentWidth(label: string): number {
return innerWidth(label) + 2;
}
function topEdge(label: string): string {
return `╭${"─".repeat(innerWidth(label))}╮`;
}
function bottomEdge(label: string, isFirst: boolean, isLast: boolean, isActive: boolean): string {
const left = isFirst ? (isActive ? "│" : "├") : isActive ? "╯" : "┴";
const right = isLast ? (isActive ? "│" : "┤") : isActive ? "╰" : "┴";
const fill = isActive ? " " : "─";
return left + fill.repeat(innerWidth(label)) + right;
}
function buildBottomRow(segments: { label: string; isActive: boolean }[], available: number): string {
const tabsWidth = segments.reduce((sum, s) => sum + segmentWidth(s.label), 0);
const remainder = available - tabsWidth;
const extending = remainder > 0;
const tabs = segments
.map((s, i) => bottomEdge(s.label, i === 0, i === segments.length - 1 && !extending, s.isActive))
.join("");
return extending ? tabs + "─".repeat(remainder - 1) + "╮" : tabs;
}
function fitCount(items: TabItem[], start: number, available: number): number {
const hiddenBefore = start;
let used = 0;
let count = 0;
for (let i = start; i < items.length; i++) {
const width = segmentWidth(items[i]!.label);
const hiddenAfter = items.length - start - count - 1;
const totalHidden = hiddenBefore + hiddenAfter;
const reserve = totalHidden > 0 ? segmentWidth(`+${totalHidden}`) : 0;
if (used + width + reserve > available) break;
used += width;
count++;
}
return Math.max(count, 1);
}
export function Tabs(props: TabsProps) {
const renderer = useRenderer();
const [width, setWidth] = createSignal(0);
let container: BoxRenderable | undefined;
const measure = () => {
if (!container) return;
setWidth(container.width);
};
const remeasureNextFrame = () => renderer.once(CliRenderEvents.FRAME, measure);
onResize(remeasureNextFrame);
const segments = createMemo(() => {
const items = props.items;
const activeIndex = Math.max(
0,
items.findIndex((t) => t.value === props.value),
);
let start = 0;
let count = fitCount(items, start, width());
if (activeIndex >= start + count) {
start = activeIndex;
count = fitCount(items, start, width());
}
const shownItems = items.slice(start, start + count);
const shown = shownItems.map((item) => ({
value: item.value as string | undefined,
label: item.label,
isActive: item.value === props.value,
}));
const hidden = items.length - count;
if (hidden > 0) {
const used = shownItems.reduce((sum, item) => sum + segmentWidth(item.label), 0);
const tag = `+${hidden}`;
if (shown.length === 0 || used + segmentWidth(tag) <= width()) {
shown.push({ value: undefined, label: tag, isActive: false });
}
}
return shown;
});
const accent = () => props.accentColor ?? theme.primary;
const muted = () => props.mutedColor ?? theme.muted;
const borderColor = () => (props.focused ? accent() : muted());
return (
<box
flexDirection="column"
ref={(el) => {
container = el;
remeasureNextFrame();
}}
>
<text fg={borderColor()}>{segments().map((s) => topEdge(s.label)).join("")}</text>
<box flexDirection="row">
<For each={segments()}>
{(s) => (
<>
<text fg={borderColor()}>│</text>
<text
fg={s.isActive ? accent() : muted()}
attributes={s.isActive ? TextAttributes.BOLD : undefined}
onMouseDown={theme.mouse && s.value !== undefined ? () => props.onChange(s.value!) : undefined}
>
{` ${s.label} `}
</text>
<text fg={borderColor()}>│</text>
</>
)}
</For>
</box>
<text fg={borderColor()}>{buildBottomRow(segments(), width())}</text>
</box>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { type ColorInput, TextAttributes } from "@opentui/core";
import { Show } from "solid-js";
import { theme } from "../index.ts";
import { Badge } from "./Badge.tsx";
export interface ToggleProps {
on: boolean;
onToggle?: () => void;
onColor?: ColorInput;
offColor?: ColorInput;
withNerdfont?: boolean;
}
export function Toggle(props: ToggleProps) {
const onColor = () => props.onColor ?? theme.success;
const offColor = () => props.offColor ?? theme.muted;
const nerdFonts = () => props.withNerdfont ?? theme.nerdFonts;
return (
<box onMouseDown={theme.mouse ? props.onToggle : undefined}>
<Show
when={nerdFonts()}
fallback={
<text fg={props.on ? onColor() : offColor()} attributes={TextAttributes.BOLD}>
{props.on ? "on" : "off"}
</text>
}
>
<Badge label={props.on ? " ●" : "● "} color={props.on ? onColor() : offColor()} withNerdfont />
</Show>
</box>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { HelpBar, type HelpBarProps } from "./HelpBar.tsx";
export { HelpModal, type HelpModalProps } from "./HelpModal.tsx";
+9
View File
@@ -0,0 +1,9 @@
export { Badge, type BadgeProps } from "./Badge.tsx";
export { MinSizeGuard, type MinSizeGuardProps } from "./MinSizeGuard.tsx";
export { Modal, type ModalProps } from "./Modal.tsx";
export { NotificationHost, type NotificationHostProps } from "./NotificationHost.tsx";
export { ProgressBar, type ProgressBarProps } from "./ProgressBar.tsx";
export { Sidebar, type SidebarHandle, type SidebarProps } from "./Sidebar.tsx";
export { SignalBars, type SignalBarsProps } from "./SignalBars.tsx";
export { stepTabValue, Tabs, type TabItem, type TabsProps } from "./Tabs.tsx";
export { Toggle, type ToggleProps } from "./Toggle.tsx";
+109
View File
@@ -0,0 +1,109 @@
export interface ColorsYAML {
base00?: string;
base01?: string;
base02?: string;
base03?: string;
base04?: string;
base05?: string;
base06?: string;
base07?: string;
base08?: string;
base09?: string;
base0a?: string;
base0b?: string;
base0c?: string;
base0d?: string;
base0e?: string;
base0f?: string;
}
export interface ConfigYAML {
nerd_fonts?: boolean;
emoji_fonts?: boolean;
reduced_motion?: boolean;
mouse?: boolean;
border?: string;
colors?: ColorsYAML;
}
export const COLOR_KEYS = [
"base00",
"base01",
"base02",
"base03",
"base04",
"base05",
"base06",
"base07",
"base08",
"base09",
"base0a",
"base0b",
"base0c",
"base0d",
"base0e",
"base0f",
] as const;
export type ResolvedColors = Record<(typeof COLOR_KEYS)[number], string>;
export function mergeColors(
base: ColorsYAML,
user: ColorsYAML,
): ResolvedColors {
const merged = {} as ResolvedColors;
for (const key of COLOR_KEYS) {
merged[key] = user[key] || base[key] || "";
}
return merged;
}
export interface ResolvedConfig {
nerdFonts: boolean;
emojiFonts: boolean;
reducedMotion: boolean;
mouse: boolean;
border: string;
colors: ResolvedColors;
}
export function mergeConfig(
base: ConfigYAML,
user: ConfigYAML,
): ResolvedConfig {
return {
nerdFonts: user.nerd_fonts ?? base.nerd_fonts ?? false,
emojiFonts: user.emoji_fonts ?? base.emoji_fonts ?? false,
reducedMotion: user.reduced_motion ?? base.reduced_motion ?? false,
mouse: user.mouse ?? base.mouse ?? true,
border: user.border || base.border || "rounded",
colors: mergeColors(base.colors ?? {}, user.colors ?? {}),
};
}
export function normalizeColor(hex: string): string {
const trimmed = hex.trim();
if (trimmed && !trimmed.startsWith("#")) return `#${trimmed}`;
return trimmed;
}
export function normalizeColors(colors: ResolvedColors): ResolvedColors {
const out = {} as ResolvedColors;
for (const key of COLOR_KEYS) out[key] = normalizeColor(colors[key]);
return out;
}
export type OpenTUIBorderStyle = "single" | "double" | "rounded" | "heavy";
const OPENTUI_BORDER_STYLES: readonly OpenTUIBorderStyle[] = [
"single",
"double",
"rounded",
"heavy",
];
export function resolveBorderStyle(name: string): OpenTUIBorderStyle {
return (OPENTUI_BORDER_STYLES as readonly string[]).includes(name)
? (name as OpenTUIBorderStyle)
: "rounded";
}
+8
View File
@@ -0,0 +1,8 @@
import { createSignal } from "solid-js";
const [helpOpen, setHelpOpen] = createSignal(false);
export { helpOpen };
export function toggleHelp(): void {
setHelpOpen((open) => !open);
}
+20
View File
@@ -0,0 +1,20 @@
import { createContext, useContext, type ParentProps } from "solid-js";
export function createSimpleContext<T, Props extends Record<string, any> = {}>(input: {
name: string;
init: (props: Props) => T;
}) {
const ctx = createContext<T>();
return {
provider: (props: ParentProps<Props>) => {
const value = input.init(props);
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
},
use() {
const value = useContext(ctx);
if (!value) throw new Error(`${input.name} context must be used within its provider`);
return value;
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export { createSimpleContext } from "./helper.tsx";
export { notify, dismiss, notifications, type Notification, type NotificationKind, type NotifyOptions } from "./notifications.ts";
export { helpOpen, toggleHelp } from "./help.ts";
+42
View File
@@ -0,0 +1,42 @@
import { createSignal } from "solid-js";
export type NotificationKind = "info" | "success" | "warning" | "error";
export interface Notification {
id: string;
title?: string;
message: string;
kind: NotificationKind;
}
export interface NotifyOptions {
id?: string;
title?: string;
kind?: NotificationKind;
duration?: number;
}
const DEFAULT_DURATION = 3000;
const [notifications, setNotifications] = createSignal<Notification[]>([]);
export { notifications };
let nextId = 0;
export function notify(message: string, options: NotifyOptions = {}): string {
const id = options.id ?? `toast-${nextId++}`;
const toast: Notification = { id, title: options.title, message, kind: options.kind ?? "info" };
setNotifications((prev) => [...prev.filter((n) => n.id !== id), toast]);
const duration = options.duration ?? DEFAULT_DURATION;
if (duration > 0) {
setTimeout(() => dismiss(id), duration);
}
return id;
}
export function dismiss(id: string): void {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}
+10 -9
View File
@@ -2,19 +2,20 @@
# Copy to ~/.config/ilovetui/config.yaml and edit to customize. # Copy to ~/.config/ilovetui/config.yaml and edit to customize.
# Whether components may use Nerd Font glyphs (requires a patched font). # Whether components may use Nerd Font glyphs (requires a patched font).
# Leave false for maximum terminal/font compatibility. nerd_fonts: true
nerd_fonts: false
# Whether the terminal font can render emoji glyphs.
emoji_fonts: true
# Disable spinner animation and other motion across all ilovetui-based TUIs.
reduced_motion: false
# Whether components may register mouse handlers (e.g. click-to-dismiss).
mouse: true
# Border style used by panels across all ilovetui-based TUIs. # Border style used by panels across all ilovetui-based TUIs.
# One of: rounded, normal, thick, double, hidden, ascii.
border: rounded border: rounded
# How sidebar+content layouts draw their borders. One of:
# full: both the sidebar and content panes get a full titled border.
# sidebar: only the sidebar gets a full border
# separator: neither pane is bordered; a single vertical line divides them.
layout_border: full
colors: colors:
base00: "#110F12" # Background base00: "#110F12" # Background
base01: "#1C1920" # Lighter Background / Status Bars base01: "#1C1920" # Lighter Background / Status Bars
+84
View File
@@ -0,0 +1,84 @@
import { join } from "node:path";
import {
type ConfigYAML,
type OpenTUIBorderStyle,
mergeConfig,
normalizeColors,
resolveBorderStyle,
} from "./config.ts";
import { buildPresets } from "./presets.ts";
import { configPath, readYamlFile } from "./yaml.ts";
export interface Theme {
base00: string;
base01: string;
base02: string;
base03: string;
base04: string;
base05: string;
base06: string;
base07: string;
base08: string;
base09: string;
base0a: string;
base0b: string;
base0c: string;
base0d: string;
base0e: string;
base0f: string;
background: string;
subtleBg: string;
selection: string;
subtle: string;
muted: string;
text: string;
primary: string;
success: string;
warning: string;
error: string;
nerdFonts: boolean;
emojiFonts: boolean;
reducedMotion: boolean;
mouse: boolean;
borderStyle: OpenTUIBorderStyle;
}
export { configPath } from "./yaml.ts";
function buildTheme(): Theme {
const defaultConfigPath = join(import.meta.dir, "default.yaml");
const base = readYamlFile<ConfigYAML>(defaultConfigPath) ?? {};
const user = readYamlFile<ConfigYAML>(configPath()) ?? {};
const merged = mergeConfig(base, user);
const colors = normalizeColors(merged.colors);
return {
...colors,
background: colors.base00,
subtleBg: colors.base01,
selection: colors.base02,
subtle: colors.base03,
muted: colors.base04,
text: colors.base05,
primary: colors.base0d,
success: colors.base0b,
warning: colors.base09,
error: colors.base08,
nerdFonts: merged.nerdFonts,
emojiFonts: merged.emojiFonts,
reducedMotion: merged.reducedMotion,
mouse: merged.mouse,
borderStyle: resolveBorderStyle(merged.border),
};
}
export const theme: Theme = buildTheme();
export const presets = buildPresets(theme);
export type { OpenTUIBorderStyle } from "./config.ts";
export type { Presets, SelectPreset, TabSelectPreset, InputPreset, BoxPreset } from "./presets.ts";
+15
View File
@@ -0,0 +1,15 @@
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui";
import { registerCommaBindings } from "@opentui/keymap/addons";
import { KeymapProvider as BaseKeymapProvider } from "@opentui/keymap/solid";
import { useRenderer } from "@opentui/solid";
import { onCleanup, type ParentProps } from "solid-js";
export { useKeymap, useBindings, useKeymapSelector, reactiveMatcherFromSignal } from "@opentui/keymap/solid";
export function KeymapProvider(props: ParentProps) {
const renderer = useRenderer();
const keymap = createDefaultOpenTuiKeymap(renderer);
onCleanup(registerCommaBindings(keymap));
return <BaseKeymapProvider keymap={keymap}>{props.children}</BaseKeymapProvider>;
}
+87
View File
@@ -0,0 +1,87 @@
import type { OpenTUIBorderStyle, Theme } from "./index.ts";
export interface SelectPreset {
backgroundColor: string;
textColor: string;
focusedBackgroundColor: string;
focusedTextColor: string;
selectedBackgroundColor: string;
selectedTextColor: string;
descriptionColor: string;
selectedDescriptionColor: string;
}
export interface TabSelectPreset {
backgroundColor: string;
textColor: string;
focusedBackgroundColor: string;
focusedTextColor: string;
selectedBackgroundColor: string;
selectedTextColor: string;
selectedDescriptionColor: string;
}
export interface InputPreset {
backgroundColor: string;
textColor: string;
focusedBackgroundColor: string;
focusedTextColor: string;
placeholderColor: string;
}
export interface BoxPreset {
border: true;
borderStyle: OpenTUIBorderStyle;
borderColor: string;
focusedBorderColor: string;
backgroundColor: string;
}
export interface Presets {
select: SelectPreset;
tabSelect: TabSelectPreset;
input: InputPreset;
textarea: InputPreset;
box: BoxPreset;
}
export function buildPresets(theme: Theme): Presets {
const select: SelectPreset = {
backgroundColor: theme.background,
textColor: theme.text,
focusedBackgroundColor: theme.subtleBg,
focusedTextColor: theme.text,
selectedBackgroundColor: theme.selection,
selectedTextColor: theme.primary,
descriptionColor: theme.muted,
selectedDescriptionColor: theme.text,
};
const tabSelect: TabSelectPreset = {
backgroundColor: theme.background,
textColor: theme.text,
focusedBackgroundColor: theme.subtleBg,
focusedTextColor: theme.text,
selectedBackgroundColor: theme.selection,
selectedTextColor: theme.primary,
selectedDescriptionColor: theme.text,
};
const input: InputPreset = {
backgroundColor: theme.background,
textColor: theme.text,
focusedBackgroundColor: theme.subtleBg,
focusedTextColor: theme.text,
placeholderColor: theme.muted,
};
const box: BoxPreset = {
border: true,
borderStyle: theme.borderStyle,
borderColor: theme.subtle,
focusedBorderColor: theme.primary,
backgroundColor: theme.background,
};
return { select, tabSelect, input, textarea: input, box };
}
+100
View File
@@ -0,0 +1,100 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { parse as parseYAML } from "yaml";
import { z } from "zod";
import { notify } from "./context/notifications.ts";
export interface ProjectConfigOptions<TDefault, TUser> {
projectName: string;
defaultConfigPath: string;
defaultSchema: z.ZodType<TDefault>;
userSchema: z.ZodType<TUser>;
}
export interface ProjectConfig<TDefault, TUser> {
defaults: TDefault;
user: TUser;
}
function userConfigPath(projectName: string): string {
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
return join(configHome, projectName, "config.yaml");
}
function readYaml(path: string): unknown {
return parseYAML(readFileSync(path, "utf-8"));
}
function warn(projectName: string, path: string, message: string): void {
const text = `${path}: ${message}`;
console.error(`[${projectName} config] ${text}`);
notify(text, { kind: "warning", duration: 0 });
}
export function loadProjectConfig<TDefault, TUser>(
options: ProjectConfigOptions<TDefault, TUser>,
): ProjectConfig<TDefault, TUser> {
const { projectName, defaultConfigPath, defaultSchema, userSchema } = options;
let rawDefaults: unknown;
try {
rawDefaults = readYaml(defaultConfigPath);
} catch (error) {
throw new Error(`Failed to read ${defaultConfigPath}: ${error instanceof Error ? error.message : error}`);
}
const defaultsResult = defaultSchema.safeParse(rawDefaults);
if (!defaultsResult.success) {
throw new Error(`${defaultConfigPath} is invalid:\n${z.prettifyError(defaultsResult.error)}`);
}
const path = userConfigPath(projectName);
const user = ((): TUser => {
if (!existsSync(path)) return {} as TUser;
let raw: unknown;
try {
raw = readYaml(path);
} catch (error) {
warn(projectName, path, error instanceof Error ? error.message : String(error));
return {} as TUser;
}
const result = userSchema.safeParse(raw ?? {});
if (!result.success) {
warn(projectName, path, z.prettifyError(result.error));
return {} as TUser;
}
return result.data;
})();
return { defaults: defaultsResult.data, user };
}
export function keybindsSchema<TName extends string>(
names: readonly TName[],
required: true,
): z.ZodObject<Record<TName, z.ZodString>>;
export function keybindsSchema<TName extends string>(
names: readonly TName[],
required: false,
): z.ZodObject<Record<TName, z.ZodOptional<z.ZodString>>>;
export function keybindsSchema<TName extends string>(names: readonly TName[], required: boolean) {
const shape = {} as Record<TName, z.ZodTypeAny>;
for (const name of names) {
shape[name] = required ? z.string().min(1) : z.string().min(1).optional();
}
return z.object(shape).strict();
}
export function mergeKeybinds<TName extends string>(
names: readonly TName[],
defaults: Record<TName, string>,
user: Partial<Record<TName, string>> | undefined,
): Record<TName, string> {
const merged = {} as Record<TName, string>;
for (const name of names) {
merged[name] = user?.[name] ?? defaults[name];
}
return merged;
}
+39
View File
@@ -0,0 +1,39 @@
import { extend } from "@opentui/solid";
import {
ASCIIFontRenderable,
BoxRenderable,
InputRenderable,
SelectRenderable,
TabSelectRenderable,
TextareaRenderable,
TextRenderable,
} from "@opentui/core";
import { presets, theme } from "./index.ts";
type AnyCtor = new (...args: any[]) => any;
function withThemeDefaults<T extends AnyCtor>(Ctor: T, defaults: object, after?: (instance: any) => void): T {
class Themed extends Ctor {
constructor(...args: any[]) {
super(...args);
Object.assign(this, defaults);
after?.(this);
}
}
return Themed as T;
}
const { borderStyle, borderColor, focusedBorderColor } = presets.box;
const boxDefaults = { borderStyle, borderColor, focusedBorderColor };
extend({
box: withThemeDefaults(BoxRenderable, boxDefaults, (instance) => {
instance.border = false;
}),
select: withThemeDefaults(SelectRenderable, presets.select),
tab_select: withThemeDefaults(TabSelectRenderable, presets.tabSelect),
input: withThemeDefaults(InputRenderable, presets.input),
textarea: withThemeDefaults(TextareaRenderable, presets.textarea),
text: withThemeDefaults(TextRenderable, { fg: theme.text }),
ascii_font: withThemeDefaults(ASCIIFontRenderable, { color: theme.primary }),
});
+19
View File
@@ -0,0 +1,19 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { parse as parseYAML } from "yaml";
export function configPath(): string {
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
return join(configDir, "ilovetui", "config.yaml");
}
export function readYamlFile<T>(path: string): T | undefined {
if (!existsSync(path)) return undefined;
try {
return (parseYAML(readFileSync(path, "utf8")) as T) ?? undefined;
} catch (err) {
console.error(`ilovetui: failed to parse ${path}, ignoring it.`, err);
return undefined;
}
}
-18
View File
@@ -1,18 +0,0 @@
# 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`.
-117
View File
@@ -1,117 +0,0 @@
package style
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
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()
}
type LayoutBorderMode string
const (
LayoutBorderFull LayoutBorderMode = "full"
LayoutBorderSidebar LayoutBorderMode = "sidebar"
LayoutBorderSeparator LayoutBorderMode = "separator"
)
func resolveLayoutBorder(name string) LayoutBorderMode {
switch strings.ToLower(strings.TrimSpace(name)) {
case string(LayoutBorderSidebar):
return LayoutBorderSidebar
case string(LayoutBorderSeparator):
return LayoutBorderSeparator
default:
return LayoutBorderFull
}
}
func ContentHeight(totalH int) int {
h := totalH - 2
if h < 0 {
return 0
}
return h
}
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
boxH := height - 1
if contentH := boxH - 1; contentH > 0 {
lines := strings.Split(content, "\n")
if len(lines) > contentH {
content = strings.Join(lines[:contentH], "\n")
}
}
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
b, _, _, _, _ := border.GetBorder()
topLeft, top, topRight := b.TopLeft, b.Top, b.TopRight
overhead := lipgloss.Width(topLeft) + lipgloss.Width(topRight) + 2
maxTitleW := max(boxWidth-overhead, 0)
if titleW := lipgloss.Width(title); titleW > maxTitleW {
title = ansi.Truncate(title, maxTitleW, "")
}
titleW := lipgloss.Width(title)
fillW := boxWidth - titleW - overhead
if fillW < 0 {
fillW = 0
}
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
topLine := bc.Render(topLeft+" ") + bc.Render(title) + bc.Render(" "+strings.Repeat(top, fillW)+topRight)
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
}
func RenderPlain(border lipgloss.Style, title, content string, width, height int) string {
if titleW := lipgloss.Width(title); titleW > width {
title = ansi.Truncate(title, width, "")
}
titleLine := lipgloss.NewStyle().
Bold(true).
Foreground(border.GetBorderTopForeground()).
Width(width).
Render(title)
contentH := max(height-1, 0)
lines := strings.Split(content, "\n")
if len(lines) > contentH {
content = strings.Join(lines[:contentH], "\n")
}
box := lipgloss.NewStyle().Width(width).Height(contentH).Render(content)
return lipgloss.JoinVertical(lipgloss.Left, titleLine, box)
}
func VerticalDivider(border lipgloss.Style, height int) string {
b, _, _, _, _ := border.GetBorder()
ch := b.Left
if ch == "" {
ch = "│"
}
line := lipgloss.NewStyle().Foreground(border.GetBorderLeftForeground()).Render(ch)
rows := make([]string, max(height, 0))
for i := range rows {
rows[i] = line
}
return strings.Join(rows, "\n")
}
-65
View File
@@ -1,65 +0,0 @@
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"`
LayoutBorder string `yaml:"layout_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),
LayoutBorder: pickString(base.LayoutBorder, user.LayoutBorder),
}
}
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),
}
}
-241
View File
@@ -1,241 +0,0 @@
package style
import (
"fmt"
"image/color"
"charm.land/glamour/v2/ansi"
)
func hexColor(c color.Color) *string {
r, g, b, _ := c.RGBA()
s := fmt.Sprintf("#%02X%02X%02X", r>>8, g>>8, b>>8)
return &s
}
func GlamourStyleConfig() ansi.StyleConfig {
str := func(s string) *string { return &s }
boolPtr := func(b bool) *bool { return &b }
uintPtr := func(u uint) *uint { return &u }
hex := hexColor
return ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "\n",
BlockSuffix: "\n",
Color: hex(S.Base05),
},
Margin: uintPtr(2),
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: hex(S.Base03),
Italic: boolPtr(true),
},
Indent: uintPtr(1),
IndentToken: str("│ "),
},
List: ansi.StyleList{
LevelIndent: 2,
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
Color: hex(S.Base0D),
Bold: boolPtr(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: " ",
Suffix: " ",
Color: hex(S.Base07),
BackgroundColor: hex(S.Base0D),
Bold: boolPtr(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
Color: hex(S.Base0D),
Bold: boolPtr(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
Color: hex(S.Base0C),
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
Color: hex(S.Base0B),
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
Color: hex(S.Base09),
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
Color: hex(S.Base08),
Bold: boolPtr(false),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: boolPtr(true),
},
Emph: ansi.StylePrimitive{
Italic: boolPtr(true),
},
Strong: ansi.StylePrimitive{
Bold: boolPtr(true),
},
HorizontalRule: ansi.StylePrimitive{
Color: hex(S.Base03),
Format: "\n--------\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
},
Task: ansi.StyleTask{
Ticked: "[✓] ",
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
Color: hex(S.Base0C),
Underline: boolPtr(true),
},
LinkText: ansi.StylePrimitive{
Color: hex(S.Base0D),
Bold: boolPtr(true),
},
Image: ansi.StylePrimitive{
Color: hex(S.Base0C),
Underline: boolPtr(true),
},
ImageText: ansi.StylePrimitive{
Color: hex(S.Base04),
Format: "Image: {{.text}} ->",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: " ",
Suffix: " ",
Color: hex(S.Base0B),
BackgroundColor: hex(S.Base01),
},
},
CodeBlock: ansi.StyleCodeBlock{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: hex(S.Base04),
},
Margin: uintPtr(2),
},
Chroma: &ansi.Chroma{
Text: ansi.StylePrimitive{
Color: hex(S.Base05),
},
Error: ansi.StylePrimitive{
Color: hex(S.Base07),
BackgroundColor: hex(S.Base08),
},
Comment: ansi.StylePrimitive{
Color: hex(S.Base03),
Italic: boolPtr(true),
},
CommentPreproc: ansi.StylePrimitive{
Color: hex(S.Base09),
},
Keyword: ansi.StylePrimitive{
Color: hex(S.Base0E),
},
KeywordReserved: ansi.StylePrimitive{
Color: hex(S.Base0E),
},
KeywordNamespace: ansi.StylePrimitive{
Color: hex(S.Base0D),
},
KeywordType: ansi.StylePrimitive{
Color: hex(S.Base0A),
},
Operator: ansi.StylePrimitive{
Color: hex(S.Base05),
},
Punctuation: ansi.StylePrimitive{
Color: hex(S.Base05),
},
Name: ansi.StylePrimitive{
Color: hex(S.Base05),
},
NameBuiltin: ansi.StylePrimitive{
Color: hex(S.Base0D),
},
NameTag: ansi.StylePrimitive{
Color: hex(S.Base08),
},
NameAttribute: ansi.StylePrimitive{
Color: hex(S.Base0A),
},
NameClass: ansi.StylePrimitive{
Color: hex(S.Base0A),
Bold: boolPtr(true),
Underline: boolPtr(true),
},
NameConstant: ansi.StylePrimitive{
Color: hex(S.Base09),
},
NameDecorator: ansi.StylePrimitive{
Color: hex(S.Base0C),
},
NameFunction: ansi.StylePrimitive{
Color: hex(S.Base0D),
},
LiteralNumber: ansi.StylePrimitive{
Color: hex(S.Base09),
},
LiteralString: ansi.StylePrimitive{
Color: hex(S.Base0B),
},
LiteralStringEscape: ansi.StylePrimitive{
Color: hex(S.Base0C),
},
GenericDeleted: ansi.StylePrimitive{
Color: hex(S.Base08),
},
GenericEmph: ansi.StylePrimitive{
Italic: boolPtr(true),
},
GenericInserted: ansi.StylePrimitive{
Color: hex(S.Base0B),
},
GenericStrong: ansi.StylePrimitive{
Bold: boolPtr(true),
},
GenericSubheading: ansi.StylePrimitive{
Color: hex(S.Base04),
},
Background: ansi.StylePrimitive{
BackgroundColor: hex(S.Base01),
},
},
},
Table: ansi.StyleTable{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{},
},
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n> ",
},
}
}
-26
View File
@@ -1,26 +0,0 @@
package style
import (
"strings"
"github.com/charmbracelet/x/ansi"
)
func Overlay(base, top string, x, y int) string {
baseLines := strings.Split(base, "\n")
topLines := strings.Split(top, "\n")
for i, tl := range topLines {
row := y + i
if row < 0 || row >= len(baseLines) {
continue
}
bl := baseLines[row]
tw := ansi.StringWidth(tl)
left := ansi.Cut(bl, 0, x)
right := ansi.Cut(bl, x+tw, ansi.StringWidth(bl))
baseLines[row] = left + tl + right
}
return strings.Join(baseLines, "\n")
}
-117
View File
@@ -1,117 +0,0 @@
package style
import (
"image/color"
"strings"
"charm.land/lipgloss/v2"
)
type Styles struct {
Base00 color.Color
Base01 color.Color
Base02 color.Color
Base03 color.Color
Base04 color.Color
Base05 color.Color
Base06 color.Color
Base07 color.Color
Base08 color.Color
Base09 color.Color
Base0A color.Color
Base0B color.Color
Base0C color.Color
Base0D color.Color
Base0E color.Color
Base0F color.Color
Background color.Color
SubtleBg color.Color
Selection color.Color
Subtle color.Color
Muted color.Color
Text color.Color
Primary color.Color
Success color.Color
Warning color.Color
Error color.Color
Bold lipgloss.Style
Faint lipgloss.Style
NerdFonts bool
BorderType lipgloss.Border
LayoutBorder LayoutBorderMode
Panel lipgloss.Style
PanelFocused lipgloss.Style
PagerDotActive string
PagerDotInactive string
}
func newStyles(c colorsYAML, nerdFonts bool, borderName, layoutBorderName string) Styles {
lc := func(s string) color.Color {
s = strings.TrimSpace(s)
if s != "" && s[0] != '#' {
s = "#" + s
}
return lipgloss.Color(s)
}
b00 := lc(c.Base00)
b01 := lc(c.Base01)
b02 := lc(c.Base02)
b03 := lc(c.Base03)
b04 := lc(c.Base04)
b05 := lc(c.Base05)
b06 := lc(c.Base06)
b07 := lc(c.Base07)
b08 := lc(c.Base08)
b09 := lc(c.Base09)
b0A := lc(c.Base0A)
b0B := lc(c.Base0B)
b0C := lc(c.Base0C)
b0D := lc(c.Base0D)
b0E := lc(c.Base0E)
b0F := lc(c.Base0F)
borderType := resolveBorderType(borderName)
layoutBorder := resolveLayoutBorder(layoutBorderName)
return Styles{
Base00: b00, Base01: b01, Base02: b02, Base03: b03,
Base04: b04, Base05: b05, Base06: b06, Base07: b07,
Base08: b08, Base09: b09, Base0A: b0A, Base0B: b0B,
Base0C: b0C, Base0D: b0D, Base0E: b0E, Base0F: b0F,
Background: b00,
SubtleBg: b01,
Selection: b02,
Subtle: b03,
Muted: b04,
Text: b05,
Primary: b0D,
Success: b0B,
Warning: b09,
Error: b08,
Bold: lipgloss.NewStyle().Bold(true),
Faint: lipgloss.NewStyle().Foreground(b03).Faint(true),
NerdFonts: nerdFonts,
BorderType: borderType,
LayoutBorder: layoutBorder,
Panel: lipgloss.NewStyle().
Border(borderType).
BorderForeground(b03),
PanelFocused: lipgloss.NewStyle().
Border(borderType).
BorderForeground(b0D),
PagerDotActive: lipgloss.NewStyle().Foreground(b0D).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(b03).SetString("•").String(),
}
}
-84
View File
@@ -1,84 +0,0 @@
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, merged.LayoutBorder), nil
}
func configDir() string {
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
return dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config")
}
-15
View File
@@ -1,15 +0,0 @@
# tabs
A horizontal tab bar styled from `style.S`. Renders the bar and the `Content` frame around the
active item; the content itself runs and renders through the host, same as any other custom
component in this repo.
- `Focused()` controls the frame's border color, independent of which tab is active (that's shown
only by the title style).
- Tabs collapse into a trailing `+N` badge when they don't fit `Width`, keeping the active one
always visible.
- `WithLoop(false)` clamps navigation at either end instead of wrapping.
- The frame follows `style.S.BorderType`: corners and junctions are derived from the border's own
glyphs, not hardcoded.
See `examples/tabs`.
-454
View File
@@ -1,454 +0,0 @@
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
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"allowJs": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
}
}