29 Commits
Author SHA1 Message Date
Hadi 18668f4aea fixes & update
Signed-off-by: Hadi <[email protected]>
2026-10-04 18:46:49 +02:00
Hadi 238d764b0a ellipsis
Signed-off-by: Hadi <[email protected]>
2026-09-07 14:36:17 +02:00
Hadi b8dc56adb1 fix notifications background
Signed-off-by: Hadi <[email protected]>
2026-09-07 10:47:22 +02:00
Hadi ad619d23d5 fix focus issue
Signed-off-by: Hadi <[email protected]>
2026-09-07 10:16:22 +02:00
Hadi fc5e87c028 rebuild
Signed-off-by: Hadi <[email protected]>
2026-09-07 10:09:59 +02:00
Hadi 4dada889cb change sidebar click behavior
Signed-off-by: Hadi <[email protected]>
2026-09-07 10:08:42 +02:00
Hadi 080ed34fe4 edit width & rebuild
Signed-off-by: Hadi <[email protected]>
2026-09-03 20:46:48 +02:00
Hadi 1c1d8ead1f add maxheight & rebuild
Signed-off-by: Hadi <[email protected]>
2026-09-03 20:42:48 +02:00
Hadi 3d6bcdf0f2 fix modal & rebuild
Signed-off-by: Hadi <[email protected]>
2026-09-03 20:21:04 +02:00
Hadi 280507f7e0 add preset & rebuild
Signed-off-by: Hadi <[email protected]>
2026-09-03 16:45:05 +02:00
Hadi 6d858380c7 fix border style
Signed-off-by: Hadi <[email protected]>
2026-09-02 17:13:05 +02:00
Hadi edf72ab183 rebuild
Signed-off-by: Hadi <[email protected]>
2026-09-02 16:44:33 +02:00
Hadi a1d368088c fix tabs size
Signed-off-by: Hadi <[email protected]>
2026-09-02 16:44:26 +02:00
Hadi 20b4e7a0a5 rebuild
Signed-off-by: Hadi <[email protected]>
2026-09-02 16:07:22 +02:00
Hadi 21e2039433 edit tab length
Signed-off-by: Hadi <[email protected]>
2026-09-02 16:07:14 +02:00
Hadi 9158a070f3 rebuild
Signed-off-by: Hadi <[email protected]>
2026-09-02 15:07:42 +02:00
Hadi 5e66c5b303 edit icons
Signed-off-by: Hadi <[email protected]>
2026-09-02 15:07:39 +02:00
Hadi b329bdc074 multiple fixes
Signed-off-by: Hadi <[email protected]>
2026-09-01 16:35:10 +02:00
Hadi 038842878a precompile
Signed-off-by: Hadi <[email protected]>
2026-09-01 10:57:30 +02:00
Hadi 12305ba1f2 rewrite bubbletea -> opentui 2026-09-01 10:23:42 +02:00
Hadi 000f9997fd change funding url
Signed-off-by: Hadi <[email protected]>
2026-08-28 15:03:37 +02:00
Hadi df7bd3df0a add SignalBars
Signed-off-by: Hadi <[email protected]>
2026-08-24 21:54:12 +02:00
Hadi 68bf97efd6 add basic app components
Signed-off-by: Hadi <[email protected]>
2026-08-24 18:57:11 +02:00
Hadi 1bc3ee2821 add border layout config
Signed-off-by: Hadi <[email protected]>
2026-08-24 14:08:36 +02:00
Hadi 2fd1861339 Change overlay display to allow bubblezone to work
Signed-off-by: Hadi <[email protected]>
2026-08-23 17:42:33 +02:00
Hadi b22ce7cbf7 minimum window size package
Signed-off-by: Hadi <[email protected]>
2026-08-23 10:50:15 +02:00
Hadi 0648e7a761 app msgs
Signed-off-by: Hadi <[email protected]>
2026-08-20 20:23:15 +02:00
Hadi 55cb4be01d v0.2.1 2026-08-20 15:43:56 +02:00
Hadi 21b574eb6f Change style package, new components, ...
Signed-off-by: Hadi <[email protected]>
2026-08-18 20:10:32 +02:00
158 changed files with 4437 additions and 5504 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 @@
ko_fi: anotherhadi
-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 }}
+1 -4
View File
@@ -1,4 +1 @@
.claude/
CLAUDE.md
result/
.pre-commit-config.yaml
node_modules/
-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.
+15 -125
View File
@@ -1,139 +1,29 @@
# Ilovetui
# I Love TUI
A minimal Go library that provides a shared [Base16](https://github.com/tinted-theming/home) color theme for terminal UIs built with [bubbletea](https://github.com/charmbracelet/bubbletea) and [lipgloss](https://github.com/charmbracelet/lipgloss), plus a small collection of Bubble Tea v2 components on top of it.
Shared OpenTUI/Solid theme, components, and config helpers for my TUIs.
The idea is simple: instead of every TUI app managing its own colors, they all share one theme file so the user customizes once and every app looks consistent.
## Install
## Packages
- `github.com/anotherhadi/ilovetui/style` — the theme itself: colors, pre-built panel styles, config loading.
- `github.com/anotherhadi/ilovetui/bubbles` — themed constructors for official `bubbles/v2` components (`help`, `textarea`, `textinput`, `list`, `table`, `filepicker`, `spinner`, `progress`, `paginator`, `viewport`).
- `github.com/anotherhadi/ilovetui/tabs`, `.../helpbar`, `.../modal`, `.../drawer`, `.../notification` — custom components not found in the official `bubbles` library, styled from the same theme.
## How it works
On import, `style` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (respecting `$XDG_CONFIG_HOME`).
If no config exists, it falls back to the embedded default. The active theme is exposed as the package-level variable `S`.
```go
import "github.com/anotherhadi/ilovetui/style"
// Use colors directly
s := lipgloss.NewStyle().Foreground(style.S.Primary)
// Use pre-built panel styles
box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h)
```bash
bun add github:anotherhadi/ilovetui
```
No setup required — just import and use.
Peer dependencies, install whichever you actually use: `@opentui/core`, `@opentui/solid`, `@opentui/keymap`, `solid-js`, `opentui-spinner`.
## Installation
## Usage
```sh
go get github.com/anotherhadi/ilovetui
```ts
import { theme, presets } from "ilovetui";
import "ilovetui/solid";
```
## Theme
Entry points: `.`, `./solid`, `./context`, `./project-config`, `./keymap`, `./components`, `./components/help`, `./components/spinner`, `./components/section-header`. See the source for what each exports.
The theme follows the [Base16](https://github.com/tinted-theming/home) standard (16 colors). The library exposes both the raw palette and semantic aliases:
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.
| Alias | Base16 | Meaning |
| ------------ | ------ | --------------------------------------- |
| `Background` | Base00 | Background |
| `SubtleBg` | Base01 | Lighter Background / Status Bars |
| `Selection` | Base02 | Selection Background |
| `Subtle` | Base03 | Comments / Invisibles |
| `Muted` | Base04 | Dark Foreground / Status Bars |
| `Text` | Base05 | Default Foreground |
| `Primary` | Base0D | Functions / Methods / Headings / Accent |
| `Success` | Base0B | Strings / Success / Diff Inserted |
| `Warning` | Base09 | Integers / Constants / Booleans |
| `Error` | Base08 | Variables / Errors / Diff Deleted |
An app can layer its own theme overrides on top of that file (e.g. a `theme:` section of its own config, validated with `ThemeConfigSchema`) by calling `configureTheme(overrides)` once at startup, before the first render. Invalid theme files are reported as warning toasts and skipped; unknown keys are ignored, so other TUIs can keep their own keys in the shared file.
The default theme is `style/default.yaml`. Copy it and edit to customize:
## Focus and keymap scoping
```sh
mkdir -p ~/.config/ilovetui
cp $(go env GOPATH)/pkg/mod/github.com/anotherhadi/ilovetui*/style/default.yaml ~/.config/ilovetui/config.yaml
```
Wrap each focus region in a `<Pane>` (`focusable` when the pane itself is the focus target). Inside it, `usePane()` gives `target` — pass it to `useBindings` so the layer is only active while focus is within the pane — and `focused()` for styling. `<Modal>` takes focus while open, restores it on close, and binds its `dismissKey` (default `escape`) plus any `commands`/`bindings` you pass, scoped to itself. Note that keymap layers are ordered by `priority` then registration order, not by tree depth: keep overlapping layers on disjoint targets, or give them explicit priorities.
Or let your app write it on first run:
```go
style.WriteDefaultConfig(style.DefaultConfigPath())
```
## Pre-built styles
`S` ships with a few ready-to-use lipgloss styles:
| Field | Description |
| ---------------- | ---------------------------------------- |
| `S.Bold` | Bold text |
| `S.Faint` | Muted / dimmed text |
| `S.Panel` | Rounded border, unfocused (Subtle color) |
| `S.PanelFocused` | Rounded border, focused (Primary color) |
## Helpers
```go
// Inner usable height of a bordered panel with outer height h
inner := style.ContentHeight(h)
// Render a box with a title embedded in the top border
box := style.RenderWithTitle(style.S.PanelFocused, "Header", content, w, h)
```
## API
```go
style.Init() // Reload from default config path
style.InitFrom(path string) // Reload from a custom path
style.InitFromBytes(data []byte) // Parse raw YAML
style.DefaultConfigPath() string // ~/.config/ilovetui/config.yaml
style.WriteDefaultConfig(path) // Write default config if missing
```
## Themed official components
```go
import "github.com/anotherhadi/ilovetui/bubbles"
h := bubbles.NewHelp()
ta := bubbles.NewTextarea(false)
ti := bubbles.NewTextInput()
l := bubbles.NewList(items, width, height)
t := bubbles.NewTable()
fp := bubbles.NewFilePicker()
sp := bubbles.NewSpinner()
pr := bubbles.NewProgress()
pg := bubbles.NewPaginator()
vp := bubbles.NewViewport()
```
Each constructor mirrors the official component's own `New`, then applies `style.S` on top. Where the
official `New` takes options (`spinner`, `table`, `progress`), they're forwarded before the theme is
applied, so you can still customize behavior; anything you pass that also sets colors will be overridden
by the theme afterward.
## Custom components
```go
import "github.com/anotherhadi/ilovetui/tabs"
t := tabs.New([]tabs.Item{{Title: "First", Model: firstPane}, {Title: "Second", Model: secondPane}})
```
`tabs` renders a horizontal tab bar styled from `style.S`. The host application renders the content
below it; see [`tabs/README.md`](tabs/README.md) and `examples/tabs` for a full example.
For the other custom components, see their own README: [`helpbar`](helpbar/README.md) (responsive
help bar that reflows into as many columns as fit), [`modal`](modal/README.md) (centered popup
dialogs), [`drawer`](drawer/README.md) (left/right sidebar panels, mirroring `modal`) and
[`notification`](notification/README.md) (toast notifications).
## Projects using ilovetui
- [anotherhadi/spilltea](https://github.com/anotherhadi/spilltea): A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players. Think Burp Suite or Caido, but entirely in your terminal.
- [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).
-6
View File
@@ -1,6 +0,0 @@
// Package bubbles provides themed constructors for official bubbles/v2
// components (help, textarea, textinput, list, table, filepicker, spinner,
// progress, paginator, viewport). Each constructor mirrors the official
// component's own New function, then applies the shared ilovetui/style
// theme on top.
package bubbles
-26
View File
@@ -1,26 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/filepicker"
"github.com/anotherhadi/ilovetui/style"
)
// NewFilePicker returns a filepicker.Model styled with the active theme.
func NewFilePicker() filepicker.Model {
f := filepicker.New()
s := filepicker.DefaultStyles()
s.Cursor = s.Cursor.Foreground(style.S.Primary)
s.DisabledCursor = s.DisabledCursor.Foreground(style.S.Subtle)
s.Symlink = s.Symlink.Foreground(style.S.Warning)
s.Directory = s.Directory.Foreground(style.S.Primary)
s.File = s.File.Foreground(style.S.Text)
s.DisabledFile = s.DisabledFile.Foreground(style.S.Subtle)
s.Permission = s.Permission.Foreground(style.S.Muted)
s.Selected = s.Selected.Foreground(style.S.Primary)
s.DisabledSelected = s.DisabledSelected.Foreground(style.S.Subtle)
s.FileSize = s.FileSize.Foreground(style.S.Muted)
s.EmptyDirectory = s.EmptyDirectory.Foreground(style.S.Subtle)
f.Styles = s
return f
}
-21
View File
@@ -1,21 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/help"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// NewHelp returns a help.Model styled with the active theme.
func NewHelp() help.Model {
h := help.New()
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(style.S.Primary)
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
h.Styles.FullKey = lipgloss.NewStyle().Foreground(style.S.Primary)
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(style.S.Subtle)
return h
}
-13
View File
@@ -1,13 +0,0 @@
package bubbles
import "strings"
// SplitH splits totalHeight into top and bottom sections, accounting for the
// height of statusBar (measured by newline count).
func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
statusH := strings.Count(statusBar, "\n") + 1
available := totalHeight - statusH
top = int(float64(available) * ratio)
bottom = available - top
return
}
-54
View File
@@ -1,54 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/list"
"github.com/anotherhadi/ilovetui/style"
)
// NewList returns a list.Model styled with the active theme, using
// NewDefaultDelegate for item rendering.
func NewList(items []list.Item, width, height int) list.Model {
m := list.New(items, NewDefaultDelegate(), width, height)
m.Styles = themedListStyles()
return m
}
// themedListStyles builds list.Styles from the active theme.
func themedListStyles() list.Styles {
// isDark only affects a couple of fallback colors below, all of which
// are overridden regardless.
s := list.DefaultStyles(true)
s.Title = s.Title.Background(style.S.Primary).Foreground(style.S.Background)
s.Spinner = s.Spinner.Foreground(style.S.Primary)
s.Filter = themedTextInputStyles()
s.DefaultFilterCharacterMatch = s.DefaultFilterCharacterMatch.Foreground(style.S.Primary)
s.StatusBar = s.StatusBar.Foreground(style.S.Muted)
s.StatusEmpty = s.StatusEmpty.Foreground(style.S.Subtle)
s.StatusBarActiveFilter = s.StatusBarActiveFilter.Foreground(style.S.Text)
s.StatusBarFilterCount = s.StatusBarFilterCount.Foreground(style.S.Subtle)
s.NoItems = s.NoItems.Foreground(style.S.Subtle)
s.ArabicPagination = s.ArabicPagination.Foreground(style.S.Subtle)
s.ActivePaginationDot = s.ActivePaginationDot.Foreground(style.S.Primary)
s.InactivePaginationDot = s.InactivePaginationDot.Foreground(style.S.Subtle)
s.DividerDot = s.DividerDot.Foreground(style.S.Subtle)
return s
}
// NewDefaultDelegate returns a list.DefaultDelegate styled with the active
// theme, for use with NewList or a custom list.Model.
func NewDefaultDelegate() list.DefaultDelegate {
d := list.NewDefaultDelegate()
d.Styles.NormalTitle = d.Styles.NormalTitle.Foreground(style.S.Text)
d.Styles.NormalDesc = d.Styles.NormalDesc.Foreground(style.S.Muted)
d.Styles.SelectedTitle = d.Styles.SelectedTitle.
BorderForeground(style.S.Primary).
Foreground(style.S.Primary)
d.Styles.SelectedDesc = d.Styles.SelectedDesc.
BorderForeground(style.S.Primary).
Foreground(style.S.Primary)
d.Styles.DimmedTitle = d.Styles.DimmedTitle.Foreground(style.S.Subtle)
d.Styles.DimmedDesc = d.Styles.DimmedDesc.Foreground(style.S.SubtleBg)
d.Styles.FilterMatch = d.Styles.FilterMatch.Foreground(style.S.Primary)
return d
}
-17
View File
@@ -1,17 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/paginator"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// NewPaginator returns a dot-style paginator.Model styled with the active theme.
func NewPaginator() paginator.Model {
p := paginator.New()
p.Type = paginator.Dots
p.ActiveDot = lipgloss.NewStyle().Foreground(style.S.Primary).Render("•")
p.InactiveDot = lipgloss.NewStyle().Foreground(style.S.Subtle).Render("•")
return p
}
-19
View File
@@ -1,19 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/progress"
"github.com/anotherhadi/ilovetui/style"
)
// NewProgress returns a progress.Model with a themed fill, blending from
// style.S.Subtle to style.S.Primary. Any opts are forwarded to progress.New;
// pass progress.WithColors to override the default blend.
func NewProgress(opts ...progress.Option) progress.Model {
allOpts := append([]progress.Option{
progress.WithColors(style.S.Subtle, style.S.Primary),
}, opts...)
p := progress.New(allOpts...)
p.EmptyColor = style.S.SubtleBg
return p
}
-16
View File
@@ -1,16 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/spinner"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// NewSpinner returns a spinner.Model styled with the active theme. Any opts
// are forwarded to spinner.New before the theme is applied.
func NewSpinner(opts ...spinner.Option) spinner.Model {
s := spinner.New(opts...)
s.Style = lipgloss.NewStyle().Foreground(style.S.Primary)
return s
}
-19
View File
@@ -1,19 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/table"
"github.com/anotherhadi/ilovetui/style"
)
// NewTable returns a table.Model styled with the active theme. Any opts are
// forwarded to table.New before the theme is applied.
func NewTable(opts ...table.Option) table.Model {
t := table.New(opts...)
s := table.DefaultStyles()
s.Header = s.Header.Foreground(style.S.Primary)
s.Cell = s.Cell.Foreground(style.S.Text)
s.Selected = s.Selected.Foreground(style.S.Primary)
t.SetStyles(s)
return t
}
-33
View File
@@ -1,33 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/textarea"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// NewTextarea returns a textarea.Model styled with the active theme.
// Set showLineNumbers to true to display line numbers in the gutter.
func NewTextarea(showLineNumbers bool) textarea.Model {
ta := textarea.New()
ta.Prompt = ""
ta.ShowLineNumbers = showLineNumbers
ta.CharLimit = 0
ta.EndOfBufferCharacter = '~'
ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.Text = lipgloss.NewStyle().Foreground(style.S.Text)
ts.Focused.CursorLine = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Text)
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Primary).Bold(true)
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(style.S.Subtle)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(style.S.Muted)
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
ta.SetStyles(ts)
return ta
}
-32
View File
@@ -1,32 +0,0 @@
package bubbles
import (
"charm.land/bubbles/v2/textinput"
"github.com/anotherhadi/ilovetui/style"
)
// NewTextInput returns a textinput.Model styled with the active theme.
func NewTextInput() textinput.Model {
t := textinput.New()
t.SetStyles(themedTextInputStyles())
return t
}
// themedTextInputStyles builds textinput.Styles from the active theme.
// Shared with NewList, which themes its filter input the same way.
func themedTextInputStyles() textinput.Styles {
// isDark only affects textinput.DefaultStyles' Blurred.Text color, which
// we override below regardless.
s := textinput.DefaultStyles(true)
s.Focused.Text = s.Focused.Text.Foreground(style.S.Text)
s.Focused.Placeholder = s.Focused.Placeholder.Foreground(style.S.Subtle)
s.Focused.Suggestion = s.Focused.Suggestion.Foreground(style.S.Subtle)
s.Focused.Prompt = s.Focused.Prompt.Foreground(style.S.Primary)
s.Blurred.Text = s.Blurred.Text.Foreground(style.S.Muted)
s.Blurred.Placeholder = s.Blurred.Placeholder.Foreground(style.S.Subtle)
s.Blurred.Suggestion = s.Blurred.Suggestion.Foreground(style.S.Subtle)
s.Blurred.Prompt = s.Blurred.Prompt.Foreground(style.S.Subtle)
s.Cursor.Color = style.S.Primary
return s
}
-38
View File
@@ -1,38 +0,0 @@
package bubbles
import (
"strings"
"charm.land/bubbles/v2/viewport"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// NewViewport returns a viewport.Model with mouse wheel disabled.
func NewViewport() viewport.Model {
vp := viewport.New()
vp.MouseWheelEnabled = false
return vp
}
// ViewportView renders the viewport and appends a subtle scroll indicator
// on the last visible line when the user has not reached the bottom.
func ViewportView(vp *viewport.Model) string {
v := vp.View()
if vp.AtBottom() {
return v
}
lines := strings.Split(v, "\n")
if len(lines) == 0 {
return v
}
arrow := lipgloss.NewStyle().Foreground(style.S.Subtle).Render("↓")
arrowW := lipgloss.Width(arrow)
inner := vp.Width() - 2*arrowW
if inner < 0 {
inner = 0
}
lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow
return strings.Join(lines, "\n")
}
+273
View File
@@ -0,0 +1,273 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "ilovetui-opentui",
"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",
},
"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/core",
"@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=="],
}
}
+25
View File
@@ -0,0 +1,25 @@
// @bun
// src/context/focus.ts
import { CliRenderEvents } from "@opentui/core";
import { useRenderer } from "@opentui/solid";
import { createEffect, createSignal, onCleanup } from "solid-js";
function isInside2(node, root) {
if (!root)
return false;
for (let current = node;current; current = current.parent) {
if (current === root)
return true;
}
return false;
}
function useFocusWithin2(target) {
const renderer = useRenderer();
const [focused, setFocused] = createSignal(false);
const sync = () => setFocused(isInside2(renderer.currentFocusedRenderable, target() ?? null));
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, sync);
onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, sync));
createEffect(sync);
return focused;
}
export { isInside2, useFocusWithin2 };
+21
View File
@@ -0,0 +1,21 @@
// @bun
// src/context/notifications.ts
import { createSignal } from "solid-js";
var DEFAULT_DURATION = 3000;
var [notifications2, setNotifications] = createSignal([]);
var nextId = 0;
function notify2(message, options = {}) {
const id = options.id ?? `toast-${nextId++}`;
const toast = { 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(() => dismiss2(id), duration);
}
return id;
}
function dismiss2(id) {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}
export { notifications2, notify2, dismiss2 };
+23
View File
@@ -0,0 +1,23 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
// src/components/Box.tsx
import { spread as _$spread } from "@opentui/solid";
import { mergeProps as _$mergeProps } from "@opentui/solid";
import { memo as _$memo } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
function Box2(props) {
return (() => {
var _el$ = _$createElement("box");
_$spread(_el$, _$mergeProps(props, {
get borderStyle() {
return props.borderStyle ?? theme2.borderStyle;
}
}), false);
return _el$;
})();
}
export { Box2 };
+79
View File
@@ -0,0 +1,79 @@
// @bun
import {
notifications2,
dismiss2
} from "./chunk-6ggk5fg7.js";
import {
theme2
} from "./chunk-p6jvscj3.js";
// src/components/NotificationHost.tsx
import { effect as _$effect } from "@opentui/solid";
import { insertNode as _$insertNode } from "@opentui/solid";
import { memo as _$memo } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { Portal } from "@opentui/solid";
import { For } from "solid-js";
var KIND_LABEL = {
info: "Info",
success: "Success",
warning: "Warning",
error: "Error"
};
function NotificationHost2(props = {}) {
const defaultColor = () => ({
info: theme2.primary,
success: theme2.success,
warning: theme2.warning,
error: theme2.error
});
const colorFor = (kind) => props.colors?.[kind] ?? defaultColor()[kind];
return _$createComponent(Portal, {
get children() {
var _el$ = _$createElement("box");
_$setProp(_el$, "position", "absolute");
_$setProp(_el$, "top", 1);
_$setProp(_el$, "right", 1);
_$setProp(_el$, "flexDirection", "column");
_$setProp(_el$, "zIndex", 1000);
_$insert(_el$, _$createComponent(For, {
get each() {
return notifications2();
},
children: (toast) => (() => {
var _el$2 = _$createElement("box"), _el$3 = _$createElement("text"), _el$4 = _$createElement("text");
_$insertNode(_el$2, _el$3);
_$insertNode(_el$2, _el$4);
_$setProp(_el$2, "border", true);
_$setProp(_el$2, "marginBottom", 1);
_$insert(_el$3, () => toast.title ?? KIND_LABEL[toast.kind]);
_$insert(_el$4, () => toast.message);
_$effect((_p$) => {
var _v$ = theme2.borderStyle, _v$2 = colorFor(toast.kind), _v$3 = props.backgroundColor ?? theme2.background, _v$4 = props.width ?? 32, _v$5 = theme2.mouse ? () => dismiss2(toast.id) : undefined, _v$6 = colorFor(toast.kind);
_v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "borderStyle", _v$, _p$.e));
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$2, "borderColor", _v$2, _p$.t));
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$2, "backgroundColor", _v$3, _p$.a));
_v$4 !== _p$.o && (_p$.o = _$setProp(_el$2, "width", _v$4, _p$.o));
_v$5 !== _p$.i && (_p$.i = _$setProp(_el$2, "onMouseDown", _v$5, _p$.i));
_v$6 !== _p$.n && (_p$.n = _$setProp(_el$3, "fg", _v$6, _p$.n));
return _p$;
}, {
e: undefined,
t: undefined,
a: undefined,
o: undefined,
i: undefined,
n: undefined
});
return _el$2;
})()
}));
return _el$;
}
});
}
export { NotificationHost2 };
+121
View File
@@ -0,0 +1,121 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
// src/components/HelpBar.tsx
import { createTextNode as _$createTextNode } from "@opentui/solid";
import { insertNode as _$insertNode } from "@opentui/solid";
import { effect as _$effect } from "@opentui/solid";
import { memo as _$memo } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { use as _$use } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { CliRenderEvents, TextAttributes } 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";
var KEY_DISPLAY = {
up: "\u2191",
down: "\u2193",
left: "\u2190",
right: "\u2192",
enter: "Enter",
escape: "Esc"
};
var SEPARATOR = " ";
var ELLIPSIS = "\u2026";
function HelpBar2(props = {}) {
const accent = () => props.accentColor ?? theme2.primary;
const muted = () => props.mutedColor ?? theme2.muted;
const renderer = useRenderer();
const [width, setWidth] = createSignal(0);
let container;
const measure = () => {
if (!container)
return;
setWidth(container.width);
};
const remeasureNextFrame = () => renderer.once(CliRenderEvents.FRAME, measure);
onResize(remeasureNextFrame);
const entries = useKeymapSelector((keymap) => 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(() => {
const available = width() - 2;
const shown = [];
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 (() => {
var _el$ = _$createElement("box");
_$use((el) => {
container = el;
remeasureNextFrame();
}, _el$);
_$setProp(_el$, "flexDirection", "row");
_$setProp(_el$, "paddingLeft", 1);
_$setProp(_el$, "paddingRight", 1);
_$setProp(_el$, "overflow", "hidden");
_$insert(_el$, _$createComponent(For, {
get each() {
return visible();
},
children: (entry, i) => [_$memo(() => _$memo(() => i() > 0)() && (() => {
var _el$4 = _$createElement("text");
_$insertNode(_el$4, _$createTextNode(` `));
_$effect((_$p) => _$setProp(_el$4, "fg", muted(), _$p));
return _el$4;
})()), (() => {
var _el$2 = _$createElement("text");
_$insert(_el$2, () => entry.keys);
_$effect((_p$) => {
var _v$ = accent(), _v$2 = TextAttributes.BOLD;
_v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e));
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$2, "attributes", _v$2, _p$.t));
return _p$;
}, {
e: undefined,
t: undefined
});
return _el$2;
})(), (() => {
var _el$3 = _$createElement("text");
_$insert(_el$3, () => ` ${entry.label}`);
_$effect((_$p) => _$setProp(_el$3, "fg", muted(), _$p));
return _el$3;
})()]
}));
return _el$;
})();
}
export { HelpBar2 };
+44
View File
@@ -0,0 +1,44 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
// src/components/KeyValue.tsx
import { memo as _$memo } from "@opentui/solid";
import { effect as _$effect } from "@opentui/solid";
import { insertNode as _$insertNode } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { Show } from "solid-js";
function KeyValue2(props) {
return (() => {
var _el$ = _$createElement("box"), _el$2 = _$createElement("text");
_$insertNode(_el$, _el$2);
_$setProp(_el$, "flexShrink", 0);
_$setProp(_el$, "flexDirection", "row");
_$setProp(_el$, "justifyContent", "space-between");
_$insert(_el$2, () => props.label);
_$insert(_el$, _$createComponent(Show, {
get when() {
return props.children;
},
get fallback() {
return (() => {
var _el$3 = _$createElement("text");
_$insert(_el$3, () => props.value ?? "");
_$effect((_$p) => _$setProp(_el$3, "fg", props.valueColor, _$p));
return _el$3;
})();
},
get children() {
return props.children;
}
}), null);
_$effect((_$p) => _$setProp(_el$2, "fg", theme2.muted, _$p));
return _el$;
})();
}
export { KeyValue2 };
+9
View File
@@ -0,0 +1,9 @@
// @bun
// src/context/help.ts
import { createSignal } from "solid-js";
var [helpOpen2, setHelpOpen] = createSignal(false);
function toggleHelp2() {
setHelpOpen((open) => !open);
}
export { helpOpen2, toggleHelp2 };
+87
View File
@@ -0,0 +1,87 @@
// @bun
import {
helpOpen2,
toggleHelp2
} from "./chunk-ee2qaswx.js";
import {
Modal2
} from "./chunk-t2cszvff.js";
// src/components/HelpModal.tsx
import { createComponent as _$createComponent } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { effect as _$effect } from "@opentui/solid";
import { createTextNode as _$createTextNode } from "@opentui/solid";
import { insertNode as _$insertNode } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { TextAttributes } from "@opentui/core";
import { formatCommandBindings } from "@opentui/keymap/extras";
import { useKeymapSelector } from "@opentui/keymap/solid";
import { createSignal } from "solid-js";
var KEY_DISPLAY = {
up: "\u2191",
down: "\u2193",
left: "\u2190",
right: "\u2192",
enter: "Enter",
escape: "Esc"
};
function HelpModal2(props = {}) {
const [scope, setScope] = createSignal(null);
const entries = useKeymapSelector((keymap) => keymap.getCommandEntries({
visibility: "active",
focused: scope()
}).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 _$createComponent(Modal2, {
get open() {
return helpOpen2();
},
onDismiss: toggleHelp2,
onOpen: (previous) => setScope(() => previous),
get dismissKey() {
return props.dismissKey;
},
get accentColor() {
return props.accentColor;
},
get backgroundColor() {
return props.backgroundColor;
},
get backdropColor() {
return props.backdropColor;
},
get width() {
return props.width;
},
get maxHeight() {
return entries().length + 6;
},
scrollable: true,
get children() {
return [(() => {
var _el$ = _$createElement("text");
_$insertNode(_el$, _$createTextNode(`Keybindings`));
_$effect((_$p) => _$setProp(_el$, "attributes", TextAttributes.BOLD, _$p));
return _el$;
})(), (() => {
var _el$3 = _$createElement("text");
_$insertNode(_el$3, _$createTextNode(` `));
return _el$3;
})(), (() => {
var _el$5 = _$createElement("text");
_$insert(_el$5, () => entries().map((entry) => `${entry.keys.padEnd(10)}${entry.label}`).join(`
`));
return _el$5;
})()];
}
});
}
export { HelpModal2 };
+26
View File
@@ -0,0 +1,26 @@
// @bun
// src/context/helper.tsx
import { createComponent as _$createComponent } from "@opentui/solid";
import { createContext, useContext } from "solid-js";
function createSimpleContext2(input) {
const ctx = createContext();
return {
provider: (props) => {
const value = input.init(props);
return _$createComponent(ctx.Provider, {
value,
get children() {
return props.children;
}
});
},
use() {
const value = useContext(ctx);
if (!value)
throw new Error(`${input.name} context must be used within its provider`);
return value;
}
};
}
export { createSimpleContext2 };
+35
View File
@@ -0,0 +1,35 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
// src/components/Spinner.tsx
import { setProp as _$setProp } from "@opentui/solid";
import { effect as _$effect } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import"opentui-spinner/solid";
function Spinner2(props) {
return (() => {
var _el$ = _$createElement("spinner");
_$effect((_p$) => {
var { name: _v$, frames: _v$2, interval: _v$3 } = props, _v$4 = props.autoplay ?? !theme2.reducedMotion, _v$5 = props.color ?? theme2.primary, _v$6 = props.backgroundColor;
_v$ !== _p$.e && (_p$.e = _$setProp(_el$, "name", _v$, _p$.e));
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$, "frames", _v$2, _p$.t));
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$, "interval", _v$3, _p$.a));
_v$4 !== _p$.o && (_p$.o = _$setProp(_el$, "autoplay", _v$4, _p$.o));
_v$5 !== _p$.i && (_p$.i = _$setProp(_el$, "color", _v$5, _p$.i));
_v$6 !== _p$.n && (_p$.n = _$setProp(_el$, "backgroundColor", _v$6, _p$.n));
return _p$;
}, {
e: undefined,
t: undefined,
a: undefined,
o: undefined,
i: undefined,
n: undefined
});
return _el$;
})();
}
export { Spinner2 };
+65
View File
@@ -0,0 +1,65 @@
// @bun
// src/config.ts
import { z } from "zod";
var COLOR_KEYS2 = [
"base00",
"base01",
"base02",
"base03",
"base04",
"base05",
"base06",
"base07",
"base08",
"base09",
"base0a",
"base0b",
"base0c",
"base0d",
"base0e",
"base0f"
];
var OPENTUI_BORDER_STYLES2 = ["single", "double", "rounded", "heavy"];
var hexColor = z.string().regex(/^#?[0-9a-fA-F]{6}$/, 'expected a hex color like "#1e1e2e"');
var colorsShape = {};
for (const key of COLOR_KEYS2)
colorsShape[key] = hexColor.optional();
var ThemeConfigSchema2 = z.object({
nerd_fonts: z.boolean().optional(),
emoji_fonts: z.boolean().optional(),
reduced_motion: z.boolean().optional(),
mouse: z.boolean().optional(),
border: z.enum(OPENTUI_BORDER_STYLES2).optional(),
colors: z.object(colorsShape).optional()
});
function layerConfigs2(layers) {
const out = {};
for (const layer of layers) {
const { colors, ...rest } = layer;
Object.assign(out, rest);
if (colors)
out.colors = { ...out.colors, ...colors };
}
return out;
}
function resolveConfig2(config) {
const colors = {};
for (const key of COLOR_KEYS2)
colors[key] = normalizeColor2(config.colors?.[key] ?? "");
return {
nerdFonts: config.nerd_fonts ?? false,
emojiFonts: config.emoji_fonts ?? false,
reducedMotion: config.reduced_motion ?? false,
mouse: config.mouse ?? true,
border: config.border ?? "rounded",
colors
};
}
function normalizeColor2(hex) {
const trimmed = hex.trim();
if (trimmed && !trimmed.startsWith("#"))
return `#${trimmed}`;
return trimmed;
}
export { COLOR_KEYS2, OPENTUI_BORDER_STYLES2, ThemeConfigSchema2, layerConfigs2, resolveConfig2, normalizeColor2 };
+79
View File
@@ -0,0 +1,79 @@
// @bun
import {
ThemeConfigSchema2,
layerConfigs2,
resolveConfig2
} from "./chunk-njvj322f.js";
import {
notify2
} from "./chunk-6ggk5fg7.js";
import {
buildPresets2
} from "./chunk-yyg967q8.js";
import {
configPath2
} from "./chunk-xykm10y9.js";
// src/index.ts
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { parse as parseYAML } from "yaml";
import { z } from "zod";
function reportThemeError(path, message) {
const text = `${path}: ${message}`;
console.error(`[ilovetui theme] ${text}`);
notify2(text, { kind: "warning", duration: 0 });
}
function readThemeFile(path) {
if (!existsSync(path))
return {};
let raw;
try {
raw = parseYAML(readFileSync(path, "utf8"));
} catch (error) {
reportThemeError(path, error instanceof Error ? error.message : String(error));
return {};
}
const result = ThemeConfigSchema2.safeParse(raw ?? {});
if (!result.success) {
reportThemeError(path, z.prettifyError(result.error));
return {};
}
return result.data;
}
var fileLayers = [readThemeFile(join(import.meta.dir, "default.yaml")), readThemeFile(configPath2())];
function buildTheme(overrides) {
const resolved = resolveConfig2(layerConfigs2([...fileLayers, overrides]));
const colors = resolved.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: resolved.nerdFonts,
emojiFonts: resolved.emojiFonts,
reducedMotion: resolved.reducedMotion,
mouse: resolved.mouse,
borderStyle: resolved.border
};
}
var theme2 = buildTheme({});
var presets2 = buildPresets2(theme2);
function configureTheme2(overrides = {}) {
Object.assign(theme2, buildTheme(overrides));
const next = buildPresets2(theme2);
for (const key of Object.keys(next))
Object.assign(presets2[key], next[key]);
}
function icon2(codepoint) {
return theme2.nerdFonts ? `${codepoint} ` : "";
}
export { theme2, presets2, configureTheme2, icon2 };
+54
View File
@@ -0,0 +1,54 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
// src/components/MinSizeGuard.tsx
import { createComponent as _$createComponent } from "@opentui/solid";
import { effect as _$effect } from "@opentui/solid";
import { insertNode as _$insertNode } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { memo as _$memo } from "@opentui/solid";
import { useTerminalDimensions } from "@opentui/solid";
import { Show } from "solid-js";
function MinSizeGuard2(props) {
const dimensions = useTerminalDimensions();
const minWidth = () => props.minWidth ?? 80;
const minHeight = () => props.minHeight ?? 24;
const tooSmall = () => dimensions().width < minWidth() || dimensions().height < minHeight();
return [_$memo(() => props.children), _$createComponent(Show, {
get when() {
return tooSmall();
},
get children() {
var _el$ = _$createElement("box"), _el$2 = _$createElement("text");
_$insertNode(_el$, _el$2);
_$setProp(_el$, "position", "absolute");
_$setProp(_el$, "top", 0);
_$setProp(_el$, "left", 0);
_$setProp(_el$, "alignItems", "center");
_$setProp(_el$, "justifyContent", "center");
_$setProp(_el$, "zIndex", 9999);
_$insert(_el$2, () => `Terminal too small
Minimum size: ${minWidth()}x${minHeight()} \u2014 current: ${dimensions().width}x${dimensions().height}`);
_$effect((_p$) => {
var _v$ = dimensions().width, _v$2 = dimensions().height, _v$3 = props.backgroundColor ?? theme2.background, _v$4 = props.textColor ?? theme2.muted;
_v$ !== _p$.e && (_p$.e = _$setProp(_el$, "width", _v$, _p$.e));
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$, "height", _v$2, _p$.t));
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$, "backgroundColor", _v$3, _p$.a));
_v$4 !== _p$.o && (_p$.o = _$setProp(_el$2, "fg", _v$4, _p$.o));
return _p$;
}, {
e: undefined,
t: undefined,
a: undefined,
o: undefined
});
return _el$;
}
})];
}
export { MinSizeGuard2 };
+61
View File
@@ -0,0 +1,61 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
import {
Badge2
} from "./chunk-v9cetqzr.js";
// src/components/Toggle.tsx
import { setProp as _$setProp } from "@opentui/solid";
import { effect as _$effect } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { memo as _$memo } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { TextAttributes } from "@opentui/core";
import { Show } from "solid-js";
function Toggle2(props) {
const onColor = () => props.onColor ?? theme2.success;
const offColor = () => props.offColor ?? theme2.muted;
const nerdFonts = () => props.withNerdfont ?? theme2.nerdFonts;
return (() => {
var _el$ = _$createElement("box");
_$insert(_el$, _$createComponent(Show, {
get when() {
return nerdFonts();
},
get fallback() {
return (() => {
var _el$2 = _$createElement("text");
_$insert(_el$2, () => props.on ? "on" : "off");
_$effect((_p$) => {
var _v$ = props.on ? onColor() : offColor(), _v$2 = TextAttributes.BOLD;
_v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e));
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$2, "attributes", _v$2, _p$.t));
return _p$;
}, {
e: undefined,
t: undefined
});
return _el$2;
})();
},
get children() {
return _$createComponent(Badge2, {
get label() {
return props.on ? " \u25CF" : "\u25CF ";
},
get color() {
return _$memo(() => !!props.on)() ? onColor() : offColor();
},
withNerdfont: true
});
}
}));
_$effect((_$p) => _$setProp(_el$, "onMouseDown", theme2.mouse ? props.onToggle : undefined, _$p));
return _el$;
})();
}
export { Toggle2 };
+155
View File
@@ -0,0 +1,155 @@
// @bun
import {
theme2,
presets2
} from "./chunk-p6jvscj3.js";
// src/components/Sidebar.tsx
import { use as _$use } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { createTextNode as _$createTextNode } from "@opentui/solid";
import { insertNode as _$insertNode } from "@opentui/solid";
import { effect as _$effect } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { TextAttributes } from "@opentui/core";
import { createEffect, Show } from "solid-js";
function truncateWithEllipsis(text, maxWidth) {
if (maxWidth <= 0)
return "";
if (text.length <= maxWidth)
return text;
return text.substring(0, Math.max(0, maxWidth - 1)) + "\u2026";
}
function itemIndexAtScreenY(el, screenY) {
const internals = el;
if (!internals.linesPerItem)
return null;
const localY = screenY - el.screenY;
if (localY < 0)
return null;
const index = internals.scrollOffset + Math.floor(localY / internals.linesPerItem);
return index >= 0 && index < el.options.length ? index : null;
}
var BORDER_WIDTH = 2;
var ITEM_LEFT_PADDING = 1;
var ITEM_INDICATOR_WIDTH = 2;
function Sidebar2(props) {
let select;
const contentWidth = () => Math.max(0, (props.width ?? 24) - BORDER_WIDTH);
const itemTextWidth = () => Math.max(0, contentWidth() - ITEM_LEFT_PADDING - ITEM_INDICATOR_WIDTH);
const displayTitle = () => props.title ? truncateWithEllipsis(props.title, contentWidth()) : props.title;
const displayItems = () => props.items.map((item) => ({
...item,
name: truncateWithEllipsis(item.name, itemTextWidth()),
description: truncateWithEllipsis(item.description, itemTextWidth())
}));
const handleMouseDown = (event) => {
if (!select)
return;
const index = itemIndexAtScreenY(select, event.y);
if (index !== null) {
select.focus();
select.setSelectedIndex(index);
select.selectCurrent();
event.preventDefault();
}
};
const handleMouseScroll = (event) => {
if (!select || !event.scroll)
return;
const steps = Math.max(1, event.scroll.delta);
if (event.scroll.direction === "down")
select.moveDown(steps);
else if (event.scroll.direction === "up")
select.moveUp(steps);
};
createEffect(() => {
const index = props.items.findIndex((item) => item.value === props.value);
if (select && index !== -1 && select.getSelectedIndex() !== index)
select.setSelectedIndex(index);
});
return (() => {
var _el$ = _$createElement("box"), _el$5 = _$createElement("select");
_$insertNode(_el$, _el$5);
_$setProp(_el$, "border", true);
_$setProp(_el$, "flexDirection", "column");
_$insert(_el$, _$createComponent(Show, {
get when() {
return props.title;
},
get children() {
return [(() => {
var _el$2 = _$createElement("text");
_$insert(_el$2, displayTitle);
_$effect((_$p) => _$setProp(_el$2, "attributes", TextAttributes.BOLD, _$p));
return _el$2;
})(), (() => {
var _el$3 = _$createElement("text");
_$insertNode(_el$3, _$createTextNode(` `));
return _el$3;
})()];
}
}), _el$5);
_$use((el) => {
select = el ?? undefined;
props.ref?.(el && {
moveUp: () => el.moveUp(),
moveDown: () => el.moveDown(),
selectCurrent: () => el.selectCurrent(),
focus: () => el.focus()
});
}, _el$5);
_$setProp(_el$5, "flexGrow", 1);
_$setProp(_el$5, "showScrollIndicator", true);
_$setProp(_el$5, "onChange", (_index, option) => {
if (!option)
return;
props.onSelect(option);
});
_$setProp(_el$5, "onSelect", (_index, option) => {
if (!option)
return;
props.onConfirm?.(option);
});
_$effect((_p$) => {
var _v$ = props.width ?? 24, _v$2 = theme2.borderStyle, _v$3 = props.focused ? props.accentColor ?? theme2.primary : props.mutedColor ?? theme2.muted, _v$4 = props.focused, _v$5 = displayItems(), _v$6 = theme2.mouse ? handleMouseDown : undefined, _v$7 = theme2.mouse ? handleMouseScroll : undefined, _v$8 = props.backgroundColor ?? "transparent", _v$9 = props.textColor ?? presets2.select.textColor, _v$0 = props.focusedBackgroundColor ?? "transparent", _v$1 = props.focusedTextColor ?? presets2.select.focusedTextColor, _v$10 = props.selectedBackgroundColor ?? presets2.select.selectedBackgroundColor, _v$11 = props.selectedTextColor ?? presets2.select.selectedTextColor, _v$12 = props.descriptionColor ?? presets2.select.descriptionColor, _v$13 = props.selectedDescriptionColor ?? presets2.select.selectedDescriptionColor;
_v$ !== _p$.e && (_p$.e = _$setProp(_el$, "width", _v$, _p$.e));
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$, "borderStyle", _v$2, _p$.t));
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$, "borderColor", _v$3, _p$.a));
_v$4 !== _p$.o && (_p$.o = _$setProp(_el$5, "focused", _v$4, _p$.o));
_v$5 !== _p$.i && (_p$.i = _$setProp(_el$5, "options", _v$5, _p$.i));
_v$6 !== _p$.n && (_p$.n = _$setProp(_el$5, "onMouseDown", _v$6, _p$.n));
_v$7 !== _p$.s && (_p$.s = _$setProp(_el$5, "onMouseScroll", _v$7, _p$.s));
_v$8 !== _p$.h && (_p$.h = _$setProp(_el$5, "backgroundColor", _v$8, _p$.h));
_v$9 !== _p$.r && (_p$.r = _$setProp(_el$5, "textColor", _v$9, _p$.r));
_v$0 !== _p$.d && (_p$.d = _$setProp(_el$5, "focusedBackgroundColor", _v$0, _p$.d));
_v$1 !== _p$.l && (_p$.l = _$setProp(_el$5, "focusedTextColor", _v$1, _p$.l));
_v$10 !== _p$.u && (_p$.u = _$setProp(_el$5, "selectedBackgroundColor", _v$10, _p$.u));
_v$11 !== _p$.c && (_p$.c = _$setProp(_el$5, "selectedTextColor", _v$11, _p$.c));
_v$12 !== _p$.w && (_p$.w = _$setProp(_el$5, "descriptionColor", _v$12, _p$.w));
_v$13 !== _p$.m && (_p$.m = _$setProp(_el$5, "selectedDescriptionColor", _v$13, _p$.m));
return _p$;
}, {
e: undefined,
t: undefined,
a: undefined,
o: undefined,
i: undefined,
n: undefined,
s: undefined,
h: undefined,
r: undefined,
d: undefined,
l: undefined,
u: undefined,
c: undefined,
w: undefined,
m: undefined
});
return _el$;
})();
}
export { Sidebar2 };
+71
View File
@@ -0,0 +1,71 @@
// @bun
import {
theme2,
presets2
} from "./chunk-p6jvscj3.js";
// src/components/ListRow.tsx
import { effect as _$effect } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { insertNode as _$insertNode } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { TextAttributes } from "@opentui/core";
import { Show } from "solid-js";
function ListRow2(props) {
const tone = (fallback) => props.selected ? presets2.select.selectedTextColor : fallback;
return (() => {
var _el$ = _$createElement("box"), _el$2 = _$createElement("box"), _el$3 = _$createElement("box"), _el$4 = _$createElement("text");
_$insertNode(_el$, _el$2);
_$setProp(_el$, "flexShrink", 0);
_$setProp(_el$, "flexDirection", "column");
_$setProp(_el$, "paddingLeft", 1);
_$setProp(_el$, "paddingRight", 1);
_$insertNode(_el$2, _el$3);
_$setProp(_el$2, "flexDirection", "row");
_$setProp(_el$2, "justifyContent", "space-between");
_$setProp(_el$2, "alignItems", "center");
_$setProp(_el$2, "height", 1);
_$insertNode(_el$3, _el$4);
_$setProp(_el$3, "flexGrow", 1);
_$setProp(_el$3, "flexShrink", 1);
_$setProp(_el$3, "minWidth", 0);
_$setProp(_el$3, "marginRight", 1);
_$setProp(_el$3, "overflow", "hidden");
_$setProp(_el$4, "wrapMode", "none");
_$insert(_el$4, () => props.label);
_$insert(_el$2, _$createComponent(Show, {
get when() {
return props.right;
},
get children() {
var _el$5 = _$createElement("box");
_$setProp(_el$5, "flexShrink", 0);
_$setProp(_el$5, "flexDirection", "row");
_$setProp(_el$5, "alignItems", "center");
_$insert(_el$5, () => props.right?.(tone));
return _el$5;
}
}), null);
_$insert(_el$, () => props.below?.(tone), null);
_$effect((_p$) => {
var _v$ = props.id, _v$2 = props.selected ? presets2.select.selectedBackgroundColor : "transparent", _v$3 = theme2.mouse && props.onClick ? () => props.onClick?.() : undefined, _v$4 = tone(), _v$5 = props.bold ? TextAttributes.BOLD : undefined;
_v$ !== _p$.e && (_p$.e = _$setProp(_el$, "id", _v$, _p$.e));
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$, "backgroundColor", _v$2, _p$.t));
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$, "onMouseDown", _v$3, _p$.a));
_v$4 !== _p$.o && (_p$.o = _$setProp(_el$4, "fg", _v$4, _p$.o));
_v$5 !== _p$.i && (_p$.i = _$setProp(_el$4, "attributes", _v$5, _p$.i));
return _p$;
}, {
e: undefined,
t: undefined,
a: undefined,
o: undefined,
i: undefined
});
return _el$;
})();
}
export { ListRow2 };
+140
View File
@@ -0,0 +1,140 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
// src/components/Modal.tsx
import { insertNode as _$insertNode } from "@opentui/solid";
import { effect as _$effect } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { use as _$use } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { RGBA } from "@opentui/core";
import { useBindings } from "@opentui/keymap/solid";
import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid";
import { createSignal, onCleanup, onMount, Show } from "solid-js";
function Modal2(props) {
return _$createComponent(Portal, {
get children() {
return _$createComponent(Show, {
get when() {
return props.open;
},
get children() {
return _$createComponent(ModalPanel, props);
}
});
}
});
}
function ModalPanel(props) {
const renderer = useRenderer();
const dimensions = useTerminalDimensions();
const [panel, setPanel] = createSignal();
const screenCap = () => Math.max(5, dimensions().height - 4);
const maxPanelHeight = () => Math.min(props.maxHeight ?? screenCap(), screenCap());
const maxContentHeight = () => Math.max(1, maxPanelHeight() - 4);
const maxPanelWidth = () => Math.max(10, dimensions().width - 4);
const panelWidth = () => Math.min(props.width ?? 50, maxPanelWidth());
let previousFocus = null;
onMount(() => {
previousFocus = renderer.currentFocusedRenderable;
props.onOpen?.(previousFocus);
panel()?.focus();
});
onCleanup(() => {
if (previousFocus && !previousFocus.isDestroyed)
previousFocus.focus();
});
const dismissKey = props.dismissKey ?? "escape";
if (dismissKey !== false || props.commands || props.bindings) {
useBindings(() => ({
target: panel,
commands: [...dismissKey !== false ? [{
name: "modal_dismiss",
desc: "close",
shortHelp: true,
run: () => props.onDismiss?.()
}] : [], ...props.commands ?? []],
bindings: [...dismissKey !== false ? [{
key: dismissKey,
cmd: "modal_dismiss"
}] : [], ...props.bindings ?? []]
}));
}
return (() => {
var _el$ = _$createElement("box"), _el$2 = _$createElement("box");
_$insertNode(_el$, _el$2);
_$setProp(_el$, "position", "absolute");
_$setProp(_el$, "top", 0);
_$setProp(_el$, "left", 0);
_$setProp(_el$, "alignItems", "center");
_$setProp(_el$, "justifyContent", "center");
_$setProp(_el$, "zIndex", 2000);
_$use(setPanel, _el$2);
_$setProp(_el$2, "focusable", true);
_$setProp(_el$2, "border", true);
_$setProp(_el$2, "flexDirection", "column");
_$setProp(_el$2, "paddingLeft", 2);
_$setProp(_el$2, "paddingRight", 2);
_$setProp(_el$2, "paddingTop", 1);
_$setProp(_el$2, "paddingBottom", 1);
_$insert(_el$2, _$createComponent(Show, {
get when() {
return props.scrollable;
},
get fallback() {
return (() => {
var _el$4 = _$createElement("box");
_$setProp(_el$4, "flexDirection", "column");
_$setProp(_el$4, "overflow", "hidden");
_$insert(_el$4, () => props.children);
_$effect((_$p) => _$setProp(_el$4, "maxHeight", maxContentHeight(), _$p));
return _el$4;
})();
},
get children() {
var _el$3 = _$createElement("scrollbox");
_$setProp(_el$3, "flexShrink", 1);
_$setProp(_el$3, "scrollY", true);
_$setProp(_el$3, "scrollX", false);
_$setProp(_el$3, "focusable", false);
_$insert(_el$3, () => props.children);
_$effect((_$p) => _$setProp(_el$3, "maxHeight", maxContentHeight(), _$p));
return _el$3;
}
}));
_$effect((_p$) => {
var _v$ = dimensions().width, _v$2 = dimensions().height, _v$3 = props.backdropColor ?? RGBA.fromInts(0, 0, 0, 150), _v$4 = theme2.mouse ? props.onDismiss : undefined, _v$5 = panelWidth(), _v$6 = maxPanelHeight(), _v$7 = theme2.borderStyle, _v$8 = props.accentColor ?? theme2.primary, _v$9 = props.accentColor ?? theme2.primary, _v$0 = props.backgroundColor ?? theme2.background, _v$1 = theme2.mouse ? (event) => event.stopPropagation() : undefined;
_v$ !== _p$.e && (_p$.e = _$setProp(_el$, "width", _v$, _p$.e));
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$, "height", _v$2, _p$.t));
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$, "backgroundColor", _v$3, _p$.a));
_v$4 !== _p$.o && (_p$.o = _$setProp(_el$, "onMouseDown", _v$4, _p$.o));
_v$5 !== _p$.i && (_p$.i = _$setProp(_el$2, "width", _v$5, _p$.i));
_v$6 !== _p$.n && (_p$.n = _$setProp(_el$2, "maxHeight", _v$6, _p$.n));
_v$7 !== _p$.s && (_p$.s = _$setProp(_el$2, "borderStyle", _v$7, _p$.s));
_v$8 !== _p$.h && (_p$.h = _$setProp(_el$2, "borderColor", _v$8, _p$.h));
_v$9 !== _p$.r && (_p$.r = _$setProp(_el$2, "focusedBorderColor", _v$9, _p$.r));
_v$0 !== _p$.d && (_p$.d = _$setProp(_el$2, "backgroundColor", _v$0, _p$.d));
_v$1 !== _p$.l && (_p$.l = _$setProp(_el$2, "onMouseDown", _v$1, _p$.l));
return _p$;
}, {
e: undefined,
t: undefined,
a: undefined,
o: undefined,
i: undefined,
n: undefined,
s: undefined,
h: undefined,
r: undefined,
d: undefined,
l: undefined
});
return _el$;
})();
}
export { Modal2 };
+32
View File
@@ -0,0 +1,32 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
// src/components/SignalBars.tsx
import { effect as _$effect } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { For } from "solid-js";
var BARS = ["\u2581", "\u2583", "\u2585", "\u2587"];
function SignalBars2(props) {
const level = () => Math.floor(props.signal * BARS.length / 100);
return (() => {
var _el$ = _$createElement("box");
_$setProp(_el$, "flexDirection", "row");
_$insert(_el$, _$createComponent(For, {
each: BARS,
children: (bar, i) => (() => {
var _el$2 = _$createElement("text");
_$insert(_el$2, bar);
_$effect((_$p) => _$setProp(_el$2, "fg", i() < level() ? props.filledColor : props.emptyColor ?? theme2.muted, _$p));
return _el$2;
})()
}));
return _el$;
})();
}
export { SignalBars2 };
+83
View File
@@ -0,0 +1,83 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
// src/components/Badge.tsx
import { insert as _$insert } from "@opentui/solid";
import { memo as _$memo } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { effect as _$effect } from "@opentui/solid";
import { createTextNode as _$createTextNode } from "@opentui/solid";
import { insertNode as _$insertNode } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { TextAttributes } from "@opentui/core";
import { Show } from "solid-js";
function Badge2(props) {
const textColor = () => props.textColor ?? theme2.background;
const backgroundColor = () => props.backgroundColor ?? theme2.background;
const attributes = () => props.bold === false ? undefined : TextAttributes.BOLD;
const nerdFonts = () => props.withNerdfont ?? theme2.nerdFonts;
return (() => {
var _el$ = _$createElement("box"), _el$4 = _$createElement("text");
_$insertNode(_el$, _el$4);
_$setProp(_el$, "flexDirection", "row");
_$insert(_el$, _$createComponent(Show, {
get when() {
return nerdFonts();
},
get children() {
var _el$2 = _$createElement("text");
_$insertNode(_el$2, _$createTextNode(`\uE0B6`));
_$effect((_p$) => {
var _v$ = props.color, _v$2 = backgroundColor();
_v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e));
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$2, "bg", _v$2, _p$.t));
return _p$;
}, {
e: undefined,
t: undefined
});
return _el$2;
}
}), _el$4);
_$insert(_el$4, (() => {
var _c$ = _$memo(() => !!nerdFonts());
return () => _c$() ? props.label : ` ${props.label} `;
})());
_$insert(_el$, _$createComponent(Show, {
get when() {
return nerdFonts();
},
get children() {
var _el$5 = _$createElement("text");
_$insertNode(_el$5, _$createTextNode(`\uE0B4`));
_$effect((_p$) => {
var _v$3 = props.color, _v$4 = backgroundColor();
_v$3 !== _p$.e && (_p$.e = _$setProp(_el$5, "fg", _v$3, _p$.e));
_v$4 !== _p$.t && (_p$.t = _$setProp(_el$5, "bg", _v$4, _p$.t));
return _p$;
}, {
e: undefined,
t: undefined
});
return _el$5;
}
}), null);
_$effect((_p$) => {
var _v$5 = textColor(), _v$6 = props.color, _v$7 = attributes();
_v$5 !== _p$.e && (_p$.e = _$setProp(_el$4, "fg", _v$5, _p$.e));
_v$6 !== _p$.t && (_p$.t = _$setProp(_el$4, "bg", _v$6, _p$.t));
_v$7 !== _p$.a && (_p$.a = _$setProp(_el$4, "attributes", _v$7, _p$.a));
return _p$;
}, {
e: undefined,
t: undefined,
a: undefined
});
return _el$;
})();
}
export { Badge2 };
+42
View File
@@ -0,0 +1,42 @@
// @bun
import {
useFocusWithin2
} from "./chunk-59dzyjgd.js";
// src/components/Pane.tsx
import { createComponent as _$createComponent } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { use as _$use } from "@opentui/solid";
import { spread as _$spread } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { createContext, createSignal, splitProps, useContext } from "solid-js";
var PaneContext = createContext();
function Pane2(props) {
const [local, rest] = splitProps(props, ["handle", "children"]);
const [el, setEl] = createSignal();
const focused = useFocusWithin2(el);
const handle = {
target: el,
focused,
focus: () => el()?.focus()
};
local.handle?.(handle);
return _$createComponent(PaneContext.Provider, {
value: handle,
get children() {
var _el$ = _$createElement("box");
_$use(setEl, _el$);
_$spread(_el$, rest, true);
_$insert(_el$, () => local.children);
return _el$;
}
});
}
function usePane2() {
const pane = useContext(PaneContext);
if (!pane)
throw new Error("usePane() must be used inside a <Pane>");
return pane;
}
export { Pane2, usePane2 };
+168
View File
@@ -0,0 +1,168 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
// src/components/Tabs.tsx
import { effect as _$effect } from "@opentui/solid";
import { insertNode as _$insertNode } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { use as _$use } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { BorderChars, CliRenderEvents, TextAttributes } from "@opentui/core";
import { onResize, useRenderer } from "@opentui/solid";
import { createMemo, createSignal, Index } from "solid-js";
function stepTabValue2(items, value, delta) {
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) {
return [...label].length + 2;
}
function segmentWidth(label) {
return innerWidth(label) + 2;
}
function topEdge(chars, label) {
return `${chars.topLeft}${chars.horizontal.repeat(innerWidth(label))}${chars.topRight}`;
}
function bottomEdge(chars, label, isFirst, isLast, isActive) {
const left = isFirst ? isActive ? chars.vertical : chars.leftT : isActive ? chars.bottomRight : chars.bottomT;
const right = isLast ? isActive ? chars.vertical : chars.rightT : isActive ? chars.bottomLeft : chars.bottomT;
const fill = isActive ? " " : chars.horizontal;
return left + fill.repeat(innerWidth(label)) + right;
}
function buildBottomRow(chars, segments, available) {
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(chars, s.label, i === 0, i === segments.length - 1 && !extending, s.isActive)).join("");
return extending ? tabs + chars.horizontal.repeat(remainder - 1) + chars.topRight : tabs;
}
function fitCount(items, start, available) {
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);
}
function Tabs2(props) {
const renderer = useRenderer();
const [width, setWidth] = createSignal(0);
let container;
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,
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 ?? theme2.primary;
const muted = () => props.mutedColor ?? theme2.muted;
const borderColor = () => props.focused ? accent() : muted();
const chars = BorderChars[theme2.borderStyle];
return (() => {
var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createElement("box"), _el$4 = _$createElement("text");
_$insertNode(_el$, _el$2);
_$insertNode(_el$, _el$3);
_$insertNode(_el$, _el$4);
_$use((el) => {
container = el;
remeasureNextFrame();
}, _el$);
_$setProp(_el$, "flexDirection", "column");
_$setProp(_el$, "height", 3);
_$setProp(_el$2, "selectable", false);
_$insert(_el$2, () => segments().map((s) => topEdge(chars, s.label)).join(""));
_$setProp(_el$3, "flexDirection", "row");
_$insert(_el$3, _$createComponent(Index, {
get each() {
return segments();
},
children: (s) => [(() => {
var _el$5 = _$createElement("text");
_$setProp(_el$5, "selectable", false);
_$insert(_el$5, () => chars.vertical);
_$effect((_$p) => _$setProp(_el$5, "fg", borderColor(), _$p));
return _el$5;
})(), (() => {
var _el$6 = _$createElement("text");
_$setProp(_el$6, "selectable", false);
_$insert(_el$6, () => ` ${s().label} `);
_$effect((_p$) => {
var _v$3 = s().isActive ? accent() : muted(), _v$4 = s().isActive ? TextAttributes.BOLD : undefined, _v$5 = theme2.mouse && s().value !== undefined ? () => props.onChange(s().value) : undefined;
_v$3 !== _p$.e && (_p$.e = _$setProp(_el$6, "fg", _v$3, _p$.e));
_v$4 !== _p$.t && (_p$.t = _$setProp(_el$6, "attributes", _v$4, _p$.t));
_v$5 !== _p$.a && (_p$.a = _$setProp(_el$6, "onMouseDown", _v$5, _p$.a));
return _p$;
}, {
e: undefined,
t: undefined,
a: undefined
});
return _el$6;
})(), (() => {
var _el$7 = _$createElement("text");
_$setProp(_el$7, "selectable", false);
_$insert(_el$7, () => chars.vertical);
_$effect((_$p) => _$setProp(_el$7, "fg", borderColor(), _$p));
return _el$7;
})()]
}));
_$setProp(_el$4, "selectable", false);
_$insert(_el$4, () => buildBottomRow(chars, segments(), width()));
_$effect((_p$) => {
var _v$ = borderColor(), _v$2 = borderColor();
_v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e));
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "fg", _v$2, _p$.t));
return _p$;
}, {
e: undefined,
t: undefined
});
return _el$;
})();
}
export { stepTabValue2, Tabs2 };
+52
View File
@@ -0,0 +1,52 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
import {
Box2
} from "./chunk-8r4rajaz.js";
import {
Tabs2
} from "./chunk-vf2ybhtx.js";
// src/components/TabbedPanel.tsx
import { insert as _$insert } from "@opentui/solid";
import { memo as _$memo } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
function TabbedPanel2(props) {
return (() => {
var _el$ = _$createElement("box");
_$setProp(_el$, "flexDirection", "column");
_$setProp(_el$, "flexGrow", 1);
_$insert(_el$, _$createComponent(Tabs2, {
get items() {
return props.items;
},
get value() {
return props.value;
},
get onChange() {
return props.onChange;
},
get focused() {
return props.focused;
}
}), null);
_$insert(_el$, _$createComponent(Box2, {
flexGrow: 1,
border: ["left", "right", "bottom"],
get borderColor() {
return _$memo(() => !!props.focused)() ? theme2.primary : theme2.muted;
},
flexDirection: "column",
get children() {
return props.children;
}
}), null);
return _el$;
})();
}
export { TabbedPanel2 };
+10
View File
@@ -0,0 +1,10 @@
// @bun
// src/yaml.ts
import { homedir } from "os";
import { join } from "path";
function configPath2() {
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
return join(configDir, "ilovetui", "config.yaml");
}
export { configPath2 };
+44
View File
@@ -0,0 +1,44 @@
// @bun
// src/presets.ts
function buildPresets2(theme) {
const select = {
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 = {
backgroundColor: theme.background,
textColor: theme.text,
focusedBackgroundColor: theme.subtleBg,
focusedTextColor: theme.text,
selectedBackgroundColor: theme.selection,
selectedTextColor: theme.primary,
selectedDescriptionColor: theme.text
};
const input = {
backgroundColor: theme.background,
textColor: theme.text,
focusedBackgroundColor: theme.subtleBg,
focusedTextColor: theme.text,
placeholderColor: theme.muted
};
const box = {
border: true,
borderStyle: theme.borderStyle,
borderColor: theme.subtle,
focusedBorderColor: theme.primary,
backgroundColor: theme.background
};
const slider = {
backgroundColor: theme.subtleBg,
foregroundColor: theme.primary
};
return { select, tabSelect, input, textarea: input, box, slider };
}
export { buildPresets2 };
+58
View File
@@ -0,0 +1,58 @@
// @bun
import {
theme2
} from "./chunk-p6jvscj3.js";
// src/components/ProgressBar.tsx
import { effect as _$effect } from "@opentui/solid";
import { memo as _$memo } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { parseColor, RGBA } from "@opentui/core";
import { createMemo, For } from "solid-js";
function sampleGradient(stops, t) {
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));
}
function ProgressBar2(props) {
const width = () => props.width ?? 20;
const stops = createMemo(() => Array.isArray(props.color) ? props.color : [props.color ?? theme2.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 (() => {
var _el$ = _$createElement("box");
_$setProp(_el$, "flexDirection", "row");
_$insert(_el$, _$createComponent(For, {
get each() {
return cells();
},
children: (i) => {
const isFilled = () => i < filled();
const t = width() > 1 ? i / (width() - 1) : 0;
return (() => {
var _el$2 = _$createElement("text");
_$insert(_el$2, (() => {
var _c$ = _$memo(() => !!isFilled());
return () => _c$() ? props.fillChar ?? "\u2588" : props.trackChar ?? "\u2591";
})());
_$effect((_$p) => _$setProp(_el$2, "fg", isFilled() ? sampleGradient(stops(), t) : props.trackColor ?? theme2.muted, _$p));
return _el$2;
})();
}
}));
return _el$;
})();
}
export { ProgressBar2 };
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
Badge2
} from "../chunk-v9cetqzr.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
Badge2 as Badge
};
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
Box2
} from "../chunk-8r4rajaz.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
Box2 as Box
};
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
HelpBar2
} from "../chunk-c1dx8cn3.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
HelpBar2 as HelpBar
};
+14
View File
@@ -0,0 +1,14 @@
// @bun
import {
HelpModal2
} from "../chunk-ersd5rrs.js";
import"../chunk-t2cszvff.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
import"../chunk-ee2qaswx.js";
export {
HelpModal2 as HelpModal
};
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
KeyValue2
} from "../chunk-c4gp0x16.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
KeyValue2 as KeyValue
};
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
ListRow2
} from "../chunk-sh9n4zha.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
ListRow2 as ListRow
};
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
MinSizeGuard2
} from "../chunk-qdyd8q9r.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
MinSizeGuard2 as MinSizeGuard
};
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
Modal2
} from "../chunk-t2cszvff.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
Modal2 as Modal
};
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
NotificationHost2
} from "../chunk-ajb193xj.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
NotificationHost2 as NotificationHost
};
+10
View File
@@ -0,0 +1,10 @@
// @bun
import {
Pane2,
usePane2
} from "../chunk-vazj633p.js";
import"../chunk-59dzyjgd.js";
export {
Pane2 as Pane,
usePane2 as usePane
};
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
ProgressBar2
} from "../chunk-z734n0wr.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
ProgressBar2 as ProgressBar
};
+81
View File
@@ -0,0 +1,81 @@
// @bun
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-yyg967q8.js";
import"../chunk-xykm10y9.js";
import {
theme2
} from "../chunk-p6jvscj3.js";
import {
Spinner2
} from "../chunk-kmqqzz9e.js";
// src/components/SectionHeader.tsx
import { createTextNode as _$createTextNode } from "@opentui/solid";
import { createComponent as _$createComponent } from "@opentui/solid";
import { effect as _$effect } from "@opentui/solid";
import { insertNode as _$insertNode } from "@opentui/solid";
import { insert as _$insert } from "@opentui/solid";
import { memo as _$memo } from "@opentui/solid";
import { setProp as _$setProp } from "@opentui/solid";
import { createElement as _$createElement } from "@opentui/solid";
import { TextAttributes } from "@opentui/core";
import { Show } from "solid-js";
function SectionHeader(props) {
return (() => {
var _el$ = _$createElement("box"), _el$2 = _$createElement("box"), _el$3 = _$createElement("text"), _el$4 = _$createElement("box");
_$insertNode(_el$, _el$2);
_$insertNode(_el$, _el$4);
_$setProp(_el$, "flexShrink", 0);
_$setProp(_el$, "flexDirection", "row");
_$setProp(_el$, "justifyContent", "space-between");
_$setProp(_el$, "alignItems", "center");
_$setProp(_el$, "height", 1);
_$insertNode(_el$2, _el$3);
_$setProp(_el$2, "flexGrow", 1);
_$setProp(_el$2, "flexShrink", 1);
_$setProp(_el$2, "minWidth", 0);
_$setProp(_el$2, "marginRight", 1);
_$setProp(_el$2, "overflow", "hidden");
_$setProp(_el$3, "wrapMode", "none");
_$insert(_el$3, () => props.title ?? "");
_$setProp(_el$4, "flexShrink", 0);
_$insert(_el$4, _$createComponent(Show, {
get when() {
return !props.loading;
},
get fallback() {
return (() => {
var _el$6 = _$createElement("box"), _el$7 = _$createElement("text"), _el$8 = _$createTextNode(` `);
_$insertNode(_el$6, _el$7);
_$setProp(_el$6, "flexDirection", "row");
_$setProp(_el$6, "alignItems", "center");
_$insert(_el$6, _$createComponent(Spinner2, {}), _el$7);
_$insertNode(_el$7, _el$8);
_$insert(_el$7, () => props.loadingLabel ?? "Loading\u2026", null);
_$effect((_$p) => _$setProp(_el$7, "fg", theme2.muted, _$p));
return _el$6;
})();
},
get children() {
var _el$5 = _$createElement("text");
_$insert(_el$5, () => props.hint ?? "");
_$effect((_$p) => _$setProp(_el$5, "fg", theme2.muted, _$p));
return _el$5;
}
}));
_$effect((_p$) => {
var _v$ = theme2.muted, _v$2 = TextAttributes.BOLD;
_v$ !== _p$.e && (_p$.e = _$setProp(_el$3, "fg", _v$, _p$.e));
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$3, "attributes", _v$2, _p$.t));
return _p$;
}, {
e: undefined,
t: undefined
});
return _el$;
})();
}
export {
SectionHeader
};
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
Sidebar2
} from "../chunk-rcywynbw.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
Sidebar2 as Sidebar
};
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
SignalBars2
} from "../chunk-tz3vvffs.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
SignalBars2 as SignalBars
};
+12
View File
@@ -0,0 +1,12 @@
// @bun
import {
Spinner2
} from "../chunk-kmqqzz9e.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
Spinner2 as Spinner
};
+14
View File
@@ -0,0 +1,14 @@
// @bun
import {
TabbedPanel2
} from "../chunk-wtbg0e39.js";
import"../chunk-vf2ybhtx.js";
import"../chunk-8r4rajaz.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
TabbedPanel2 as TabbedPanel
};
+14
View File
@@ -0,0 +1,14 @@
// @bun
import {
stepTabValue2,
Tabs2
} from "../chunk-vf2ybhtx.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
Tabs2 as Tabs,
stepTabValue2 as stepTabValue
};
+13
View File
@@ -0,0 +1,13 @@
// @bun
import {
Toggle2
} from "../chunk-qhp6k176.js";
import"../chunk-v9cetqzr.js";
import"../chunk-p6jvscj3.js";
import"../chunk-yyg967q8.js";
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-xykm10y9.js";
export {
Toggle2 as Toggle
};
+18
View File
@@ -0,0 +1,18 @@
// @bun
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-yyg967q8.js";
import"../chunk-xykm10y9.js";
import"../chunk-p6jvscj3.js";
import {
HelpBar2
} from "../chunk-c1dx8cn3.js";
import"../chunk-ee2qaswx.js";
import"../chunk-t2cszvff.js";
import {
HelpModal2
} from "../chunk-ersd5rrs.js";
export {
HelpBar2 as HelpBar,
HelpModal2 as HelpModal
};
+69
View File
@@ -0,0 +1,69 @@
// @bun
import"../chunk-njvj322f.js";
import"../chunk-6ggk5fg7.js";
import"../chunk-yyg967q8.js";
import"../chunk-xykm10y9.js";
import"../chunk-p6jvscj3.js";
import {
Badge2
} from "../chunk-v9cetqzr.js";
import {
Box2
} from "../chunk-8r4rajaz.js";
import {
KeyValue2
} from "../chunk-c4gp0x16.js";
import {
ListRow2
} from "../chunk-sh9n4zha.js";
import {
MinSizeGuard2
} from "../chunk-qdyd8q9r.js";
import {
Modal2
} from "../chunk-t2cszvff.js";
import {
NotificationHost2
} from "../chunk-ajb193xj.js";
import"../chunk-59dzyjgd.js";
import {
Pane2,
usePane2
} from "../chunk-vazj633p.js";
import {
ProgressBar2
} from "../chunk-z734n0wr.js";
import {
Sidebar2
} from "../chunk-rcywynbw.js";
import {
SignalBars2
} from "../chunk-tz3vvffs.js";
import {
stepTabValue2,
Tabs2
} from "../chunk-vf2ybhtx.js";
import {
TabbedPanel2
} from "../chunk-wtbg0e39.js";
import {
Toggle2
} from "../chunk-qhp6k176.js";
export {
Badge2 as Badge,
Box2 as Box,
KeyValue2 as KeyValue,
ListRow2 as ListRow,
MinSizeGuard2 as MinSizeGuard,
Modal2 as Modal,
NotificationHost2 as NotificationHost,
Pane2 as Pane,
ProgressBar2 as ProgressBar,
Sidebar2 as Sidebar,
SignalBars2 as SignalBars,
TabbedPanel2 as TabbedPanel,
Tabs2 as Tabs,
Toggle2 as Toggle,
stepTabValue2 as stepTabValue,
usePane2 as usePane
};
+17
View File
@@ -0,0 +1,17 @@
// @bun
import {
COLOR_KEYS2,
OPENTUI_BORDER_STYLES2,
ThemeConfigSchema2,
layerConfigs2,
resolveConfig2,
normalizeColor2
} from "./chunk-njvj322f.js";
export {
COLOR_KEYS2 as COLOR_KEYS,
OPENTUI_BORDER_STYLES2 as OPENTUI_BORDER_STYLES,
ThemeConfigSchema2 as ThemeConfigSchema,
layerConfigs2 as layerConfigs,
normalizeColor2 as normalizeColor,
resolveConfig2 as resolveConfig
};
+9
View File
@@ -0,0 +1,9 @@
// @bun
import {
isInside2,
useFocusWithin2
} from "../chunk-59dzyjgd.js";
export {
isInside2 as isInside,
useFocusWithin2 as useFocusWithin
};
+9
View File
@@ -0,0 +1,9 @@
// @bun
import {
helpOpen2,
toggleHelp2
} from "../chunk-ee2qaswx.js";
export {
helpOpen2 as helpOpen,
toggleHelp2 as toggleHelp
};
+7
View File
@@ -0,0 +1,7 @@
// @bun
import {
createSimpleContext2
} from "../chunk-jza406qg.js";
export {
createSimpleContext2 as createSimpleContext
};
+27
View File
@@ -0,0 +1,27 @@
// @bun
import {
createSimpleContext2
} from "../chunk-jza406qg.js";
import {
notifications2,
notify2,
dismiss2
} from "../chunk-6ggk5fg7.js";
import {
helpOpen2,
toggleHelp2
} from "../chunk-ee2qaswx.js";
import {
isInside2,
useFocusWithin2
} from "../chunk-59dzyjgd.js";
export {
createSimpleContext2 as createSimpleContext,
dismiss2 as dismiss,
helpOpen2 as helpOpen,
isInside2 as isInside,
notifications2 as notifications,
notify2 as notify,
toggleHelp2 as toggleHelp,
useFocusWithin2 as useFocusWithin
};
+11
View File
@@ -0,0 +1,11 @@
// @bun
import {
notifications2,
notify2,
dismiss2
} from "../chunk-6ggk5fg7.js";
export {
dismiss2 as dismiss,
notifications2 as notifications,
notify2 as notify
};
+10 -3
View File
@@ -2,11 +2,18 @@
# Copy to ~/.config/ilovetui/config.yaml and edit to customize.
# Whether components may use Nerd Font glyphs (requires a patched font).
# Leave false for maximum terminal/font compatibility.
nerd_fonts: false
nerd_fonts: true
# 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.
# One of: rounded, normal, thick, double, hidden, ascii.
border: rounded
colors:
+23
View File
@@ -0,0 +1,23 @@
// @bun
import {
theme2,
presets2,
configureTheme2,
icon2
} from "./chunk-p6jvscj3.js";
import"./chunk-yyg967q8.js";
import {
ThemeConfigSchema2
} from "./chunk-njvj322f.js";
import"./chunk-6ggk5fg7.js";
import {
configPath2
} from "./chunk-xykm10y9.js";
export {
ThemeConfigSchema2 as ThemeConfigSchema,
configPath2 as configPath,
configureTheme2 as configureTheme,
icon2 as icon,
presets2 as presets,
theme2 as theme
};
+27
View File
@@ -0,0 +1,27 @@
// @bun
// src/keymap/provider.tsx
import { createComponent as _$createComponent } from "@opentui/solid";
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 } from "solid-js";
import { useKeymap, useBindings, useKeymapSelector, reactiveMatcherFromSignal } from "@opentui/keymap/solid";
function KeymapProvider(props) {
const renderer = useRenderer();
const keymap = createDefaultOpenTuiKeymap(renderer);
onCleanup(registerCommaBindings(keymap));
return _$createComponent(BaseKeymapProvider, {
keymap,
get children() {
return props.children;
}
});
}
export {
KeymapProvider,
reactiveMatcherFromSignal,
useBindings,
useKeymap,
useKeymapSelector
};
+7
View File
@@ -0,0 +1,7 @@
// @bun
import {
buildPresets2
} from "./chunk-yyg967q8.js";
export {
buildPresets2 as buildPresets
};
+117
View File
@@ -0,0 +1,117 @@
// @bun
import {
notify2
} from "./chunk-6ggk5fg7.js";
// src/project-config.ts
import { existsSync, readFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import { parse as parseYAML } from "yaml";
import { z } from "zod";
function userConfigPath(projectName) {
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
return join(configHome, projectName, "config.yaml");
}
function readYaml(path) {
return parseYAML(readFileSync(path, "utf-8"));
}
function warn(projectName, path, message) {
const text = `${path}: ${message}`;
console.error(`[${projectName} config] ${text}`);
notify2(text, { kind: "warning", duration: 0 });
}
function loadProjectConfig(options) {
const { projectName, defaultConfigPath, defaultSchema, userSchema } = options;
let rawDefaults;
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:
${z.prettifyError(defaultsResult.error)}`);
}
const path = userConfigPath(projectName);
const user = (() => {
if (!existsSync(path))
return {};
let raw;
try {
raw = readYaml(path);
} catch (error) {
warn(projectName, path, error instanceof Error ? error.message : String(error));
return {};
}
const result = userSchema.safeParse(raw ?? {});
if (!result.success) {
warn(projectName, path, z.prettifyError(result.error));
return {};
}
return result.data;
})();
return { defaults: defaultsResult.data, user };
}
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function deepMerge(base, over) {
if (!isPlainObject(base) || !isPlainObject(over))
return over === undefined ? base : over;
const out = { ...base };
for (const [key, value] of Object.entries(over))
out[key] = deepMerge(base[key], value);
return out;
}
function loadMergedProjectConfig(options) {
const { projectName, defaultConfigPath, schema } = options;
let rawDefaults;
try {
rawDefaults = readYaml(defaultConfigPath);
} catch (error) {
throw new Error(`Failed to read ${defaultConfigPath}: ${error instanceof Error ? error.message : error}`);
}
const defaults = schema.safeParse(rawDefaults);
if (!defaults.success) {
throw new Error(`${defaultConfigPath} is invalid:
${z.prettifyError(defaults.error)}`);
}
const path = userConfigPath(projectName);
if (!existsSync(path))
return defaults.data;
let rawUser;
try {
rawUser = readYaml(path);
} catch (error) {
warn(projectName, path, error instanceof Error ? error.message : String(error));
return defaults.data;
}
const merged = schema.safeParse(deepMerge(rawDefaults, rawUser ?? {}));
if (!merged.success) {
warn(projectName, path, z.prettifyError(merged.error));
return defaults.data;
}
return merged.data;
}
function keybindsSchema(names, required) {
const shape = {};
for (const name of names) {
shape[name] = required ? z.string().min(1) : z.string().min(1).optional();
}
return z.object(shape).strict();
}
function mergeKeybinds(names, defaults, user) {
const merged = {};
for (const name of names) {
merged[name] = user?.[name] ?? defaults[name];
}
return merged;
}
export {
keybindsSchema,
loadMergedProjectConfig,
loadProjectConfig,
mergeKeybinds
};
+48
View File
@@ -0,0 +1,48 @@
// @bun
import"./chunk-njvj322f.js";
import"./chunk-6ggk5fg7.js";
import"./chunk-yyg967q8.js";
import"./chunk-xykm10y9.js";
import {
theme2,
presets2
} from "./chunk-p6jvscj3.js";
// src/solid.ts
import { extend } from "@opentui/solid";
import {
ASCIIFontRenderable,
BoxRenderable,
InputRenderable,
SelectRenderable,
SliderRenderable,
TabSelectRenderable,
TextareaRenderable,
TextRenderable
} from "@opentui/core";
function withThemeDefaults(Ctor, defaults, after) {
class Themed extends Ctor {
constructor(...args) {
super(...args);
Object.assign(this, defaults());
after?.(this);
}
}
return Themed;
}
var boxDefaults = () => {
const { borderStyle, borderColor, focusedBorderColor } = presets2.box;
return { borderStyle, borderColor, focusedBorderColor };
};
extend({
box: withThemeDefaults(BoxRenderable, boxDefaults, (instance) => {
instance.border = false;
}),
select: withThemeDefaults(SelectRenderable, () => presets2.select),
tab_select: withThemeDefaults(TabSelectRenderable, () => presets2.tabSelect),
input: withThemeDefaults(InputRenderable, () => presets2.input),
textarea: withThemeDefaults(TextareaRenderable, () => presets2.textarea),
slider: withThemeDefaults(SliderRenderable, () => presets2.slider),
text: withThemeDefaults(TextRenderable, () => ({ fg: theme2.text })),
ascii_font: withThemeDefaults(ASCIIFontRenderable, () => ({ color: theme2.primary }))
});
+7
View File
@@ -0,0 +1,7 @@
// @bun
import {
configPath2
} from "./chunk-xykm10y9.js";
export {
configPath2 as configPath
};
-140
View File
@@ -1,140 +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 this package
otherwise mirrors closely: same stack of panels triggered from anywhere via an exported `tea.Msg`
(`ShowMsg`/`Show`) rather than a direct reference to the `Model` that ends up rendering it, same
composite-over-an-already-rendered-string `Render`, no assumption about how the host builds that
string.
## Quick start
```go
import (
"github.com/anotherhadi/ilovetui/drawer"
)
type model struct {
d drawer.Model
width, height int
}
func newModel() model {
return model{d: drawer.New()}
}
func (m model) Init() tea.Cmd { return m.d.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "l" {
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
}
if msg.String() == "esc" && m.d.Open() {
return m, drawer.Close()
}
}
var cmd tea.Cmd
m.d, cmd = m.d.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
background := renderYourUI(m.width, m.height)
view := tea.NewView(m.d.Render(background))
view.AltScreen = true
return view
}
```
Any component in the same bubbletea program can trigger a drawer via `drawer.Show`, without
holding a reference to the `drawer.Model` that will actually render it - that `Model` just needs
to see every `tea.Msg` the program produces (i.e. get its `Update` called from the top-level
`Update`), same as any other child model.
## Content is a model
A drawer's body is a `tea.Model`, not a string. While a drawer is on top of the stack it gets
every message the `drawer.Model` receives, its `Init` runs when it opens, and its commands come
back out - so it can hold a file list, a filter form, or a picker that reports its choice with a
`tea.Msg` of its own, which the component that opened it listens for:
```go
type pickedMsg struct{ file string }
// somewhere else
return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
```
Only the topmost drawer is updated: everything beneath it is dimmed and frozen until the drawers
above it close.
For a drawer with nothing to interact with, `drawer.Text` wraps a plain string:
```go
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
```
The box shrinks to fit whatever the content draws (unless `WithWidth` fixes it), so a content
that wants a specific size sets it on itself - the drawer only ever sees the rendered result.
## Side and width
```go
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"),
drawer.WithSide(drawer.Right), drawer.WithWidth(24))
```
`WithSide` anchors the drawer to `drawer.Left` (the default) or `drawer.Right`. `WithWidth` fixes
the drawer's total width instead of shrinking to fit its content, still capped by whatever
actually fits the background - same unit as the `Model`-level `WithMaxWidth`. Either way, the
drawer always spans the background's full height, flush top to bottom.
## Showing and closing
```go
return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
return m, drawer.Close() // close the topmost drawer
```
The stack is a plain LIFO, with no identity: a drawer is closed by being on top, never by being
named. There is nothing to tag a drawer with, and nothing that can target one in the middle of the
stack - the topmost is both the only one that receives messages and the only one `Close` can
reach, so the two rules never disagree.
- `drawer.Open()` reports whether at least one drawer is currently shown - handy for a host that
wants to route key presses to the drawer instead of its normal UI while one is open. Note that a
host doing this also makes it impossible for a key to open a second drawer, which is what keeps
the stack shallow without any bookkeeping.
- A content model closes its own drawer by returning `drawer.Close()`, since it only ever runs
while it is the topmost one.
## Stacking
Drawers stack: showing a second one while the first is still open pushes it on top, dimming both
the background and the first drawer to the same flat color, same as `modal`. Opening a left drawer
and a right drawer at once still stacks - if you want both visible at full color simultaneously,
render them as two ordinary panels via `layout` instead; this package is for transient
sidebars, not permanent chrome.
## Styling
```go
d := drawer.New(drawer.WithMaxWidth(30), drawer.WithStyles(myStyles))
return m, drawer.Show("Nav", drawer.Text("content"), drawer.WithDrawerStyle(oneOffStyles))
```
`WithMaxWidth` caps how wide a drawer can grow (border and padding included) before wrapping; a
drawer narrower than the cap shrinks to fit its content instead of padding out to it, unless shown
with `WithWidth`. A drawer can also never overflow past the edge of whatever background it's
rendered on. `WithStyles` sets the default look for every drawer shown by this `Model`;
`WithDrawerStyle` (a `Show` option) overrides it for one drawer alone. `DefaultStyles()` builds
from `style.S`, same palette roles as `modal.DefaultStyles()`.
## Examples
- `examples/drawer` - a left nav drawer and a right inspector drawer, either one at a time.
-109
View File
@@ -1,109 +0,0 @@
package drawer
import tea "charm.land/bubbletea/v2"
// Side is which edge of the background a Drawer is anchored to.
type Side int
const (
// Left anchors the drawer to the left edge (the zero value, so a Drawer
// built without WithSide opens on the left).
Left Side = iota
// Right anchors the drawer to the right edge.
Right
)
// Drawer is one sidebar panel on the stack.
type Drawer struct {
Title string
// Content is the drawer's body: a full model, updated and rendered by
// the drawer.Model while it's on top of the stack. Wrap a plain string
// with Text for a drawer with nothing to interact with.
Content tea.Model
Side Side
// Width, if non-zero, fixes the drawer's total width (border and
// padding included, same unit as the Model's WithMaxWidth) instead of
// shrinking to fit what Content draws, and Title. Still capped by whatever actually
// fits the background.
Width int
// Style, if non-nil, overrides the Model's default Styles for this
// drawer alone.
Style *Styles
}
// DrawerOption configures a Drawer built by Show.
type DrawerOption func(*Drawer)
// WithSide anchors the drawer to the given Side (default Left).
func WithSide(s Side) DrawerOption {
return func(d *Drawer) { d.Side = s }
}
// WithWidth fixes the drawer's total width instead of shrinking to fit its
// content, still capped by whatever actually fits the background.
func WithWidth(w int) DrawerOption {
return func(d *Drawer) { d.Width = w }
}
// WithDrawerStyle overrides the Model's default Styles for this drawer
// alone, for a one-off custom look instead of the shared theme.
func WithDrawerStyle(s Styles) DrawerOption {
return func(d *Drawer) { d.Style = &s }
}
func newDrawer(title string, content tea.Model, opts ...DrawerOption) Drawer {
d := Drawer{Title: title, Content: content}
for _, opt := range opts {
opt(&d)
}
return d
}
// ShowMsg tells a drawer.Model to display Drawer, pushing it on top of the
// stack. Any component in the same bubbletea program can trigger one via
// Show, without holding a reference to the drawer.Model that will actually
// render it - that Model just needs to see every tea.Msg the program
// produces, same as any other child model.
type ShowMsg struct{ Drawer Drawer }
// Show returns a tea.Cmd that opens a new drawer on top of the stack,
// anchored to the left edge by default. content is a model, so a drawer can
// hold anything a pane can - a file list, a filter form, a picker that
// reports its choice with its own tea.Msg:
//
// return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
// return m, drawer.Show("Nav", drawer.Text("Home\nProjects"))
//
// The content's Init runs when the drawer opens, and it receives every
// message while it's the topmost drawer (see Model.Update).
func Show(title string, content tea.Model, opts ...DrawerOption) tea.Cmd {
d := newDrawer(title, content, opts...)
return func() tea.Msg { return ShowMsg{Drawer: d} }
}
// DismissMsg closes the topmost drawer. The stack is a plain LIFO: a drawer
// is closed by being on top, never by being named.
type DismissMsg struct{}
// Close returns a tea.Cmd that closes the topmost drawer - which is also the
// only one that can act (see Model.Update), so a content model closes itself
// by returning it:
//
// return c, drawer.Close()
func Close() tea.Cmd {
return func() tea.Msg { return DismissMsg{} }
}
// text is a model wrapping a fixed string: a drawer body with nothing to
// update.
type text string
func (t text) Init() tea.Cmd { return nil }
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
func (t text) View() tea.View { return tea.NewView(string(t)) }
// Text wraps a plain string as drawer content, for the common drawer that has
// nothing to interact with:
//
// drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
func Text(s string) tea.Model { return text(s) }
-114
View File
@@ -1,114 +0,0 @@
// Package drawer renders a full-height panel flush against the left or
// right edge of an already-rendered background, on top of it dimmed -
// the sidebar/drawer equivalent of github.com/anotherhadi/ilovetui/modal,
// which this package otherwise mirrors closely: same stack of panels
// triggered from anywhere via an exported tea.Msg (see ShowMsg/Show)
// rather than a direct reference to the Model that ends up rendering it,
// same composite-over-an-already-rendered-string Render, same absence of
// any assumption about how the host builds that string.
//
// A drawer's content is a model, not a string: it is updated while it's on
// top of the stack, so it can hold anything a pane can - a file list, a
// filter form, a picker reporting its choice back with its own tea.Msg. See
// Show and Text.
package drawer
import tea "charm.land/bubbletea/v2"
// Model holds the currently open drawers (a stack: the most recently shown
// is drawn on top, everything beneath it - the background and any earlier
// drawer - dimmed, see Render) and the rendering config (max width, styles)
// they share. Build one with New.
type Model struct {
drawers []Drawer
nextID int
maxWidth int
styles Styles
}
// Option configures a Model at construction. See WithMaxWidth, WithStyles.
type Option func(*Model)
// WithMaxWidth caps a drawer's total width (border and padding included). A
// drawer shown without WithWidth shrinks to fit its content instead of
// padding out to the cap; a drawer shown with WithWidth uses that width
// instead, still capped by this. 0 means only the background's own width
// caps it.
func WithMaxWidth(w int) Option {
return func(m *Model) { m.maxWidth = w }
}
// WithStyles overrides the default styles (see DefaultStyles).
func WithStyles(s Styles) Option {
return func(m *Model) { m.styles = s }
}
// New builds a Model. Defaults: a 30-column max width, DefaultStyles.
func New(opts ...Option) Model {
m := Model{
maxWidth: 30,
styles: DefaultStyles(),
}
for _, opt := range opts {
opt(&m)
}
return m
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case ShowMsg:
return m.show(msg.Drawer)
case DismissMsg:
return m.pop(), nil
}
return m.updateTop(msg)
}
// updateTop forwards msg to the topmost drawer's content - the only one the
// user can interact with, everything beneath it being dimmed (see Render). A
// drawer deeper in the stack is frozen until the ones above it close.
//
// This is what lets drawer content be a real model: it gets the key presses,
// the ticks and the results of its own commands, and can report back to the
// rest of the program with a tea.Msg of its own.
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
i := len(m.drawers) - 1
if i < 0 || m.drawers[i].Content == nil {
return m, nil
}
var cmd tea.Cmd
m.drawers[i].Content, cmd = m.drawers[i].Content.Update(msg)
return m, cmd
}
// Open reports whether at least one drawer is currently shown - handy for a
// host that wants to route key presses to the drawer (e.g. esc to dismiss)
// instead of its normal UI while one is open.
func (m Model) Open() bool { return len(m.drawers) > 0 }
// show pushes a drawer on top of the stack and returns its content's Init - a
// drawer's body starts the same way any other model does.
func (m Model) show(d Drawer) (Model, tea.Cmd) {
m.drawers = append(m.drawers, d)
return m, initContent(d)
}
// initContent is d's content's Init, or nil for a drawer without content.
func initContent(d Drawer) tea.Cmd {
if d.Content == nil {
return nil
}
return d.Content.Init()
}
// pop closes the topmost drawer (see Close).
func (m Model) pop() Model {
if len(m.drawers) == 0 {
return m
}
m.drawers = m.drawers[:len(m.drawers)-1]
return m
}
-155
View File
@@ -1,155 +0,0 @@
package drawer
import (
"image/color"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/anotherhadi/ilovetui/style"
)
// Render draws every open drawer (see Model.Update/Show) on top of
// background (already rendered, e.g. layout.Model.View() or any other
// component's View()) and returns the result. Each drawer in the stack
// first flattens whatever came before it - background plus any earlier
// drawer - to a single flat DimColor (see dim), then draws its own
// full-height box flush against its Side on top, so nesting a second
// drawer on top of a first dims the first one too. background is returned
// unchanged whenever there's nothing to draw.
func (m Model) Render(background string) string {
if len(m.drawers) == 0 {
return background
}
result := background
for _, d := range m.drawers {
w, h := lipgloss.Width(result), lipgloss.Height(result)
if w <= 0 || h <= 0 {
return result
}
result = m.renderOne(d, result, w, h)
}
return result
}
// View is a convenience for a pane whose sole purpose is showing drawers
// (e.g. a dedicated layout.Leaf): it draws the stack over a blank
// width x height area instead of an existing background.
func (m Model) View(width, height int) string {
return m.Render(blank(width, height))
}
// renderOne dims background flat and draws d's box, spanning its full
// height, flush against d.Side.
func (m Model) renderOne(d Drawer, background string, w, h int) string {
s := m.styles
if d.Style != nil {
s = *d.Style
}
box := m.renderBox(d, s, w, h)
bw := lipgloss.Width(box)
x := 0
if d.Side == Right {
x = max(w-bw, 0)
}
compositor := lipgloss.NewCompositor(
lipgloss.NewLayer(dim(background, s.DimColor)),
lipgloss.NewLayer(box).X(x).Y(0).Z(1),
)
return compositor.Render()
}
// dim flattens s to a single flat color: every existing style (colors,
// bold, underline...) is stripped, then every character - including
// whitespace, so highlighted/selected backgrounds vanish too - is
// repainted in c. Applying a Foreground style to a multi-line string styles
// each line independently (see lipgloss.Style.Render), so this keeps s's
// line structure intact.
func dim(s string, c color.Color) string {
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
}
// renderBox draws d as a bordered, title-embedded box (style.RenderWithTitle)
// spanning the background's full height, capped by the Model's configured
// max width and by whatever actually fits inside a bgW-wide background.
func (m Model) renderBox(d Drawer, s Styles, bgW, bgH int) string {
widthCap := m.maxWidth
if d.Width > 0 {
widthCap = d.Width
}
maxW := effectiveMax(widthCap, bgW)
body := contentView(d)
inner := contentWidth(d, body, maxW)
content := s.Content.Width(inner).Render(body)
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
return style.RenderWithTitle(s.Border, s.Title.Render(d.Title), content, boxWidth, bgH)
}
// contentView is the drawer body's rendered string, or "" for a drawer
// without content. The box shrinks to fit whatever the content model draws,
// so a content that wants a specific size sets it on itself - the drawer only
// ever sees the result.
func contentView(d Drawer) string {
if d.Content == nil {
return ""
}
return d.Content.View().Content
}
// contentWidth is the drawer's inner (border/padding excluded) width, given
// the resolved total-width cap maxWidth (see renderBox): maxWidth-4 if
// d.Width is set (a fixed total width), otherwise its natural size (long
// enough for the widest line of title/content), capped at maxWidth-4.
func contentWidth(d Drawer, body string, maxWidth int) int {
capped := max(maxWidth-4, 1)
if d.Width > 0 {
return capped
}
natural := max(naturalWidth(body), lipgloss.Width(d.Title), 1)
return min(natural, capped)
}
// naturalWidth is the width of content's widest line.
func naturalWidth(content string) int {
w := 0
for _, line := range strings.Split(content, "\n") {
if lw := lipgloss.Width(line); lw > w {
w = lw
}
}
return w
}
// effectiveMax resolves the cap actually used along the width axis:
// configured (0 = unlimited) narrowed down to fits, whatever actually fits
// the background - a drawer can never overflow past the edge of the
// background, or the terminal, when the background is a full-screen View(),
// regardless of how WithMaxWidth/WithWidth was set.
func effectiveMax(configured, fits int) int {
if fits < 1 {
fits = 1
}
if configured > 0 && configured < fits {
return configured
}
return fits
}
func blank(width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
line := strings.Repeat(" ", width)
lines := make([]string, height)
for i := range lines {
lines[i] = line
}
return strings.Join(lines, "\n")
}
-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")
}
// The border's top-left corner glyph should be the very first rune.
if len([]rune(lines[0])) == 0 {
t.Fatalf("expected a rendered top border line")
}
}
func TestRenderRightFlushToRightEdge(t *testing.T) {
m := New(WithMaxWidth(10))
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Inspector", Text("hi"), WithSide(Right))})
out := m.Render(background(40, 10))
if lipgloss.Width(out) != 40 {
t.Fatalf("expected full background width 40, got %d", lipgloss.Width(out))
}
}
func TestSpansFullBackgroundHeight(t *testing.T) {
m := New()
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"))})
out := m.Render(background(40, 12))
if lipgloss.Height(out) != 12 {
t.Fatalf("expected full height 12, got %d", lipgloss.Height(out))
}
}
func TestFixedWidthHonored(t *testing.T) {
m := New(WithMaxWidth(50))
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("x"), WithWidth(20))})
box := m.renderBox(m.drawers[0], m.styles, 80, 10)
if got := lipgloss.Width(box); got != 20 { // WithWidth is the total box width
t.Fatalf("expected fixed box width 20, got %d", got)
}
}
func TestNoDrawersReturnsBackgroundUnchanged(t *testing.T) {
m := New()
bg := background(10, 5)
if got := m.Render(bg); got != bg {
t.Fatalf("expected background unchanged when no drawer is open")
}
}
-37
View File
@@ -1,37 +0,0 @@
package drawer
import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// Styles is the set of lipgloss styles, plus the dim color, used to render a
// drawer and the background behind it. Border carries the box's border
// (shape + color, no size), Title and Content color the two pieces of text
// drawn inside it, DimColor is the single flat color every character of the
// background gets overwritten with while the drawer is open.
type Styles struct {
Border lipgloss.Style
Title lipgloss.Style
Content lipgloss.Style
DimColor color.Color
}
// DefaultStyles builds a Styles from style.S: the box borrows
// PanelFocused's border (the drawer is what has focus while it's open).
// DimColor reuses Subtle - the base16 "comments/invisibles" role, already
// used across this repo for de-emphasized text (borders, placeholders,
// separators, see bubbles/*.go and modal.DefaultStyles) - darker than
// Muted, which reads too bright once it's covering an entire screen instead
// of a single blurred field.
func DefaultStyles() Styles {
return Styles{
Border: style.S.PanelFocused.Padding(0, 1),
Title: lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary),
Content: lipgloss.NewStyle().Foreground(style.S.Text),
DimColor: style.S.Subtle,
}
}
-76
View File
@@ -1,76 +0,0 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/style"
)
type model struct {
d drawer.Model
width, height int
}
func newModel() model {
return model{d: drawer.New()}
}
func (m model) Init() tea.Cmd { return m.d.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "l":
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"),
drawer.WithSide(drawer.Left), drawer.WithWidth(20))
case "r":
return m, drawer.Show("Inspector", drawer.Text("id: 42\nstatus: ok"),
drawer.WithSide(drawer.Right), drawer.WithWidth(20))
case "esc":
if m.d.Open() {
return m, drawer.Close()
}
}
}
var cmd tea.Cmd
m.d, cmd = m.d.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App")
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(
"Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the drawer.")
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"l: open left drawer r: open right drawer esc: close the top one q: quit")
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", body, "", help))
view := tea.NewView(m.d.Render(background))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-231
View File
@@ -1,231 +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/drawer"
"github.com/anotherhadi/ilovetui/examples/fullapp/metrics"
"github.com/anotherhadi/ilovetui/examples/fullapp/overview"
"github.com/anotherhadi/ilovetui/examples/fullapp/settings"
"github.com/anotherhadi/ilovetui/examples/fullapp/sidebar"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/ilovetui/style"
)
var pages = []struct {
item sidebar.NavItem
new func() tea.Model
}{
{sidebar.NavItem{Icon: "󰋜", Name: "Overview"}, func() tea.Model { return overview.New() }},
{sidebar.NavItem{Icon: "󰄨", Name: "Metrics"}, func() tea.Model { return metrics.New() }},
{sidebar.NavItem{Icon: "󰒓", Name: "Settings"}, func() tea.Model { return settings.New() }},
}
type HelpProvider interface {
HelpBindings() []key.Binding
}
func navItems() []sidebar.NavItem {
items := make([]sidebar.NavItem, len(pages))
for i, p := range pages {
items[i] = p.item
}
return items
}
type keyMap struct {
FocusSidebar key.Binding
Close key.Binding
Help key.Binding
Quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
FocusSidebar: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "focus sidebar")),
Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q / ctrl+c", "quit")),
}
}
type Model struct {
sidebar sidebar.Model
page tea.Model
help helpbar.Model
notif notification.Model
modal modal.Model
drawer drawer.Model
keys keyMap
sidebarWidth int
hideNav bool
contentFocused bool
width, height int
}
func NewModel() Model {
keys := defaultKeyMap()
return Model{
sidebar: sidebar.New(navItems()...),
sidebarWidth: 24,
page: pages[0].new(),
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.FocusSidebar, keys.Quit)),
notif: notification.New(),
modal: modal.New(),
drawer: drawer.New(),
keys: keys,
hideNav: true,
}
}
func (m Model) Init() tea.Cmd {
return m.page.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m.resize()
case sidebar.SelectMsg:
m.page = pages[msg.Index].new()
var cmd tea.Cmd
m, cmd = m.resize()
return m, tea.Batch(m.page.Init(), cmd)
case sidebar.BlurMsg:
m.contentFocused = true
return m.resize()
case tea.KeyPressMsg:
if m.modal.Open() {
if key.Matches(msg, m.keys.Close) {
return m, modal.Close()
}
var cmd tea.Cmd
m.modal, cmd = m.modal.Update(msg)
return m, cmd
}
if m.drawer.Open() {
if key.Matches(msg, m.keys.Close) {
return m, drawer.Close()
}
var cmd tea.Cmd
m.drawer, cmd = m.drawer.Update(msg)
return m, cmd
}
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.help, _ = m.help.Update(msg)
return m.resize()
case key.Matches(msg, m.keys.FocusSidebar):
m.contentFocused = !m.contentFocused
return m.resize()
}
if m.contentFocused {
var cmd tea.Cmd
m.page, cmd = m.page.Update(msg)
return m, cmd
}
var cmd tea.Cmd
m.sidebar, cmd = m.sidebar.Update(msg)
return m, cmd
}
var notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd tea.Cmd
m.notif, notifCmd = m.notif.Update(msg)
m.modal, modalCmd = m.modal.Update(msg)
m.drawer, drawerCmd = m.drawer.Update(msg)
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
m.page, pageCmd = m.page.Update(msg)
return m, tea.Batch(notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd)
}
func (m Model) navWidth() int {
if m.hideNav && m.contentFocused {
return 0
}
return m.sidebarWidth
}
func (m Model) resize() (Model, tea.Cmd) {
if m.width <= 0 || m.height <= 0 {
return m, nil
}
m.help.SetWidth(m.width)
inner := style.ContentHeight(m.height - m.help.Height(m.focusedHelp()...))
navW := m.navWidth()
m.sidebar.SetSize(max(navW-2, 0), inner)
var cmd tea.Cmd
m.page, cmd = m.page.Update(tea.WindowSizeMsg{
Width: max(m.width-navW-2, 0),
Height: inner,
})
return m, cmd
}
func (m Model) focusedHelp() []key.Binding {
if !m.contentFocused {
return m.sidebar.HelpBindings()
}
if hp, ok := m.page.(HelpProvider); ok {
return hp.HelpBindings()
}
return nil
}
func (m Model) View() tea.View {
if m.width <= 0 || m.height <= 0 {
return tea.NewView("")
}
helpBar := m.help.View(m.focusedHelp()...)
bodyH := max(m.height-lipgloss.Height(helpBar), 0)
navW := m.navWidth()
body := style.RenderWithTitle(
panel(m.contentFocused), m.sidebar.Selected().Name, m.page.View().Content, m.width-navW, bodyH)
if navW > 0 {
left := style.RenderWithTitle(
panel(!m.contentFocused), "Menu", m.sidebar.View(), navW, bodyH)
body = lipgloss.JoinHorizontal(lipgloss.Top, left, body)
}
appView := lipgloss.JoinVertical(lipgloss.Left,
body,
helpBar,
)
view := tea.NewView(m.notif.Render(m.modal.Render(m.drawer.Render(appView))))
view.AltScreen = true
return view
}
func panel(focused bool) lipgloss.Style {
if focused {
return style.S.PanelFocused
}
return style.S.Panel
}
func main() {
if _, err := tea.NewProgram(NewModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-81
View File
@@ -1,81 +0,0 @@
// Package metrics is the fullapp example's second page: a spinner and a few
// gauges. It has no keys either, but unlike overview it runs a command, so
// it's what proves the shell keeps feeding ticks to a pane that doesn't have
// focus.
package metrics
import (
"fmt"
"charm.land/bubbles/v2/progress"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/style"
)
// gaugeWidth is how wide a bar renders, before the label in front of it.
const gaugeWidth = 30
type gauge struct {
name string
percent float64
}
// Model is the page. Values are hardcoded - this is a layout test, not a
// monitoring tool.
type Model struct {
spinner spinner.Model
bar progress.Model
gauges []gauge
width, height int
}
func New() Model {
bar := bubbles.NewProgress()
bar.SetWidth(gaugeWidth)
return Model{
spinner: bubbles.NewSpinner(spinner.WithSpinner(spinner.MiniDot)),
bar: bar,
gauges: []gauge{
{"cpu", 0.42},
{"memory", 0.71},
{"disk", 0.13},
},
}
}
// Init starts the spinner ticking. The shell returns it from its own Init,
// or the spinner never starts.
func (m Model) Init() tea.Cmd { return m.spinner.Tick }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = size.Width, size.Height
return m, nil
}
// Every other message goes to the spinner, whose own tick keeps it
// turning - including while another pane has focus.
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
func (m Model) View() tea.View {
rows := []string{style.S.Bold.Render(m.spinner.View() + " collecting")}
for _, g := range m.gauges {
// ViewAs renders a given percentage without animating toward it,
// which is all a fixed value needs.
rows = append(rows, fmt.Sprintf("%-8s %s", g.name, m.bar.ViewAs(g.percent)))
}
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
}
-45
View File
@@ -1,45 +0,0 @@
// Package overview is the fullapp example's first page: the simplest pane
// there is - no keys, no commands, just text sized to whatever room the
// shell gives it.
package overview
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// Model is the page. It's an ordinary tea.Model: nothing about it knows it
// lives in a shell, and it never implements HelpBindings because it has no
// keys of its own to advertise.
type Model struct {
width, height int
}
func New() Model { return Model{} }
func (m Model) Init() tea.Cmd { return nil }
// Update only tracks the size the shell hands down as a tea.WindowSizeMsg,
// same as if the page were the whole program.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = size.Width, size.Height
}
return m, nil
}
func (m Model) View() tea.View {
body := lipgloss.JoinVertical(lipgloss.Center,
style.S.Bold.Render("Overview"),
"",
style.S.Faint.Render("tab focuses this pane, but there's"),
style.S.Faint.Render("nothing here to focus on."),
)
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(body))
}
-102
View File
@@ -1,102 +0,0 @@
// Package settings is the fullapp example's third page: a short list of
// toggles. It's the one page with keys of its own, so it's what proves the
// shell routes presses to the focused pane and lists that pane's bindings in
// the help bar.
package settings
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
type toggle struct {
name string
on bool
}
type keyMap struct {
Up key.Binding
Down key.Binding
Toggle key.Binding
}
// Model is the page. The cursor is a plain int: three lines don't need a
// list.Model behind them.
type Model struct {
keys keyMap
toggles []toggle
cursor int
width, height int
}
func New() Model {
return Model{
keys: keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Toggle: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")),
},
toggles: []toggle{
{name: "Nerd fonts", on: style.S.NerdFonts},
{name: "Notifications", on: true},
{name: "Telemetry", on: false},
},
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
// The shell only sends these while this pane has focus, so there's
// no focused check to make here.
switch {
case key.Matches(msg, m.keys.Up):
m.cursor = max(m.cursor-1, 0)
case key.Matches(msg, m.keys.Down):
m.cursor = min(m.cursor+1, len(m.toggles)-1)
case key.Matches(msg, m.keys.Toggle):
m.toggles[m.cursor].on = !m.toggles[m.cursor].on
}
}
return m, nil
}
func (m Model) View() tea.View {
rows := make([]string, len(m.toggles))
for i, t := range m.toggles {
cursor, box := " ", "[ ]"
if i == m.cursor {
cursor = "> "
}
if t.on {
box = "[x]"
}
line := cursor + box + " " + t.name
if i == m.cursor {
line = lipgloss.NewStyle().Foreground(style.S.Primary).Render(line)
}
rows[i] = line
}
return tea.NewView(lipgloss.NewStyle().
Width(m.width).Height(m.height).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
}
// HelpBindings makes the page's keys show up in the shell's help bar while
// it has focus. It's the optional half of the contract: overview and metrics
// have no keys and don't implement it.
func (m Model) HelpBindings() []key.Binding {
return []key.Binding{m.keys.Up, m.keys.Down, m.keys.Toggle}
}
-134
View File
@@ -1,134 +0,0 @@
package sidebar
import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
)
type NavItem struct {
Icon string
Name string
}
func (n NavItem) Title() string {
if n.Icon == "" {
return n.Name
}
return n.Icon + " " + n.Name
}
func (n NavItem) Description() string { return "" }
func (n NavItem) FilterValue() string { return n.Name }
type SelectMsg struct {
Index int
Item NavItem
}
// BlurMsg is the sidebar asking to be given up: it goes out with the
// SelectMsg, on the grounds that picking an entry means you're done with the
// menu. Where focus lands instead is the host's call - the sidebar has no
// idea what else is on screen.
type BlurMsg struct{}
// blur is BlurMsg's command form.
func blur() tea.Msg { return BlurMsg{} }
type KeyMap struct {
Select key.Binding
}
func DefaultKeyMap() KeyMap {
return KeyMap{
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
}
}
type Model struct {
KeyMap KeyMap
list list.Model
items []NavItem
selected int
}
func New(items ...NavItem) Model {
d := bubbles.NewDefaultDelegate()
d.ShowDescription = false
d.SetSpacing(0)
l := bubbles.NewList(listItems(items), 0, 0)
l.SetDelegate(d)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetShowPagination(false)
l.SetFilteringEnabled(false)
l.DisableQuitKeybindings()
return Model{
KeyMap: DefaultKeyMap(),
list: l,
items: items,
}
}
func listItems(items []NavItem) []list.Item {
out := make([]list.Item, len(items))
for i, item := range items {
out[i] = item
}
return out
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if press, ok := msg.(tea.KeyPressMsg); ok && key.Matches(press, m.KeyMap.Select) {
// Only the interactive path asks for focus to move on. Select on its
// own doesn't, because the host also calls it to set the starting
// entry - which must not steal focus from anything.
return m, tea.Batch(m.Select(m.list.Index()), blur)
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m *Model) Select(index int) tea.Cmd {
if index < 0 || index >= len(m.items) {
return nil
}
m.selected = index
m.list.Select(index)
item := m.items[index]
return func() tea.Msg { return SelectMsg{Index: index, Item: item} }
}
func (m *Model) SetSize(width, height int) { m.list.SetSize(width, height) }
func (m Model) Selected() NavItem {
if m.selected < 0 || m.selected >= len(m.items) {
return NavItem{}
}
return m.items[m.selected]
}
func (m Model) SelectedIndex() int { return m.selected }
func (m Model) Cursor() int { return m.list.Index() }
func (m Model) View() string { return m.list.View() }
func (m Model) HelpBindings() []key.Binding {
return []key.Binding{
m.list.KeyMap.CursorUp,
m.list.KeyMap.CursorDown,
m.KeyMap.Select,
}
}
-111
View File
@@ -1,111 +0,0 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/style"
)
// confirmedMsg is what the confirmation modal reports back with. The app
// listens for it like any other message - it never holds a reference to the
// modal, and the modal never knows what confirming means.
type confirmedMsg struct{}
// confirm is the modal's content: a model, so it owns its own keys. The host
// no longer has to ask which modal is on top to know where "y" should go.
type confirm struct{}
func (c confirm) Init() tea.Cmd { return nil }
func (c confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "y" {
// Report back and close itself: Close() is a package-level command,
// so the content needs no reference to the modal.Model either.
return c, tea.Batch(
func() tea.Msg { return confirmedMsg{} },
modal.Close(),
)
}
return c, nil
}
func (c confirm) View() tea.View {
return tea.NewView("This can't be undone.\n\ny: confirm esc: cancel")
}
type model struct {
m modal.Model
deleted bool
width, height int
}
func newModel() model {
return model{m: modal.New()}
}
func (m model) Init() tea.Cmd { return m.m.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case confirmedMsg:
m.deleted = true
return m, nil
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "m":
return m, modal.Show("Delete file?", confirm{})
case "n":
if m.m.Open() {
return m, modal.Show("Really sure?", modal.Text("There's no undo for this one either."))
}
case "esc":
if m.m.Open() {
return m, modal.Close()
}
}
}
var cmd tea.Cmd
m.m, cmd = m.m.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App")
text := "Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the modal."
if m.deleted {
text = "File deleted - the modal's content reported back\nwith its own message, and closed itself."
}
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(text)
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"m: open modal n: open nested modal y: confirm esc: cancel q: quit")
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", body, "", help))
view := tea.NewView(m.m.Render(background))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-97
View File
@@ -1,97 +0,0 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/ilovetui/style"
)
var positions = []struct {
name string
pos notification.Position
}{
{"top", notification.Top},
{"top-left", notification.TopLeft},
{"top-right", notification.TopRight},
{"bottom", notification.Bottom},
{"bottom-left", notification.BottomLeft},
{"bottom-right", notification.BottomRight},
}
const stickyID = "sticky-demo"
type model struct {
notif notification.Model
posIdx int
width, height int
}
func newModel() model {
return model{notif: notification.New(notification.WithPosition(positions[0].pos))}
}
func (m model) Init() tea.Cmd {
return m.notif.Init()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "1":
return m, notification.Show("Info", "Just so you know.", notification.Info)
case "2":
return m, notification.Show("Success", "Config written to disk.", notification.Success)
case "3":
return m, notification.Show("Warning", "Disk space getting low on /dev/sda1.", notification.Warning)
case "4":
return m, notification.Show("Error", "Failed to reach the remote host.", notification.Error)
case "s":
return m, notification.Show("Sticky", "Stays until you press d.",
notification.Info, notification.WithID(stickyID), notification.WithDuration(0))
case "d":
return m, notification.Dismiss(stickyID)
case "p":
m.posIdx = (m.posIdx + 1) % len(positions)
m.notif = notification.New(notification.WithPosition(positions[m.posIdx].pos))
return m, m.notif.Init()
}
}
var cmd tea.Cmd
m.notif, cmd = m.notif.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
"1-4: info/success/warning/error s: sticky d: dismiss sticky p: position (" +
positions[m.posIdx].name + ") q: quit")
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, help)
view := tea.NewView(m.notif.Render(background))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-235
View File
@@ -1,235 +0,0 @@
// Command sidebar is the whole app-shell pattern in one file: a sidebar on
// the left, an ordinary tea.Model on the right, a global help bar at the
// bottom, tab to move focus between the two. No layout package involved -
// lipgloss.JoinHorizontal/JoinVertical and style.RenderWithTitle already do
// all of it, and the panes stay plain tea.Models.
package main
import (
"fmt"
"os"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/style"
)
// sidebarWidth is the sidebar's total width, border included.
const sidebarWidth = 24
// HelpProvider is the only contract in this pattern, and it's optional: a
// pane that implements it gets its own bindings listed in the global help
// bar while it's focused. A pane that doesn't just contributes nothing.
type HelpProvider interface {
HelpBindings() []key.Binding
}
// ---------------------------------------------------------------- shell keys
type keyMap struct {
Focus key.Binding
Help key.Binding
Quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
Focus: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch pane")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q", "quit")),
}
}
// ---------------------------------------------------------------- the shell
type model struct {
sidebar list.Model
content tea.Model
help helpbar.Model
keys keyMap
contentFocused bool
w, h int
}
func newModel() model {
items := []list.Item{page("Overview"), page("Metrics"), page("Settings")}
sidebar := bubbles.NewList(items, sidebarWidth-2, 0)
sidebar.SetShowTitle(false)
sidebar.SetShowStatusBar(false)
sidebar.SetShowHelp(false)
keys := defaultKeyMap()
return model{
sidebar: sidebar,
content: newCounter(),
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.Focus, keys.Quit)),
keys: keys,
}
}
func (m model) Init() tea.Cmd { return m.content.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height
return m.resize()
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
// Expanding the bar takes rows away from the panes.
m.help, _ = m.help.Update(msg)
return m.resize()
case key.Matches(msg, m.keys.Focus):
m.contentFocused = !m.contentFocused
return m, nil
}
// Only the focused pane sees key presses.
if m.contentFocused {
var cmd tea.Cmd
m.content, cmd = m.content.Update(msg)
return m, cmd
}
var cmd tea.Cmd
m.sidebar, cmd = m.sidebar.Update(msg)
return m, cmd
}
// Everything else (ticks, HTTP responses...) goes to both, so a blurred
// pane keeps working.
var sidebarCmd, contentCmd tea.Cmd
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
m.content, contentCmd = m.content.Update(msg)
return m, tea.Batch(sidebarCmd, contentCmd)
}
// bodyHeight is the height left for the two panes once the help bar has
// taken its share. resize and View both go through it so they can't disagree.
func (m model) bodyHeight() int {
return max(m.h-m.help.Height(m.focusedHelp()...), 0)
}
func (m model) resize() (model, tea.Cmd) {
if m.w <= 0 || m.h <= 0 {
return m, nil
}
m.help.SetWidth(m.w)
inner := style.ContentHeight(m.bodyHeight())
m.sidebar.SetSize(sidebarWidth-2, inner)
var cmd tea.Cmd
m.content, cmd = m.content.Update(tea.WindowSizeMsg{
Width: max(m.w-sidebarWidth-2, 0), Height: inner,
})
return m, cmd
}
// focusedHelp is the focused pane's own bindings, if it offers any.
func (m model) focusedHelp() []key.Binding {
if m.contentFocused {
if hp, ok := m.content.(HelpProvider); ok {
return hp.HelpBindings()
}
return nil
}
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
}
func (m model) View() tea.View {
if m.w <= 0 || m.h <= 0 {
return tea.NewView("")
}
helpBar := m.help.View(m.focusedHelp()...)
bodyH := m.bodyHeight()
left := style.RenderWithTitle(
panel(!m.contentFocused), "Menu", m.sidebar.View(), sidebarWidth, bodyH)
right := style.RenderWithTitle(
panel(m.contentFocused), "Content", m.content.View().Content, m.w-sidebarWidth, bodyH)
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
lipgloss.JoinHorizontal(lipgloss.Top, left, right),
helpBar,
))
view.AltScreen = true
return view
}
// panel picks the bordered panel style matching a pane's focus state.
func panel(focused bool) lipgloss.Style {
if focused {
return style.S.PanelFocused
}
return style.S.Panel
}
// ------------------------------------------------------- the right-hand pane
// counter is an ordinary tea.Model - nothing about it knows it's living in a
// shell. It implements HelpProvider purely to appear in the help bar.
type counter struct {
n int
w, h int
keys struct{ Inc, Dec key.Binding }
}
func newCounter() *counter {
c := &counter{}
c.keys.Inc = key.NewBinding(key.WithKeys("+", "k"), key.WithHelp("+/k", "increment"))
c.keys.Dec = key.NewBinding(key.WithKeys("-", "j"), key.WithHelp("-/j", "decrement"))
return c
}
func (c *counter) Init() tea.Cmd { return nil }
func (c *counter) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.w, c.h = msg.Width, msg.Height
case tea.KeyPressMsg:
switch {
case key.Matches(msg, c.keys.Inc):
c.n++
case key.Matches(msg, c.keys.Dec):
c.n--
}
}
return c, nil
}
func (c *counter) View() tea.View {
return tea.NewView(lipgloss.NewStyle().
Width(c.w).Height(c.h).
AlignHorizontal(lipgloss.Center).
AlignVertical(lipgloss.Center).
Render(fmt.Sprintf("count: %d", c.n)))
}
func (c *counter) HelpBindings() []key.Binding {
return []key.Binding{c.keys.Inc, c.keys.Dec}
}
// ------------------------------------------------------------- sidebar items
type page string
func (p page) Title() string { return string(p) }
func (p page) Description() string { return "" }
func (p page) FilterValue() string { return string(p) }
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
-111
View File
@@ -1,111 +0,0 @@
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/tabs"
)
// pane is a minimal tabs.Tab implementation. It keeps its own counter to
// show that each tab's model has independent state that persists across
// switches, and its own width/height to show how a host propagates size
// down to a wrapped Tab (see model.Update's tea.WindowSizeMsg case).
type pane struct {
name string
count int
width, height int
}
func newPane(name string) pane {
return pane{name: name}
}
func (p pane) Init() tea.Cmd {
return nil
}
func (p pane) Update(msg tea.Msg) (tabs.Tab, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.width, p.height = msg.Width, msg.Height
case tea.KeyPressMsg:
if msg.String() == "+" {
p.count++
}
}
return p, nil
}
func (p pane) View() string {
return fmt.Sprintf("%s\n\npress + to increment: %d\n(content area: %dx%d)", p.name, p.count, p.width, p.height)
}
var docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2)
type model struct {
tabs tabs.Model
}
func newModel() model {
items := []tabs.Item{
{Title: "Lip Gloss", Model: newPane("Lip Gloss")},
{Title: "Blush", Model: newPane("Blush")},
{Title: "Eye Shadow", Model: newPane("Eye Shadow")},
{Title: "Mascara", Model: newPane("Mascara")},
{Title: "Foundation", Model: newPane("Foundation")},
}
return model{tabs: tabs.New(items)}
}
func (m model) Init() tea.Cmd {
return m.tabs.Init()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" || msg.String() == "q" {
return m, tea.Quit
}
case tea.WindowSizeMsg:
// Size the tabs component to fill the terminal, net of docStyle's own
// frame. The tab bar itself keeps its natural width; Content stretches.
m.tabs.SetSize(
msg.Width-docStyle.GetHorizontalFrameSize(),
msg.Height-docStyle.GetVerticalFrameSize(),
)
// tabs has no generic way to size an arbitrary Tab itself, so forward
// the actual usable content area as a WindowSizeMsg: tabs.Update
// already routes non-key messages to the active item's Update, so
// this reaches pane.Update's own tea.WindowSizeMsg case above.
var cmd tea.Cmd
m.tabs, cmd = m.tabs.Update(tea.WindowSizeMsg{
Width: m.tabs.ContentWidth(),
Height: m.tabs.ContentHeight(),
})
return m, cmd
}
var cmd tea.Cmd
m.tabs, cmd = m.tabs.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
view := tea.NewView(docStyle.Render(m.tabs.View()))
view.AltScreen = true
return view
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
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 minimal Go library that provides a shared Base16color theme for terminal UIs built with bubbletea and lipgloss.";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
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=
-91
View File
@@ -1,91 +0,0 @@
# Helpbar
A responsive help bar: one line of key bindings that expands, on demand, into a multi-column view reflowed to use as many columns as the available width allows.
## Quick start
```go
import "github.com/anotherhadi/ilovetui/helpbar"
type model struct {
help helpbar.Model
keys keyMap
h int
}
func newModel() model {
keys := defaultKeyMap()
return model{
help: helpbar.New(
helpbar.WithToggle(keys.Help), // '?' expands/collapses
helpbar.WithGlobal(keys.Focus, keys.Quit), // always shown
),
keys: keys,
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.h = msg.Height
m.help.SetWidth(msg.Width)
case tea.KeyPressMsg:
m.help, _ = m.help.Update(msg) // flips ShowAll on the toggle binding
}
return m, nil
}
func (m model) View() tea.View {
bar := m.help.View(m.focused().HelpBindings()...)
body := renderBody(m.h - lipgloss.Height(bar))
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, bar))
}
```
## Global vs. contextual bindings
Bindings come from two places, and they render in this order:
1. The `WithToggle` binding, so the way to expand the bar always leads.
2. `WithGlobal` bindings - what your app reserves for itself (quit, switch pane...), set once.
3. Contextual bindings, passed to `View` at render time.
Contextual bindings are meant to change from render to render, so the bar can track whatever component currently has focus. Nothing in the package knows what "focus" means for your app - you just hand it a different slice:
```go
func (m model) focusedHelp() []key.Binding {
if m.contentFocused {
return m.content.HelpBindings()
}
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
}
```
Disabled bindings (`key.Binding.SetEnabled(false)`) are dropped before layout, so the reflow never
budgets width for something that won't be drawn.
## Reserving room for the bar
The bar's height depends on its content and on whether it's expanded, so ask it rather than assuming
a fixed number of rows:
```go
body := m.height - m.help.Height(contextual...)
```
`Height` is exactly `lipgloss.Height` of what `View` returns for the same arguments, so the two can
never disagree about where the bar begins. An empty bar (no bindings, or no width set) renders `""`
and takes zero rows.
## Styling
Defaults come from the shared `style` theme. Override with `WithStyles`:
```go
helpbar.New(helpbar.WithStyles(myStyles)) // help.Styles from charm.land/bubbles/v2/help
```
## Examples
- `examples/sidebar` uses it as an app-wide bar tracking the focused pane.
- `examples/app` full app with a single help bar

Some files were not shown because too many files have changed in this diff Show More