mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-10-05 10:58:26 +02:00
rewrite bubbletea -> opentui
This commit is contained in:
@@ -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:"
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
custom: ["https://hadi.icu/donate"]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
|
|
||||||
- uses: goreleaser/goreleaser-action@v6
|
|
||||||
with:
|
|
||||||
version: "~> v2"
|
|
||||||
args: release --clean --config .github/.goreleaser.yaml
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
+2
-4
@@ -1,4 +1,2 @@
|
|||||||
.claude/
|
node_modules/
|
||||||
CLAUDE.md
|
dist/
|
||||||
result/
|
|
||||||
.pre-commit-config.yaml
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -1,36 +1,22 @@
|
|||||||
# I Love TUI
|
# I Love TUI
|
||||||
|
|
||||||
A shared [Base16](https://github.com/tinted-theming/home) theme, a themed wrapper around every official component, and a small set of custom Bubble Tea components, in one Go module, so every TUI built with it shares one config file and looks consistent.
|
Shared OpenTUI/Solid theme, components, and config helpers for my TUIs.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```sh
|
```bash
|
||||||
go get github.com/anotherhadi/ilovetui
|
bun add github:anotherhadi/ilovetui
|
||||||
```
|
```
|
||||||
|
|
||||||
## Packages
|
Peer dependencies, install whichever you actually use: `@opentui/core`, `@opentui/solid`, `@opentui/keymap`, `solid-js`, `opentui-spinner`.
|
||||||
|
|
||||||
- [`style`](style/README.md): the theme itself. Colors, pre-built panel styles, config loading.
|
## Usage
|
||||||
- [`bubbles`](bubbles/README.md): themed constructors for official `bubbles/v2` components (`help`, `textarea`, `textinput`, `viewport`, ...).
|
|
||||||
- [`tabs`](tabs/README.md), [`modal`](modal/README.md), [`drawer`](drawer/README.md), [`notification`](notification/README.md), [`helpbar`](helpbar/README.md): custom components not found in the official `bubbles` library, styled from the same theme.
|
|
||||||
|
|
||||||
Each package has its own README and a runnable example under `examples/<package>`.
|
```ts
|
||||||
|
import { theme, presets } from "ilovetui";
|
||||||
## Quick start
|
import "ilovetui/solid";
|
||||||
|
|
||||||
On import, `style` automatically loads the user's theme from `~/.config/ilovetui/config.yaml` (embedded default as fallback), exposed as the package-level `S`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
import "github.com/anotherhadi/ilovetui/style"
|
|
||||||
|
|
||||||
s := lipgloss.NewStyle().Foreground(style.S.Primary)
|
|
||||||
box := style.RenderWithTitle(style.S.PanelFocused, "Title", content, w, h)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
No setup required. See [`style/README.md`](style/README.md) for config details.
|
Entry points: `.`, `./solid`, `./context`, `./project-config`, `./keymap`, `./components`, `./components/help`, `./components/spinner`. See the source for what each exports.
|
||||||
|
|
||||||
## Projects using ilovetui
|
The theme is base16 and user-overridable via `~/.config/ilovetui/config.yaml`. Copy [`src/default.yaml`](https://github.com/anotherhadi/ilovetui/blob/main/src/default.yaml) there as a starting point.
|
||||||
|
|
||||||
- [anotherhadi/spilltea](https://github.com/anotherhadi/spilltea): A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players. Think Burp Suite or Caido, but entirely in your terminal.
|
|
||||||
- [anotherhadi/usbguard-tui](https://github.com/anotherhadi/usbguard-tui): A terminal UI for managing USB devices via usbguard. TUI built with golang & bubbletea.
|
|
||||||
- [anotherhadi/jwt-tui](https://github.com/anotherhadi/jwt-tui): A terminal UI for inspecting, editing, and signing JSON Web Tokens (JWTs).
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RenderToggle(on bool) string {
|
|
||||||
if !style.S.NerdFonts {
|
|
||||||
if on {
|
|
||||||
return lipgloss.NewStyle().
|
|
||||||
Foreground(style.S.Success).
|
|
||||||
Bold(true).
|
|
||||||
Render("on")
|
|
||||||
}
|
|
||||||
return lipgloss.NewStyle().
|
|
||||||
Foreground(style.S.Subtle).
|
|
||||||
Bold(true).
|
|
||||||
Render("off")
|
|
||||||
}
|
|
||||||
|
|
||||||
if on {
|
|
||||||
return RenderBadge(" ●", style.S.Success)
|
|
||||||
}
|
|
||||||
return RenderBadge("● ", style.S.Subtle)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RenderBadge(label string, fill color.Color) string {
|
|
||||||
body := lipgloss.NewStyle().
|
|
||||||
Background(fill).
|
|
||||||
Foreground(style.S.Background).
|
|
||||||
Bold(true).
|
|
||||||
Render(label)
|
|
||||||
|
|
||||||
cap := lipgloss.NewStyle().Foreground(fill).Background(style.S.Background)
|
|
||||||
return cap.Render("") + body + cap.Render("")
|
|
||||||
}
|
|
||||||
|
|
||||||
func SignalBars(signal int) string {
|
|
||||||
bars := []rune("▁▃▅▇")
|
|
||||||
level := signal * len(bars) / 100
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
for i, ch := range bars {
|
|
||||||
s := lipgloss.NewStyle().Foreground(style.S.Subtle)
|
|
||||||
if i < level {
|
|
||||||
s = lipgloss.NewStyle().Foreground(style.S.Text)
|
|
||||||
}
|
|
||||||
b.WriteString(s.Render(string(ch)))
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
type QuitMsg struct{}
|
|
||||||
|
|
||||||
func Quit() tea.Cmd {
|
|
||||||
return func() tea.Msg { return QuitMsg{} }
|
|
||||||
}
|
|
||||||
|
|
||||||
type FocusMsg struct{}
|
|
||||||
|
|
||||||
func Focus() tea.Cmd {
|
|
||||||
return func() tea.Msg { return FocusMsg{} }
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlurMsg struct{}
|
|
||||||
|
|
||||||
func Blur() tea.Cmd {
|
|
||||||
return func() tea.Msg { return BlurMsg{} }
|
|
||||||
}
|
|
||||||
|
|
||||||
type RequestFocusMsg struct{}
|
|
||||||
|
|
||||||
func RequestFocus() tea.Cmd {
|
|
||||||
return func() tea.Msg { return RequestFocusMsg{} }
|
|
||||||
}
|
|
||||||
|
|
||||||
type SetTitleMsg struct{ Title string }
|
|
||||||
|
|
||||||
func SetTitle(title string) tea.Cmd {
|
|
||||||
return func() tea.Msg { return SetTitleMsg{Title: title} }
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# bubbles
|
|
||||||
|
|
||||||
Themed constructors for every official `bubbles/v2` component: `help`, `textarea`, `textinput`,
|
|
||||||
`list`, `table`, `filepicker`, `spinner`, `progress`, `paginator`, `viewport`. Each wraps the
|
|
||||||
official `New`, then applies `style.S` colors on top.
|
|
||||||
|
|
||||||
- `spinner`, `table`, `progress` forward any `opts` to the upstream `New` before the theme is
|
|
||||||
applied, so the theme's colors always win over anything conflicting in `opts`.
|
|
||||||
- `ViewportView` renders a `viewport.Model` with a themed scrollbar thumb in the left gutter,
|
|
||||||
shown only when content overflows.
|
|
||||||
- `NewList`/`NewDefaultDelegate` theme both the list chrome and the selection/filter colors of its
|
|
||||||
default delegate.
|
|
||||||
|
|
||||||
Custom components in this repo (`tabs`, `modal`, `drawer`, `notification`, `helpbar`) build any
|
|
||||||
official component they need through here, never through `charm.land/bubbles/v2` directly.
|
|
||||||
|
|
||||||
See `examples/bubbles`.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package bubbles
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package bubbles
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/filepicker"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewFilePicker() filepicker.Model {
|
|
||||||
f := filepicker.New()
|
|
||||||
s := filepicker.DefaultStyles()
|
|
||||||
s.Cursor = s.Cursor.Foreground(style.S.Primary)
|
|
||||||
s.DisabledCursor = s.DisabledCursor.Foreground(style.S.Subtle)
|
|
||||||
s.Symlink = s.Symlink.Foreground(style.S.Warning)
|
|
||||||
s.Directory = s.Directory.Foreground(style.S.Primary)
|
|
||||||
s.File = s.File.Foreground(style.S.Text)
|
|
||||||
s.DisabledFile = s.DisabledFile.Foreground(style.S.Subtle)
|
|
||||||
s.Permission = s.Permission.Foreground(style.S.Muted)
|
|
||||||
s.Selected = s.Selected.Foreground(style.S.Primary)
|
|
||||||
s.DisabledSelected = s.DisabledSelected.Foreground(style.S.Subtle)
|
|
||||||
s.FileSize = s.FileSize.Foreground(style.S.Muted)
|
|
||||||
s.EmptyDirectory = s.EmptyDirectory.Foreground(style.S.Subtle)
|
|
||||||
f.Styles = s
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package bubbles
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/help"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewHelp() help.Model {
|
|
||||||
h := help.New()
|
|
||||||
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(style.S.Primary)
|
|
||||||
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
|
|
||||||
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
|
||||||
h.Styles.FullKey = lipgloss.NewStyle().Foreground(style.S.Primary)
|
|
||||||
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(style.S.Muted)
|
|
||||||
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
|
||||||
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package bubbles
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/list"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewList(items []list.Item, width, height int) list.Model {
|
|
||||||
m := list.New(items, NewDefaultDelegate(), width, height)
|
|
||||||
m.Styles = themedListStyles()
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func themedListStyles() list.Styles {
|
|
||||||
s := list.DefaultStyles(true)
|
|
||||||
s.Title = s.Title.Background(style.S.Primary).Foreground(style.S.Background)
|
|
||||||
s.Spinner = s.Spinner.Foreground(style.S.Primary)
|
|
||||||
s.Filter = themedTextInputStyles()
|
|
||||||
s.DefaultFilterCharacterMatch = s.DefaultFilterCharacterMatch.Foreground(style.S.Primary)
|
|
||||||
s.StatusBar = s.StatusBar.Foreground(style.S.Muted)
|
|
||||||
s.StatusEmpty = s.StatusEmpty.Foreground(style.S.Subtle)
|
|
||||||
s.StatusBarActiveFilter = s.StatusBarActiveFilter.Foreground(style.S.Text)
|
|
||||||
s.StatusBarFilterCount = s.StatusBarFilterCount.Foreground(style.S.Subtle)
|
|
||||||
s.NoItems = s.NoItems.Foreground(style.S.Subtle)
|
|
||||||
s.ArabicPagination = s.ArabicPagination.Foreground(style.S.Subtle)
|
|
||||||
s.ActivePaginationDot = s.ActivePaginationDot.Foreground(style.S.Primary)
|
|
||||||
s.InactivePaginationDot = s.InactivePaginationDot.Foreground(style.S.Subtle)
|
|
||||||
s.DividerDot = s.DividerDot.Foreground(style.S.Subtle)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDefaultDelegate() list.DefaultDelegate {
|
|
||||||
d := list.NewDefaultDelegate()
|
|
||||||
d.Styles.NormalTitle = d.Styles.NormalTitle.Foreground(style.S.Text)
|
|
||||||
d.Styles.NormalDesc = d.Styles.NormalDesc.Foreground(style.S.Muted)
|
|
||||||
d.Styles.SelectedTitle = d.Styles.SelectedTitle.
|
|
||||||
BorderForeground(style.S.Primary).
|
|
||||||
Foreground(style.S.Primary)
|
|
||||||
d.Styles.SelectedDesc = d.Styles.SelectedDesc.
|
|
||||||
BorderForeground(style.S.Primary).
|
|
||||||
Foreground(style.S.Primary)
|
|
||||||
d.Styles.DimmedTitle = d.Styles.DimmedTitle.Foreground(style.S.Subtle)
|
|
||||||
d.Styles.DimmedDesc = d.Styles.DimmedDesc.Foreground(style.S.SubtleBg)
|
|
||||||
d.Styles.FilterMatch = d.Styles.FilterMatch.Foreground(style.S.Primary)
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package bubbles
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/paginator"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewPaginator() paginator.Model {
|
|
||||||
p := paginator.New()
|
|
||||||
p.Type = paginator.Dots
|
|
||||||
p.ActiveDot = lipgloss.NewStyle().Foreground(style.S.Primary).Render("•")
|
|
||||||
p.InactiveDot = lipgloss.NewStyle().Foreground(style.S.Subtle).Render("•")
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package bubbles
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/progress"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewProgress(opts ...progress.Option) progress.Model {
|
|
||||||
allOpts := append([]progress.Option{
|
|
||||||
progress.WithColors(style.S.Subtle, style.S.Primary),
|
|
||||||
}, opts...)
|
|
||||||
p := progress.New(allOpts...)
|
|
||||||
p.EmptyColor = style.S.SubtleBg
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package bubbles
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/spinner"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewSpinner(opts ...spinner.Option) spinner.Model {
|
|
||||||
s := spinner.New(opts...)
|
|
||||||
s.Style = lipgloss.NewStyle().Foreground(style.S.Primary)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package bubbles
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/table"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewTable(opts ...table.Option) table.Model {
|
|
||||||
t := table.New(opts...)
|
|
||||||
s := table.DefaultStyles()
|
|
||||||
s.Header = s.Header.Foreground(style.S.Primary)
|
|
||||||
s.Cell = s.Cell.Foreground(style.S.Text)
|
|
||||||
s.Selected = s.Selected.Foreground(style.S.Primary)
|
|
||||||
t.SetStyles(s)
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package bubbles
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/textarea"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewTextarea(showLineNumbers bool) textarea.Model {
|
|
||||||
ta := textarea.New()
|
|
||||||
ta.Prompt = ""
|
|
||||||
ta.ShowLineNumbers = showLineNumbers
|
|
||||||
ta.CharLimit = 0
|
|
||||||
ta.EndOfBufferCharacter = '~'
|
|
||||||
ts := ta.Styles()
|
|
||||||
ts.Focused.Base = lipgloss.NewStyle()
|
|
||||||
ts.Blurred.Base = lipgloss.NewStyle()
|
|
||||||
ts.Focused.Text = lipgloss.NewStyle().Foreground(style.S.Text)
|
|
||||||
ts.Focused.CursorLine = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Text)
|
|
||||||
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(style.S.Selection).Foreground(style.S.Primary).Bold(true)
|
|
||||||
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
|
||||||
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
|
||||||
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
|
|
||||||
ts.Blurred.Text = lipgloss.NewStyle().Foreground(style.S.Muted)
|
|
||||||
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
|
|
||||||
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(style.S.Subtle)
|
|
||||||
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(style.S.SubtleBg)
|
|
||||||
ta.SetStyles(ts)
|
|
||||||
return ta
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package bubbles
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/textinput"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewTextInput() textinput.Model {
|
|
||||||
t := textinput.New()
|
|
||||||
t.SetStyles(themedTextInputStyles())
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
func themedTextInputStyles() textinput.Styles {
|
|
||||||
s := textinput.DefaultStyles(true)
|
|
||||||
s.Focused.Text = s.Focused.Text.Foreground(style.S.Text)
|
|
||||||
s.Focused.Placeholder = s.Focused.Placeholder.Foreground(style.S.Subtle)
|
|
||||||
s.Focused.Suggestion = s.Focused.Suggestion.Foreground(style.S.Subtle)
|
|
||||||
s.Focused.Prompt = s.Focused.Prompt.Foreground(style.S.Primary)
|
|
||||||
s.Blurred.Text = s.Blurred.Text.Foreground(style.S.Muted)
|
|
||||||
s.Blurred.Placeholder = s.Blurred.Placeholder.Foreground(style.S.Subtle)
|
|
||||||
s.Blurred.Suggestion = s.Blurred.Suggestion.Foreground(style.S.Subtle)
|
|
||||||
s.Blurred.Prompt = s.Blurred.Prompt.Foreground(style.S.Subtle)
|
|
||||||
s.Cursor.Color = style.S.Primary
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package bubbles
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/viewport"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
const ViewportGutterWidth = 2
|
|
||||||
|
|
||||||
func NewViewport() viewport.Model {
|
|
||||||
vp := viewport.New()
|
|
||||||
vp.MouseWheelEnabled = false
|
|
||||||
return vp
|
|
||||||
}
|
|
||||||
|
|
||||||
func ViewportView(vp *viewport.Model) string {
|
|
||||||
height := vp.Height()
|
|
||||||
total := vp.TotalLineCount()
|
|
||||||
blank := lipgloss.NewStyle().Width(ViewportGutterWidth).Render("")
|
|
||||||
|
|
||||||
if height <= 0 || total <= height {
|
|
||||||
vp.LeftGutterFunc = func(viewport.GutterContext) string { return blank }
|
|
||||||
return vp.View()
|
|
||||||
}
|
|
||||||
|
|
||||||
yOffset := vp.YOffset()
|
|
||||||
thumbSize := max(1, height*height/total)
|
|
||||||
thumbStart := 0
|
|
||||||
if maxOffset := total - height; maxOffset > 0 {
|
|
||||||
thumbStart = yOffset * (height - thumbSize) / maxOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
trackStyle := lipgloss.NewStyle().Foreground(style.S.Subtle)
|
|
||||||
thumbStyle := lipgloss.NewStyle().Foreground(style.S.Primary)
|
|
||||||
|
|
||||||
vp.LeftGutterFunc = func(ctx viewport.GutterContext) string {
|
|
||||||
pos := ctx.Index - yOffset
|
|
||||||
if pos >= thumbStart && pos < thumbStart+thumbSize {
|
|
||||||
return thumbStyle.Render("█") + " "
|
|
||||||
}
|
|
||||||
return trackStyle.Render("│") + " "
|
|
||||||
}
|
|
||||||
|
|
||||||
return vp.View()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "ilovetui-opentui",
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.8.1",
|
||||||
|
"zod": "^4.5.4",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@opentui/keymap": "^0.5.9",
|
||||||
|
"@opentui/solid": "^0.5.9",
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
|
"opentui-spinner": "^0.0.7",
|
||||||
|
"solid-js": "1.9.12",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentui/core": ">=0.5.0",
|
||||||
|
"@opentui/keymap": ">=0.5.0",
|
||||||
|
"@opentui/solid": ">=0.5.0",
|
||||||
|
"opentui-spinner": ">=0.0.7",
|
||||||
|
"solid-js": ">=1.9.0",
|
||||||
|
},
|
||||||
|
"optionalPeers": [
|
||||||
|
"@opentui/keymap",
|
||||||
|
"@opentui/solid",
|
||||||
|
"opentui-spinner",
|
||||||
|
"solid-js",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@ampproject/remapping": ["@ampproject/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||||
|
|
||||||
|
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||||
|
|
||||||
|
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||||
|
|
||||||
|
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||||
|
|
||||||
|
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
|
||||||
|
|
||||||
|
"@babel/helper-annotate-as-pure": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||||
|
|
||||||
|
"@babel/helper-create-class-features-plugin": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="],
|
||||||
|
|
||||||
|
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||||
|
|
||||||
|
"@babel/helper-member-expression-to-functions": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||||
|
|
||||||
|
"@babel/helper-optimise-call-expression": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="],
|
||||||
|
|
||||||
|
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||||
|
|
||||||
|
"@babel/helper-replace-supers": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="],
|
||||||
|
|
||||||
|
"@babel/helper-skip-transparent-expression-wrappers": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="],
|
||||||
|
|
||||||
|
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||||
|
|
||||||
|
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
|
||||||
|
|
||||||
|
"@babel/plugin-syntax-jsx": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
|
||||||
|
|
||||||
|
"@babel/plugin-syntax-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-modules-commonjs": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="],
|
||||||
|
|
||||||
|
"@babel/preset-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
|
||||||
|
|
||||||
|
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||||
|
|
||||||
|
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@opentui/core": ["@opentui/[email protected]", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
|
||||||
|
|
||||||
|
"@opentui/core-darwin-arm64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||||
|
|
||||||
|
"@opentui/core-darwin-x64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-arm64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-arm64-musl": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-x64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-x64-musl": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||||
|
|
||||||
|
"@opentui/core-win32-arm64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||||
|
|
||||||
|
"@opentui/core-win32-x64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||||
|
|
||||||
|
"@opentui/keymap": ["@opentui/[email protected]", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
|
||||||
|
|
||||||
|
"@opentui/solid": ["@opentui/[email protected]", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["[email protected]", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
|
||||||
|
|
||||||
|
"babel-plugin-jsx-dom-expressions": ["[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA=="],
|
||||||
|
|
||||||
|
"babel-plugin-module-resolver": ["[email protected]", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="],
|
||||||
|
|
||||||
|
"babel-preset-solid": ["[email protected]", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
|
||||||
|
|
||||||
|
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||||
|
|
||||||
|
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
|
||||||
|
|
||||||
|
"bun-ffi-structs": ["[email protected]", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["[email protected]", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
|
||||||
|
|
||||||
|
"cli-spinners": ["[email protected]", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
||||||
|
|
||||||
|
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"diff": ["[email protected]", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["[email protected]", "", {}, "sha512-4T+DTDWuMPM4aHlHwWdAVCVWwp7LDilnhzkj+c/Lbj91XSQrLuOmZSLtS9Q4iIqjlPUbPOnC624zDVVHCHaolQ=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["[email protected]", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
|
"entities": ["[email protected]", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||||
|
|
||||||
|
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"find-babel-config": ["[email protected]", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||||
|
|
||||||
|
"find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
|
||||||
|
|
||||||
|
"fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||||
|
|
||||||
|
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["[email protected]", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||||
|
|
||||||
|
"glob": ["[email protected]", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
|
||||||
|
|
||||||
|
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||||
|
|
||||||
|
"html-entities": ["[email protected]", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||||
|
|
||||||
|
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
|
||||||
|
|
||||||
|
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
|
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
|
||||||
|
|
||||||
|
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||||
|
|
||||||
|
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
|
||||||
|
|
||||||
|
"minipass": ["[email protected]", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
|
||||||
|
|
||||||
|
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"node-releases": ["[email protected]", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="],
|
||||||
|
|
||||||
|
"opentui-spinner": ["[email protected]", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="],
|
||||||
|
|
||||||
|
"p-limit": ["[email protected]", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||||
|
|
||||||
|
"p-locate": ["[email protected]", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||||
|
|
||||||
|
"p-try": ["[email protected]", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
|
"parse5": ["[email protected]", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||||
|
|
||||||
|
"path-exists": ["[email protected]", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
|
||||||
|
|
||||||
|
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"path-scurry": ["[email protected]", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||||
|
|
||||||
|
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"pkg-up": ["[email protected]", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
|
||||||
|
|
||||||
|
"reselect": ["[email protected]", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
|
||||||
|
|
||||||
|
"resolve": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
||||||
|
|
||||||
|
"s-js": ["[email protected]", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="],
|
||||||
|
|
||||||
|
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"seroval": ["[email protected]", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="],
|
||||||
|
|
||||||
|
"seroval-plugins": ["[email protected]", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="],
|
||||||
|
|
||||||
|
"solid-js": ["[email protected]", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
|
||||||
|
|
||||||
|
"string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
|
|
||||||
|
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="],
|
||||||
|
|
||||||
|
"web-tree-sitter": ["[email protected]", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
||||||
|
|
||||||
|
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
|
"yaml": ["[email protected]", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||||
|
|
||||||
|
"zod": ["[email protected]", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
|
||||||
|
|
||||||
|
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
||||||
|
|
||||||
|
"parse5/entities": ["[email protected]", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||||
|
|
||||||
|
"path-scurry/lru-cache": ["[email protected]", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
|
"path-scurry/minipass": ["[email protected]", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# drawer
|
|
||||||
|
|
||||||
A full-height panel flush against the left or right edge of an already-rendered background, on top
|
|
||||||
of it dimmed. The sidebar/drawer equivalent of [`modal`](../modal/README.md), which it otherwise
|
|
||||||
mirrors closely: same `tea.Msg`-triggered stack (`ShowMsg`/`Show`), same composite-over-a-string
|
|
||||||
`Render`, same rule that content is a `tea.Model`.
|
|
||||||
|
|
||||||
- `WithSide(Left|Right)` and `WithWidth` are per-drawer `Show` options; width otherwise shrinks to
|
|
||||||
fit content, capped by the `Model`'s `WithMaxWidth`.
|
|
||||||
- The stack is a plain LIFO. Only the topmost drawer is updated; `Close()` closes it.
|
|
||||||
- `View(width, height)` renders on a blank background of that size, for use as a standalone pane.
|
|
||||||
|
|
||||||
See `examples/drawer`.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package drawer
|
|
||||||
|
|
||||||
import tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
type Side int
|
|
||||||
|
|
||||||
const (
|
|
||||||
Left Side = iota
|
|
||||||
Right
|
|
||||||
)
|
|
||||||
|
|
||||||
type Drawer struct {
|
|
||||||
Title string
|
|
||||||
Content tea.Model
|
|
||||||
Side Side
|
|
||||||
Width int
|
|
||||||
Style *Styles
|
|
||||||
}
|
|
||||||
|
|
||||||
type DrawerOption func(*Drawer)
|
|
||||||
|
|
||||||
func WithSide(s Side) DrawerOption {
|
|
||||||
return func(d *Drawer) { d.Side = s }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithWidth(w int) DrawerOption {
|
|
||||||
return func(d *Drawer) { d.Width = w }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithDrawerStyle(s Styles) DrawerOption {
|
|
||||||
return func(d *Drawer) { d.Style = &s }
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDrawer(title string, content tea.Model, opts ...DrawerOption) Drawer {
|
|
||||||
d := Drawer{Title: title, Content: content}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&d)
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShowMsg struct{ Drawer Drawer }
|
|
||||||
|
|
||||||
func Show(title string, content tea.Model, opts ...DrawerOption) tea.Cmd {
|
|
||||||
d := newDrawer(title, content, opts...)
|
|
||||||
return func() tea.Msg { return ShowMsg{Drawer: d} }
|
|
||||||
}
|
|
||||||
|
|
||||||
type DismissMsg struct{}
|
|
||||||
|
|
||||||
func Close() tea.Cmd {
|
|
||||||
return func() tea.Msg { return DismissMsg{} }
|
|
||||||
}
|
|
||||||
|
|
||||||
type text string
|
|
||||||
|
|
||||||
func (t text) Init() tea.Cmd { return nil }
|
|
||||||
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
|
|
||||||
func (t text) View() tea.View { return tea.NewView(string(t)) }
|
|
||||||
|
|
||||||
func Text(s string) tea.Model { return text(s) }
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package drawer
|
|
||||||
|
|
||||||
import tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
drawers []Drawer
|
|
||||||
nextID int
|
|
||||||
maxWidth int
|
|
||||||
styles Styles
|
|
||||||
}
|
|
||||||
|
|
||||||
type Option func(*Model)
|
|
||||||
|
|
||||||
func WithMaxWidth(w int) Option {
|
|
||||||
return func(m *Model) { m.maxWidth = w }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithStyles(s Styles) Option {
|
|
||||||
return func(m *Model) { m.styles = s }
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(opts ...Option) Model {
|
|
||||||
m := Model{
|
|
||||||
maxWidth: 30,
|
|
||||||
styles: DefaultStyles(),
|
|
||||||
}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&m)
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case ShowMsg:
|
|
||||||
return m.show(msg.Drawer)
|
|
||||||
case DismissMsg:
|
|
||||||
return m.pop(), nil
|
|
||||||
}
|
|
||||||
return m.updateTop(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
|
|
||||||
i := len(m.drawers) - 1
|
|
||||||
if i < 0 || m.drawers[i].Content == nil {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.drawers[i].Content, cmd = m.drawers[i].Content.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Open() bool { return len(m.drawers) > 0 }
|
|
||||||
|
|
||||||
func (m Model) show(d Drawer) (Model, tea.Cmd) {
|
|
||||||
m.drawers = append(m.drawers, d)
|
|
||||||
return m, initContent(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
func initContent(d Drawer) tea.Cmd {
|
|
||||||
if d.Content == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return d.Content.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) pop() Model {
|
|
||||||
if len(m.drawers) == 0 {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
m.drawers = m.drawers[:len(m.drawers)-1]
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package drawer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"github.com/charmbracelet/x/ansi"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m Model) Render(background string) string {
|
|
||||||
if len(m.drawers) == 0 {
|
|
||||||
return background
|
|
||||||
}
|
|
||||||
|
|
||||||
result := background
|
|
||||||
for _, d := range m.drawers {
|
|
||||||
w, h := lipgloss.Width(result), lipgloss.Height(result)
|
|
||||||
if w <= 0 || h <= 0 {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
result = m.renderOne(d, result, w, h)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View(width, height int) string {
|
|
||||||
return m.Render(blank(width, height))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderOne(d Drawer, background string, w, h int) string {
|
|
||||||
s := m.styles
|
|
||||||
if d.Style != nil {
|
|
||||||
s = *d.Style
|
|
||||||
}
|
|
||||||
|
|
||||||
box := m.renderBox(d, s, w, h)
|
|
||||||
bw := lipgloss.Width(box)
|
|
||||||
x := 0
|
|
||||||
if d.Side == Right {
|
|
||||||
x = max(w-bw, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return style.Overlay(dim(background, s.DimColor), box, x, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dim(s string, c color.Color) string {
|
|
||||||
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderBox(d Drawer, s Styles, bgW, bgH int) string {
|
|
||||||
widthCap := m.maxWidth
|
|
||||||
if d.Width > 0 {
|
|
||||||
widthCap = d.Width
|
|
||||||
}
|
|
||||||
maxW := effectiveMax(widthCap, bgW)
|
|
||||||
|
|
||||||
body := contentView(d)
|
|
||||||
inner := contentWidth(d, body, maxW)
|
|
||||||
content := s.Content.Width(inner).Render(body)
|
|
||||||
|
|
||||||
boxWidth := inner + 4
|
|
||||||
|
|
||||||
return style.RenderWithTitle(s.Border, s.Title.Render(d.Title), content, boxWidth, bgH)
|
|
||||||
}
|
|
||||||
|
|
||||||
func contentView(d Drawer) string {
|
|
||||||
if d.Content == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return d.Content.View().Content
|
|
||||||
}
|
|
||||||
|
|
||||||
func contentWidth(d Drawer, body string, maxWidth int) int {
|
|
||||||
capped := max(maxWidth-4, 1)
|
|
||||||
if d.Width > 0 {
|
|
||||||
return capped
|
|
||||||
}
|
|
||||||
natural := max(naturalWidth(body), lipgloss.Width(d.Title), 1)
|
|
||||||
return min(natural, capped)
|
|
||||||
}
|
|
||||||
|
|
||||||
func naturalWidth(content string) int {
|
|
||||||
w := 0
|
|
||||||
for _, line := range strings.Split(content, "\n") {
|
|
||||||
if lw := lipgloss.Width(line); lw > w {
|
|
||||||
w = lw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
func effectiveMax(configured, fits int) int {
|
|
||||||
if fits < 1 {
|
|
||||||
fits = 1
|
|
||||||
}
|
|
||||||
if configured > 0 && configured < fits {
|
|
||||||
return configured
|
|
||||||
}
|
|
||||||
return fits
|
|
||||||
}
|
|
||||||
|
|
||||||
func blank(width, height int) string {
|
|
||||||
if width <= 0 || height <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
line := strings.Repeat(" ", width)
|
|
||||||
lines := make([]string, height)
|
|
||||||
for i := range lines {
|
|
||||||
lines[i] = line
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package drawer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func background(w, h int) string {
|
|
||||||
return blank(w, h)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderLeftFlushToLeftEdge(t *testing.T) {
|
|
||||||
m := New(WithMaxWidth(10))
|
|
||||||
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"), WithSide(Left))})
|
|
||||||
|
|
||||||
out := m.Render(background(40, 10))
|
|
||||||
lines := strings.Split(out, "\n")
|
|
||||||
if len(lines) != 10 {
|
|
||||||
t.Fatalf("expected 10 lines, got %d", len(lines))
|
|
||||||
}
|
|
||||||
if lipgloss.Width(out) == 0 {
|
|
||||||
t.Fatalf("expected non-empty render")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len([]rune(lines[0])) == 0 {
|
|
||||||
t.Fatalf("expected a rendered top border line")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderRightFlushToRightEdge(t *testing.T) {
|
|
||||||
m := New(WithMaxWidth(10))
|
|
||||||
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Inspector", Text("hi"), WithSide(Right))})
|
|
||||||
|
|
||||||
out := m.Render(background(40, 10))
|
|
||||||
if lipgloss.Width(out) != 40 {
|
|
||||||
t.Fatalf("expected full background width 40, got %d", lipgloss.Width(out))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSpansFullBackgroundHeight(t *testing.T) {
|
|
||||||
m := New()
|
|
||||||
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"))})
|
|
||||||
|
|
||||||
out := m.Render(background(40, 12))
|
|
||||||
if lipgloss.Height(out) != 12 {
|
|
||||||
t.Fatalf("expected full height 12, got %d", lipgloss.Height(out))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixedWidthHonored(t *testing.T) {
|
|
||||||
m := New(WithMaxWidth(50))
|
|
||||||
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("x"), WithWidth(20))})
|
|
||||||
|
|
||||||
box := m.renderBox(m.drawers[0], m.styles, 80, 10)
|
|
||||||
if got := lipgloss.Width(box); got != 20 {
|
|
||||||
t.Fatalf("expected fixed box width 20, got %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoDrawersReturnsBackgroundUnchanged(t *testing.T) {
|
|
||||||
m := New()
|
|
||||||
bg := background(10, 5)
|
|
||||||
if got := m.Render(bg); got != bg {
|
|
||||||
t.Fatalf("expected background unchanged when no drawer is open")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package drawer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Styles struct {
|
|
||||||
Border lipgloss.Style
|
|
||||||
Title lipgloss.Style
|
|
||||||
Content lipgloss.Style
|
|
||||||
DimColor color.Color
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultStyles() Styles {
|
|
||||||
return Styles{
|
|
||||||
Border: style.S.PanelFocused.Padding(0, 1),
|
|
||||||
Title: lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary),
|
|
||||||
Content: lipgloss.NewStyle().Foreground(style.S.Text),
|
|
||||||
DimColor: style.S.Subtle,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/spinner"
|
|
||||||
"charm.land/bubbles/v2/textinput"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/bubbles"
|
|
||||||
)
|
|
||||||
|
|
||||||
type model struct {
|
|
||||||
input textinput.Model
|
|
||||||
spinner spinner.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
func newModel() model {
|
|
||||||
ti := bubbles.NewTextInput()
|
|
||||||
ti.Placeholder = "type something"
|
|
||||||
ti.Focus()
|
|
||||||
return model{input: ti, spinner: bubbles.NewSpinner()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return m.spinner.Tick }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "ctrl+c" {
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
var inputCmd, spinnerCmd tea.Cmd
|
|
||||||
m.input, inputCmd = m.input.Update(msg)
|
|
||||||
m.spinner, spinnerCmd = m.spinner.Update(msg)
|
|
||||||
return m, tea.Batch(inputCmd, spinnerCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
return tea.NewView(lipgloss.JoinHorizontal(lipgloss.Center, m.spinner.View(), " ", m.input.View()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/drawer"
|
|
||||||
)
|
|
||||||
|
|
||||||
type model struct {
|
|
||||||
d drawer.Model
|
|
||||||
width, height int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newModel() model { return model{d: drawer.New()} }
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return m.d.Init() }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.width, m.height = msg.Width, msg.Height
|
|
||||||
case tea.KeyPressMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
case "l":
|
|
||||||
return m, drawer.Show("Nav", drawer.Text("Home\nSettings"), drawer.WithSide(drawer.Left))
|
|
||||||
case "esc":
|
|
||||||
if m.d.Open() {
|
|
||||||
return m, drawer.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.d, cmd = m.d.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "l: open q: quit")
|
|
||||||
view := tea.NewView(m.d.Render(background))
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/helpbar"
|
|
||||||
)
|
|
||||||
|
|
||||||
type keyMap struct {
|
|
||||||
Inc, Help, Quit key.Binding
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultKeyMap() keyMap {
|
|
||||||
return keyMap{
|
|
||||||
Inc: key.NewBinding(key.WithKeys("+"), key.WithHelp("+", "increment")),
|
|
||||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
|
||||||
Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type model struct {
|
|
||||||
help helpbar.Model
|
|
||||||
keys keyMap
|
|
||||||
n, w int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newModel() model {
|
|
||||||
keys := defaultKeyMap()
|
|
||||||
return model{
|
|
||||||
keys: keys,
|
|
||||||
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.Quit)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.w = msg.Width
|
|
||||||
m.help.SetWidth(m.w)
|
|
||||||
case tea.KeyPressMsg:
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, m.keys.Quit):
|
|
||||||
return m, tea.Quit
|
|
||||||
case key.Matches(msg, m.keys.Inc):
|
|
||||||
m.n++
|
|
||||||
default:
|
|
||||||
m.help, _ = m.help.Update(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
bar := m.help.View(m.keys.Inc)
|
|
||||||
body := lipgloss.Place(m.w, 1, lipgloss.Center, lipgloss.Top, fmt.Sprintf("count: %d", m.n))
|
|
||||||
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, bar))
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/modal"
|
|
||||||
)
|
|
||||||
|
|
||||||
type model struct {
|
|
||||||
m modal.Model
|
|
||||||
width, height int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newModel() model { return model{m: modal.New()} }
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return m.m.Init() }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.width, m.height = msg.Width, msg.Height
|
|
||||||
case tea.KeyPressMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
case "o":
|
|
||||||
return m, modal.Show("Hello", modal.Text("This is a modal.\n\nesc: close"))
|
|
||||||
case "esc":
|
|
||||||
if m.m.Open() {
|
|
||||||
return m, modal.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.m, cmd = m.m.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "o: open q: quit")
|
|
||||||
view := tea.NewView(m.m.Render(background))
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/notification"
|
|
||||||
)
|
|
||||||
|
|
||||||
type model struct {
|
|
||||||
notif notification.Model
|
|
||||||
width, height int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newModel() model { return model{notif: notification.New()} }
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return m.notif.Init() }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.width, m.height = msg.Width, msg.Height
|
|
||||||
case tea.KeyPressMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
case "s":
|
|
||||||
return m, notification.Show("Saved", "Config written to disk.", notification.Success)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.notif, cmd = m.notif.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, "s: show q: quit")
|
|
||||||
view := tea.NewView(m.notif.Render(background))
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
type model struct{ focused bool }
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyPressMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
case "tab":
|
|
||||||
m.focused = !m.focused
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
box := style.S.Panel
|
|
||||||
if m.focused {
|
|
||||||
box = style.S.PanelFocused
|
|
||||||
}
|
|
||||||
return tea.NewView(style.RenderWithTitle(box, "Panel", "tab: toggle focus q: quit", 30, 5))
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if _, err := tea.NewProgram(model{}).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/tabs"
|
|
||||||
)
|
|
||||||
|
|
||||||
type pane string
|
|
||||||
|
|
||||||
func (p pane) Init() tea.Cmd { return nil }
|
|
||||||
func (p pane) Update(tea.Msg) (tabs.Tab, tea.Cmd) { return p, nil }
|
|
||||||
func (p pane) View() string { return string(p) }
|
|
||||||
|
|
||||||
type model struct{ tabs tabs.Model }
|
|
||||||
|
|
||||||
func newModel() model {
|
|
||||||
items := []tabs.Item{
|
|
||||||
{Title: "First", Model: pane("first content")},
|
|
||||||
{Title: "Second", Model: pane("second content")},
|
|
||||||
{Title: "Third", Model: pane("third content")},
|
|
||||||
}
|
|
||||||
return model{tabs: tabs.New(items)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return m.tabs.Init() }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "q" {
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
if s, ok := msg.(tea.WindowSizeMsg); ok {
|
|
||||||
m.tabs.SetSize(s.Width, s.Height)
|
|
||||||
msg = tea.WindowSizeMsg{Width: m.tabs.ContentWidth(), Height: m.tabs.ContentHeight()}
|
|
||||||
}
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.tabs, cmd = m.tabs.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
view := tea.NewView(m.tabs.View())
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-87
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
description = "A shared Base16 theme, a themed wrapper around every official component, and a small set of custom Bubble Tea components, in one Go module, so every TUI built with it shares one config file and looks consistent.";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
||||||
git-hooks = {
|
|
||||||
url = "github:cachix/git-hooks.nix";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = {
|
|
||||||
self,
|
|
||||||
nixpkgs,
|
|
||||||
git-hooks,
|
|
||||||
}: let
|
|
||||||
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
|
||||||
|
|
||||||
forAllSystems = f:
|
|
||||||
nixpkgs.lib.genAttrs supportedSystems
|
|
||||||
(system: f system (import nixpkgs {inherit system;}));
|
|
||||||
in {
|
|
||||||
devShells = forAllSystems (system: pkgs: {
|
|
||||||
default = import ./nix/shell.nix {
|
|
||||||
inherit pkgs;
|
|
||||||
gitHooksLib = git-hooks.lib.${system};
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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=
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# helpbar
|
|
||||||
|
|
||||||
A responsive help bar: one line of key bindings that expands, on toggle, into a multi-column view
|
|
||||||
reflowed to use as many columns as the available width allows.
|
|
||||||
|
|
||||||
- Bindings render in order: the `WithToggle` binding first, then `WithGlobal` bindings (set once),
|
|
||||||
then contextual bindings passed to `View` at render time.
|
|
||||||
- Disabled bindings (`key.Binding.SetEnabled(false)`) are dropped before layout, so the reflow never
|
|
||||||
budgets width for something that won't be drawn.
|
|
||||||
- `Height(contextual...)` always matches `lipgloss.Height` of `View` for the same arguments, so a
|
|
||||||
host can reserve exactly the right amount of space. An empty bar renders `""` and takes 0 rows.
|
|
||||||
|
|
||||||
See `examples/helpbar`.
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package helpbar
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/help"
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"github.com/charmbracelet/x/ansi"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/bubbles"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
ShowAll bool
|
|
||||||
|
|
||||||
help help.Model
|
|
||||||
global []key.Binding
|
|
||||||
toggle key.Binding
|
|
||||||
width int
|
|
||||||
}
|
|
||||||
|
|
||||||
type Option func(*Model)
|
|
||||||
|
|
||||||
func WithGlobal(bindings ...key.Binding) Option {
|
|
||||||
return func(m *Model) { m.global = bindings }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithToggle(b key.Binding) Option {
|
|
||||||
return func(m *Model) { m.toggle = b }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithStyles(s help.Styles) Option {
|
|
||||||
return func(m *Model) { m.help.Styles = s }
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(opts ...Option) Model {
|
|
||||||
m := Model{help: bubbles.NewHelp()}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&m)
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) SetWidth(w int) {
|
|
||||||
m.width = w
|
|
||||||
m.help.SetWidth(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Width() int { return m.width }
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|
||||||
if keyMsg, ok := msg.(tea.KeyPressMsg); ok && key.Matches(keyMsg, m.toggle) {
|
|
||||||
m.ShowAll = !m.ShowAll
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View(contextual ...key.Binding) string {
|
|
||||||
bindings := m.bindings(contextual)
|
|
||||||
if len(bindings) == 0 || m.width <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var view string
|
|
||||||
if !m.ShowAll {
|
|
||||||
view = m.help.ShortHelpView(bindings)
|
|
||||||
} else {
|
|
||||||
view = m.help.FullHelpView(m.columns(bindings))
|
|
||||||
}
|
|
||||||
return m.clampWidth(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) clampWidth(view string) string {
|
|
||||||
lines := strings.Split(view, "\n")
|
|
||||||
for i, line := range lines {
|
|
||||||
lines[i] = ansi.Truncate(line, m.width, "")
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Height(contextual ...key.Binding) int {
|
|
||||||
view := m.View(contextual...)
|
|
||||||
if view == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return lipgloss.Height(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) bindings(contextual []key.Binding) []key.Binding {
|
|
||||||
all := make([]key.Binding, 0, 1+len(m.global)+len(contextual))
|
|
||||||
if m.toggle.Enabled() {
|
|
||||||
all = append(all, m.toggle)
|
|
||||||
}
|
|
||||||
for _, b := range append(append([]key.Binding{}, m.global...), contextual...) {
|
|
||||||
if b.Enabled() {
|
|
||||||
all = append(all, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return all
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) columns(bindings []key.Binding) [][]key.Binding {
|
|
||||||
if m.width <= 0 {
|
|
||||||
return [][]key.Binding{bindings}
|
|
||||||
}
|
|
||||||
for rows := 1; rows < len(bindings); rows++ {
|
|
||||||
groups := chunkColumns(bindings, rows)
|
|
||||||
if m.renderedWidth(groups) <= m.width {
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunkColumns(bindings, len(bindings))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderedWidth(groups [][]key.Binding) int {
|
|
||||||
unbounded := m.help
|
|
||||||
unbounded.SetWidth(0)
|
|
||||||
return lipgloss.Width(unbounded.FullHelpView(groups))
|
|
||||||
}
|
|
||||||
|
|
||||||
func chunkColumns(bindings []key.Binding, rows int) [][]key.Binding {
|
|
||||||
if rows < 1 {
|
|
||||||
rows = 1
|
|
||||||
}
|
|
||||||
groups := make([][]key.Binding, 0, (len(bindings)+rows-1)/rows)
|
|
||||||
for i := 0; i < len(bindings); i += rows {
|
|
||||||
groups = append(groups, bindings[i:min(i+rows, len(bindings))])
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package ilovetui
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package minsize
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
MinWidth int
|
|
||||||
MinHeight int
|
|
||||||
Style lipgloss.Style
|
|
||||||
}
|
|
||||||
|
|
||||||
type Option func(*Model)
|
|
||||||
|
|
||||||
func WithStyle(s lipgloss.Style) Option {
|
|
||||||
return func(m *Model) { m.Style = s }
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(minWidth, minHeight int, opts ...Option) Model {
|
|
||||||
m := Model{
|
|
||||||
MinWidth: minWidth,
|
|
||||||
MinHeight: minHeight,
|
|
||||||
Style: lipgloss.NewStyle().Foreground(style.S.Muted),
|
|
||||||
}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&m)
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Fits(width, height int) bool {
|
|
||||||
return width >= m.MinWidth && height >= m.MinHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View(width, height int) string {
|
|
||||||
msg := m.Style.Render(m.message(width, height))
|
|
||||||
if width < 1 || height < 1 {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) message(width, height int) string {
|
|
||||||
tooNarrow := width < m.MinWidth
|
|
||||||
tooShort := height < m.MinHeight
|
|
||||||
switch {
|
|
||||||
case tooNarrow && tooShort:
|
|
||||||
return fmt.Sprintf("minimum is %dx%d", m.MinWidth, m.MinHeight)
|
|
||||||
case tooNarrow:
|
|
||||||
return fmt.Sprintf("minimum width: %d", m.MinWidth)
|
|
||||||
case tooShort:
|
|
||||||
return fmt.Sprintf("minimum height: %d", m.MinHeight)
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# modal
|
|
||||||
|
|
||||||
A centered popup box on top of a dimmed background, triggered from anywhere in a bubbletea program
|
|
||||||
via an exported `tea.Msg` (`ShowMsg`/`Show`), not a direct reference to the `Model` that renders it.
|
|
||||||
Composites over an already-rendered string, so it makes no assumption about how the host builds
|
|
||||||
that string.
|
|
||||||
|
|
||||||
- A modal's content is a `tea.Model`, not a string: it gets `Init`/`Update` while it's on top, and
|
|
||||||
can report back with its own `tea.Msg` (see `modal.Text` for a plain, non-interactive content).
|
|
||||||
- The stack is a plain LIFO with no identity. Only the topmost modal is updated; `Close()` always
|
|
||||||
closes it.
|
|
||||||
- Showing a second modal while one is open pushes it on top, nesting confirmations naturally.
|
|
||||||
- `WithMaxWidth`/`WithMaxHeight` cap growth; a modal narrower than the cap shrinks to fit instead.
|
|
||||||
|
|
||||||
See `examples/modal`.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package modal
|
|
||||||
|
|
||||||
import tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
type Modal struct {
|
|
||||||
Title string
|
|
||||||
|
|
||||||
Content tea.Model
|
|
||||||
|
|
||||||
Style *Styles
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModalOption func(*Modal)
|
|
||||||
|
|
||||||
func WithModalStyle(s Styles) ModalOption {
|
|
||||||
return func(mo *Modal) { mo.Style = &s }
|
|
||||||
}
|
|
||||||
|
|
||||||
func newModal(title string, content tea.Model, opts ...ModalOption) Modal {
|
|
||||||
mo := Modal{Title: title, Content: content}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&mo)
|
|
||||||
}
|
|
||||||
return mo
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShowMsg struct{ Modal Modal }
|
|
||||||
|
|
||||||
func Show(title string, content tea.Model, opts ...ModalOption) tea.Cmd {
|
|
||||||
mo := newModal(title, content, opts...)
|
|
||||||
return func() tea.Msg { return ShowMsg{Modal: mo} }
|
|
||||||
}
|
|
||||||
|
|
||||||
type DismissMsg struct{}
|
|
||||||
|
|
||||||
func Close() tea.Cmd {
|
|
||||||
return func() tea.Msg { return DismissMsg{} }
|
|
||||||
}
|
|
||||||
|
|
||||||
type text string
|
|
||||||
|
|
||||||
func (t text) Init() tea.Cmd { return nil }
|
|
||||||
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
|
|
||||||
func (t text) View() tea.View { return tea.NewView(string(t)) }
|
|
||||||
|
|
||||||
func Text(s string) tea.Model { return text(s) }
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package modal
|
|
||||||
|
|
||||||
import tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
modals []Modal
|
|
||||||
maxWidth int
|
|
||||||
maxHeight int
|
|
||||||
styles Styles
|
|
||||||
}
|
|
||||||
|
|
||||||
type Option func(*Model)
|
|
||||||
|
|
||||||
func WithMaxWidth(w int) Option {
|
|
||||||
return func(m *Model) { m.maxWidth = w }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithMaxHeight(h int) Option {
|
|
||||||
return func(m *Model) { m.maxHeight = h }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithStyles(s Styles) Option {
|
|
||||||
return func(m *Model) { m.styles = s }
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(opts ...Option) Model {
|
|
||||||
m := Model{
|
|
||||||
maxWidth: 60,
|
|
||||||
maxHeight: 20,
|
|
||||||
styles: DefaultStyles(),
|
|
||||||
}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&m)
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case ShowMsg:
|
|
||||||
return m.show(msg.Modal)
|
|
||||||
case DismissMsg:
|
|
||||||
return m.pop(), nil
|
|
||||||
}
|
|
||||||
return m.updateTop(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
|
|
||||||
i := len(m.modals) - 1
|
|
||||||
if i < 0 || m.modals[i].Content == nil {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.modals[i].Content, cmd = m.modals[i].Content.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Open() bool { return len(m.modals) > 0 }
|
|
||||||
|
|
||||||
func (m Model) show(mo Modal) (Model, tea.Cmd) {
|
|
||||||
m.modals = append(m.modals, mo)
|
|
||||||
return m, initContent(mo)
|
|
||||||
}
|
|
||||||
|
|
||||||
func initContent(mo Modal) tea.Cmd {
|
|
||||||
if mo.Content == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return mo.Content.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) pop() Model {
|
|
||||||
if len(m.modals) == 0 {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
m.modals = m.modals[:len(m.modals)-1]
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
-109
@@ -1,109 +0,0 @@
|
|||||||
package modal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"github.com/charmbracelet/x/ansi"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
const margin = 2
|
|
||||||
|
|
||||||
func (m Model) Render(background string) string {
|
|
||||||
if len(m.modals) == 0 {
|
|
||||||
return background
|
|
||||||
}
|
|
||||||
|
|
||||||
result := background
|
|
||||||
for _, mo := range m.modals {
|
|
||||||
w, h := lipgloss.Width(result), lipgloss.Height(result)
|
|
||||||
if w <= 0 || h <= 0 {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
result = m.renderOne(mo, result, w, h)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View(width, height int) string {
|
|
||||||
return m.Render(blank(width, height))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderOne(mo Modal, background string, w, h int) string {
|
|
||||||
s := m.styles
|
|
||||||
if mo.Style != nil {
|
|
||||||
s = *mo.Style
|
|
||||||
}
|
|
||||||
|
|
||||||
box := m.renderBox(mo, s, w, h)
|
|
||||||
bw, bh := lipgloss.Width(box), lipgloss.Height(box)
|
|
||||||
x, y := max((w-bw)/2, 0), max((h-bh)/2, 0)
|
|
||||||
|
|
||||||
return style.Overlay(dim(background, s.DimColor), box, x, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dim(s string, c color.Color) string {
|
|
||||||
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderBox(mo Modal, s Styles, bgW, bgH int) string {
|
|
||||||
maxW := effectiveMax(m.maxWidth, bgW-2*margin)
|
|
||||||
maxH := effectiveMax(m.maxHeight, bgH-2*margin)
|
|
||||||
|
|
||||||
body := contentView(mo)
|
|
||||||
inner := contentWidth(body, mo.Title, maxW)
|
|
||||||
content := s.Content.Width(inner).Render(body)
|
|
||||||
|
|
||||||
boxWidth := inner + 4
|
|
||||||
boxHeight := min(lipgloss.Height(content)+2, maxH)
|
|
||||||
|
|
||||||
return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
func contentView(mo Modal) string {
|
|
||||||
if mo.Content == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return mo.Content.View().Content
|
|
||||||
}
|
|
||||||
|
|
||||||
func contentWidth(body, title string, maxWidth int) int {
|
|
||||||
natural := max(naturalWidth(body), lipgloss.Width(title), 1)
|
|
||||||
capped := max(maxWidth-4, 1)
|
|
||||||
return min(natural, capped)
|
|
||||||
}
|
|
||||||
|
|
||||||
func naturalWidth(content string) int {
|
|
||||||
w := 0
|
|
||||||
for _, line := range strings.Split(content, "\n") {
|
|
||||||
if lw := lipgloss.Width(line); lw > w {
|
|
||||||
w = lw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
func effectiveMax(configured, fits int) int {
|
|
||||||
if fits < 1 {
|
|
||||||
fits = 1
|
|
||||||
}
|
|
||||||
if configured > 0 && configured < fits {
|
|
||||||
return configured
|
|
||||||
}
|
|
||||||
return fits
|
|
||||||
}
|
|
||||||
|
|
||||||
func blank(width, height int) string {
|
|
||||||
if width <= 0 || height <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
line := strings.Repeat(" ", width)
|
|
||||||
lines := make([]string, height)
|
|
||||||
for i := range lines {
|
|
||||||
lines[i] = line
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package modal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Styles struct {
|
|
||||||
Border lipgloss.Style
|
|
||||||
Title lipgloss.Style
|
|
||||||
Content lipgloss.Style
|
|
||||||
DimColor color.Color
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultStyles() Styles {
|
|
||||||
return Styles{
|
|
||||||
Border: style.S.PanelFocused.Padding(0, 1),
|
|
||||||
Title: lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary),
|
|
||||||
Content: lipgloss.NewStyle().Foreground(style.S.Text),
|
|
||||||
DimColor: style.S.Subtle,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
pkgs,
|
|
||||||
gitHooksLib,
|
|
||||||
}: let
|
|
||||||
hooks = gitHooksLib.run {
|
|
||||||
src = ../.;
|
|
||||||
hooks = {
|
|
||||||
gofmt.enable = true;
|
|
||||||
govet.enable = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in
|
|
||||||
pkgs.mkShell {
|
|
||||||
packages = with pkgs;
|
|
||||||
[
|
|
||||||
go
|
|
||||||
doctoc
|
|
||||||
(python3.withPackages (ps: [ps.pyte]))
|
|
||||||
]
|
|
||||||
++ hooks.enabledPackages;
|
|
||||||
|
|
||||||
shellHook = hooks.shellHook;
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Run a TUI binary in a virtual terminal (via pyte) and print its rendered
|
|
||||||
screen as plain text, for visual verification of bubbletea/lipgloss output.
|
|
||||||
|
|
||||||
Dev tooling only, provided by the nix-shell. Not part of the Go module.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
tui-snapshot.py [--width W] [--height H] [--wait SECONDS] [--keys 'k:delay,...'] -- <cmd> [args...]
|
|
||||||
|
|
||||||
Example:
|
|
||||||
tui-snapshot.py --keys 'l:0.2,l:0.2,q:0' -- go run ./examples/tabs
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import fcntl
|
|
||||||
import os
|
|
||||||
import pty
|
|
||||||
import select
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import termios
|
|
||||||
import time
|
|
||||||
|
|
||||||
import pyte
|
|
||||||
|
|
||||||
class RepeatAwareScreen(pyte.Screen):
|
|
||||||
"""pyte has no support for the ECMA-48 REP sequence (``CSI Ps b``,
|
|
||||||
"repeat the preceding graphic character Ps times"), which bubbletea's
|
|
||||||
renderer uses to compress runs of identical styled cells (e.g. long
|
|
||||||
border/padding runs). Left unhandled, pyte silently drops it, corrupting
|
|
||||||
the frame. This adds the missing handler."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self._last_drawn = " "
|
|
||||||
|
|
||||||
def draw(self, data):
|
|
||||||
super().draw(data)
|
|
||||||
if data:
|
|
||||||
self._last_drawn = data[-1]
|
|
||||||
|
|
||||||
def repeat_last_character(self, count=1):
|
|
||||||
self.draw(self._last_drawn * (count or 1))
|
|
||||||
|
|
||||||
|
|
||||||
class RepeatAwareStream(pyte.Stream):
|
|
||||||
"""Also works around a pyte bug: it maps ECMA-48 HPA (Character Position
|
|
||||||
Absolute, "move cursor to column Ps") to "'" (apostrophe, 0x27) instead
|
|
||||||
of the actual standard character "`" (backtick, 0x60) - see pyte's
|
|
||||||
escape.py. Real terminal apps send the correct backtick, which pyte then
|
|
||||||
silently no-ops on since it's not in its dispatch table, leaving the
|
|
||||||
cursor stuck instead of moving it. bubbletea's renderer uses HPA (mixed
|
|
||||||
with CUF) to jump the cursor to a border's column after erasing padding
|
|
||||||
with ECH, so without this the trailing border character gets drawn right
|
|
||||||
after the leading one instead of at the far edge."""
|
|
||||||
|
|
||||||
csi = {
|
|
||||||
**pyte.Stream.csi,
|
|
||||||
"b": "repeat_last_character",
|
|
||||||
"`": "cursor_to_column",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
KEYMAP = {
|
|
||||||
"enter": "\r",
|
|
||||||
"esc": "\x1b",
|
|
||||||
"tab": "\t",
|
|
||||||
"up": "\x1b[A",
|
|
||||||
"down": "\x1b[B",
|
|
||||||
"right": "\x1b[C",
|
|
||||||
"left": "\x1b[D",
|
|
||||||
"space": " ",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def encode_key(key):
|
|
||||||
"""Resolve a --keys token to the bytes to write to the pty. Falls back to
|
|
||||||
the literal string for a plain character (e.g. 'l'), but without this,
|
|
||||||
something like 'ctrl+l' would be sent as the 6 literal characters
|
|
||||||
'c','t','r','l','+','l' instead of the single 0x0C control byte a real
|
|
||||||
terminal would send for Ctrl-L - silently testing the wrong thing."""
|
|
||||||
if key in KEYMAP:
|
|
||||||
return KEYMAP[key]
|
|
||||||
if key.startswith("ctrl+") and len(key) == 6 and key[5].isalpha():
|
|
||||||
return chr(ord(key[5].lower()) - ord("a") + 1)
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
def set_size(fd, rows, cols):
|
|
||||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--width", type=int, default=80)
|
|
||||||
parser.add_argument("--height", type=int, default=24)
|
|
||||||
parser.add_argument(
|
|
||||||
"--wait", type=float, default=1.0, help="seconds to let the app render before the snapshot"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--keys",
|
|
||||||
default="",
|
|
||||||
help="comma-separated key:delay pairs sent before the final snapshot, e.g. 'l:0.2,l:0.2,q:0'",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--colors",
|
|
||||||
action="store_true",
|
|
||||||
help="also print, per row, the foreground color of each non-blank cell (for border/color bugs "
|
|
||||||
"that don't show up in the plain-text dump)",
|
|
||||||
)
|
|
||||||
parser.add_argument("cmd", nargs=argparse.REMAINDER)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
cmd = args.cmd
|
|
||||||
if cmd and cmd[0] == "--":
|
|
||||||
cmd = cmd[1:]
|
|
||||||
if not cmd:
|
|
||||||
parser.error("missing command to run")
|
|
||||||
|
|
||||||
screen = RepeatAwareScreen(args.width, args.height)
|
|
||||||
stream = RepeatAwareStream(screen)
|
|
||||||
|
|
||||||
# Open the pty and set its size *before* forking, so the child never
|
|
||||||
# observes a stale/default size on its first render (pyte has no way to
|
|
||||||
# recover from a corrupted initial frame drawn at the wrong width).
|
|
||||||
master_fd, slave_fd = pty.openpty()
|
|
||||||
set_size(slave_fd, args.height, args.width)
|
|
||||||
|
|
||||||
pid = os.fork()
|
|
||||||
if pid == 0:
|
|
||||||
os.close(master_fd)
|
|
||||||
os.setsid()
|
|
||||||
fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
|
|
||||||
os.dup2(slave_fd, 0)
|
|
||||||
os.dup2(slave_fd, 1)
|
|
||||||
os.dup2(slave_fd, 2)
|
|
||||||
os.close(slave_fd)
|
|
||||||
os.execvp(cmd[0], cmd)
|
|
||||||
os._exit(1)
|
|
||||||
|
|
||||||
os.close(slave_fd)
|
|
||||||
fd = master_fd
|
|
||||||
|
|
||||||
def pump(duration):
|
|
||||||
end = time.time() + duration
|
|
||||||
while True:
|
|
||||||
remaining = end - time.time()
|
|
||||||
if remaining <= 0:
|
|
||||||
break
|
|
||||||
r, _, _ = select.select([fd], [], [], remaining)
|
|
||||||
if fd not in r:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
data = os.read(fd, 65536)
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
stream.feed(data.decode(errors="ignore"))
|
|
||||||
|
|
||||||
pump(args.wait)
|
|
||||||
|
|
||||||
for pair in filter(None, args.keys.split(",")):
|
|
||||||
key, _, delay = pair.partition(":")
|
|
||||||
try:
|
|
||||||
os.write(fd, encode_key(key).encode())
|
|
||||||
except OSError:
|
|
||||||
break
|
|
||||||
pump(float(delay) if delay else 0.3)
|
|
||||||
|
|
||||||
try:
|
|
||||||
os.kill(pid, 15)
|
|
||||||
os.waitpid(pid, 0)
|
|
||||||
except (ProcessLookupError, ChildProcessError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
for y, line in enumerate(screen.display):
|
|
||||||
print(line.rstrip())
|
|
||||||
if args.colors:
|
|
||||||
row = screen.buffer[y]
|
|
||||||
cells = []
|
|
||||||
for x in sorted(row):
|
|
||||||
ch = row[x]
|
|
||||||
if ch.data.strip():
|
|
||||||
fg = ch.fg if ch.fg != "default" else "-"
|
|
||||||
cells.append(f"{x}:{ch.data!r}:{fg}")
|
|
||||||
if cells:
|
|
||||||
print(" " + " ".join(cells))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# notification
|
|
||||||
|
|
||||||
Toast notifications, triggered from anywhere in a bubbletea program via an exported `tea.Msg`
|
|
||||||
(`ShowMsg`/`Show`), not a direct reference to the `Model` that renders them. Composites over an
|
|
||||||
already-rendered string, so it makes no assumption about how the host builds that string.
|
|
||||||
|
|
||||||
- Four kinds: `Info`, `Success`, `Warning`, `Error`, each with its own `style.S` color preset.
|
|
||||||
- Auto-dismisses after `DefaultDuration` (3s) unless shown with `WithDuration(0)`, which makes it
|
|
||||||
sticky; a sticky toast needs `WithID` so `Dismiss(id)` can remove it later.
|
|
||||||
- Six anchors (`Top`, `TopLeft`, `TopRight`, `Bottom`, `BottomLeft`, `BottomRight`). Toasts stack
|
|
||||||
along the anchored edge, newest closest to it.
|
|
||||||
- `WithMaxWidth` caps growth; a toast narrower than the cap shrinks to fit instead.
|
|
||||||
|
|
||||||
See `examples/notification`.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package notification
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
toasts []Toast
|
|
||||||
nextID int
|
|
||||||
position Position
|
|
||||||
maxWidth int
|
|
||||||
styles Styles
|
|
||||||
}
|
|
||||||
|
|
||||||
type Option func(*Model)
|
|
||||||
|
|
||||||
func WithPosition(p Position) Option {
|
|
||||||
return func(m *Model) { m.position = p }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithMaxWidth(w int) Option {
|
|
||||||
return func(m *Model) { m.maxWidth = w }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithStyles(s Styles) Option {
|
|
||||||
return func(m *Model) { m.styles = s }
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(opts ...Option) Model {
|
|
||||||
m := Model{
|
|
||||||
position: TopRight,
|
|
||||||
maxWidth: 40,
|
|
||||||
styles: DefaultStyles(),
|
|
||||||
}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&m)
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case ShowMsg:
|
|
||||||
return m.show(msg.Toast)
|
|
||||||
case DismissMsg:
|
|
||||||
return m.remove(msg.ID), nil
|
|
||||||
case expireMsg:
|
|
||||||
return m.remove(msg.id), nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) show(t Toast) (Model, tea.Cmd) {
|
|
||||||
if t.ID == "" {
|
|
||||||
t.ID = fmt.Sprintf("toast-%d", m.nextID)
|
|
||||||
m.nextID++
|
|
||||||
}
|
|
||||||
|
|
||||||
replaced := false
|
|
||||||
for i, existing := range m.toasts {
|
|
||||||
if existing.ID == t.ID {
|
|
||||||
m.toasts[i] = t
|
|
||||||
replaced = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !replaced {
|
|
||||||
m.toasts = append(m.toasts, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.Duration <= 0 {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
id := t.ID
|
|
||||||
return m, tea.Tick(t.Duration, func(time.Time) tea.Msg {
|
|
||||||
return expireMsg{id: id}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) remove(id string) Model {
|
|
||||||
for i, t := range m.toasts {
|
|
||||||
if t.ID == id {
|
|
||||||
m.toasts = append(m.toasts[:i], m.toasts[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package notification
|
|
||||||
|
|
||||||
type Position int
|
|
||||||
|
|
||||||
const (
|
|
||||||
Top Position = iota
|
|
||||||
TopLeft
|
|
||||||
TopRight
|
|
||||||
Bottom
|
|
||||||
BottomLeft
|
|
||||||
BottomRight
|
|
||||||
)
|
|
||||||
|
|
||||||
const margin = 1
|
|
||||||
|
|
||||||
func placement(pos Position, w, h, sw, sh int) (x, y int) {
|
|
||||||
switch pos {
|
|
||||||
case Top:
|
|
||||||
x = (w - sw) / 2
|
|
||||||
y = margin
|
|
||||||
case TopLeft:
|
|
||||||
x = margin
|
|
||||||
y = margin
|
|
||||||
case TopRight:
|
|
||||||
x = w - sw - margin
|
|
||||||
y = margin
|
|
||||||
case Bottom:
|
|
||||||
x = (w - sw) / 2
|
|
||||||
y = h - sh - margin
|
|
||||||
case BottomLeft:
|
|
||||||
x = margin
|
|
||||||
y = h - sh - margin
|
|
||||||
case BottomRight:
|
|
||||||
x = w - sw - margin
|
|
||||||
y = h - sh - margin
|
|
||||||
}
|
|
||||||
if x < 0 {
|
|
||||||
x = 0
|
|
||||||
}
|
|
||||||
if y < 0 {
|
|
||||||
y = 0
|
|
||||||
}
|
|
||||||
return x, y
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pos Position) anchoredTop() bool {
|
|
||||||
return pos == Top || pos == TopLeft || pos == TopRight
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package notification
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m Model) Render(background string) string {
|
|
||||||
if len(m.toasts) == 0 {
|
|
||||||
return background
|
|
||||||
}
|
|
||||||
w, h := lipgloss.Width(background), lipgloss.Height(background)
|
|
||||||
if w <= 0 || h <= 0 {
|
|
||||||
return background
|
|
||||||
}
|
|
||||||
|
|
||||||
stack := clipToHeight(m.renderStack(effectiveMaxWidth(m.maxWidth, w)), h-2*margin, m.position.anchoredTop())
|
|
||||||
if stack == "" {
|
|
||||||
return background
|
|
||||||
}
|
|
||||||
sw, sh := lipgloss.Width(stack), lipgloss.Height(stack)
|
|
||||||
x, y := placement(m.position, w, h, sw, sh)
|
|
||||||
|
|
||||||
return style.Overlay(background, stack, x, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View(width, height int) string {
|
|
||||||
return m.Render(blank(width, height))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderStack(maxWidth int) string {
|
|
||||||
ordered := m.orderedToasts()
|
|
||||||
parts := make([]string, 0, len(ordered)*2-1)
|
|
||||||
for i, t := range ordered {
|
|
||||||
if i > 0 {
|
|
||||||
parts = append(parts, "")
|
|
||||||
}
|
|
||||||
parts = append(parts, m.renderToast(t, maxWidth))
|
|
||||||
}
|
|
||||||
return lipgloss.JoinVertical(stackAlign(m.position), parts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) orderedToasts() []Toast {
|
|
||||||
if !m.position.anchoredTop() {
|
|
||||||
return m.toasts
|
|
||||||
}
|
|
||||||
ordered := make([]Toast, len(m.toasts))
|
|
||||||
for i, t := range m.toasts {
|
|
||||||
ordered[len(m.toasts)-1-i] = t
|
|
||||||
}
|
|
||||||
return ordered
|
|
||||||
}
|
|
||||||
|
|
||||||
func stackAlign(pos Position) lipgloss.Position {
|
|
||||||
switch pos {
|
|
||||||
case TopLeft, BottomLeft:
|
|
||||||
return lipgloss.Left
|
|
||||||
case TopRight, BottomRight:
|
|
||||||
return lipgloss.Right
|
|
||||||
default:
|
|
||||||
return lipgloss.Center
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderToast(t Toast, maxWidth int) string {
|
|
||||||
k := m.styles.forKind(t)
|
|
||||||
|
|
||||||
inner := contentWidth(t, maxWidth)
|
|
||||||
message := k.Message.Width(inner).Render(t.Message)
|
|
||||||
|
|
||||||
boxWidth := inner + 4
|
|
||||||
boxHeight := lipgloss.Height(message) + 2
|
|
||||||
|
|
||||||
return style.RenderWithTitle(k.Border, k.Title.Render(t.Title), message, boxWidth, boxHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
func contentWidth(t Toast, maxWidth int) int {
|
|
||||||
natural := max(lipgloss.Width(t.Title), lipgloss.Width(t.Message), 1)
|
|
||||||
if maxWidth <= 0 {
|
|
||||||
return natural
|
|
||||||
}
|
|
||||||
capped := max(maxWidth-4, 1)
|
|
||||||
return min(natural, capped)
|
|
||||||
}
|
|
||||||
|
|
||||||
func effectiveMaxWidth(configured, bgWidth int) int {
|
|
||||||
fits := max(bgWidth-2*margin, 1)
|
|
||||||
if configured > 0 && configured < fits {
|
|
||||||
return configured
|
|
||||||
}
|
|
||||||
return fits
|
|
||||||
}
|
|
||||||
|
|
||||||
func clipToHeight(stack string, maxHeight int, anchoredTop bool) string {
|
|
||||||
lines := strings.Split(stack, "\n")
|
|
||||||
if len(lines) <= maxHeight {
|
|
||||||
return stack
|
|
||||||
}
|
|
||||||
if maxHeight <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if anchoredTop {
|
|
||||||
lines = lines[:maxHeight]
|
|
||||||
} else {
|
|
||||||
lines = lines[len(lines)-maxHeight:]
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func blank(width, height int) string {
|
|
||||||
if width <= 0 || height <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
line := strings.Repeat(" ", width)
|
|
||||||
lines := make([]string, height)
|
|
||||||
for i := range lines {
|
|
||||||
lines[i] = line
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package notification
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
type KindStyle struct {
|
|
||||||
Border lipgloss.Style
|
|
||||||
Title lipgloss.Style
|
|
||||||
Message lipgloss.Style
|
|
||||||
}
|
|
||||||
|
|
||||||
type Styles struct {
|
|
||||||
Info KindStyle
|
|
||||||
Success KindStyle
|
|
||||||
Warning KindStyle
|
|
||||||
Error KindStyle
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultStyles() Styles {
|
|
||||||
return Styles{
|
|
||||||
Info: kindStyle(style.S.Primary),
|
|
||||||
Success: kindStyle(style.S.Success),
|
|
||||||
Warning: kindStyle(style.S.Warning),
|
|
||||||
Error: kindStyle(style.S.Error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func kindStyle(c color.Color) KindStyle {
|
|
||||||
return KindStyle{
|
|
||||||
Border: lipgloss.NewStyle().
|
|
||||||
Border(style.S.BorderType).
|
|
||||||
BorderForeground(c).
|
|
||||||
Padding(0, 1),
|
|
||||||
Title: lipgloss.NewStyle().Bold(true).Foreground(c),
|
|
||||||
Message: lipgloss.NewStyle().Foreground(style.S.Text),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s Styles) forKind(t Toast) KindStyle {
|
|
||||||
if t.Style != nil {
|
|
||||||
return *t.Style
|
|
||||||
}
|
|
||||||
switch t.Kind {
|
|
||||||
case Success:
|
|
||||||
return s.Success
|
|
||||||
case Warning:
|
|
||||||
return s.Warning
|
|
||||||
case Error:
|
|
||||||
return s.Error
|
|
||||||
default:
|
|
||||||
return s.Info
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package notification
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Kind int
|
|
||||||
|
|
||||||
const (
|
|
||||||
Info Kind = iota
|
|
||||||
Success
|
|
||||||
Warning
|
|
||||||
Error
|
|
||||||
)
|
|
||||||
|
|
||||||
const DefaultDuration = 3 * time.Second
|
|
||||||
|
|
||||||
type Toast struct {
|
|
||||||
ID string
|
|
||||||
Title string
|
|
||||||
Message string
|
|
||||||
Kind Kind
|
|
||||||
|
|
||||||
Duration time.Duration
|
|
||||||
|
|
||||||
Style *KindStyle
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToastOption func(*Toast)
|
|
||||||
|
|
||||||
func WithID(id string) ToastOption {
|
|
||||||
return func(t *Toast) { t.ID = id }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithDuration(d time.Duration) ToastOption {
|
|
||||||
return func(t *Toast) { t.Duration = d }
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithToastStyle(s KindStyle) ToastOption {
|
|
||||||
return func(t *Toast) { t.Style = &s }
|
|
||||||
}
|
|
||||||
|
|
||||||
func newToast(title, message string, kind Kind, opts ...ToastOption) Toast {
|
|
||||||
t := Toast{
|
|
||||||
Title: title,
|
|
||||||
Message: message,
|
|
||||||
Kind: kind,
|
|
||||||
Duration: DefaultDuration,
|
|
||||||
}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&t)
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShowMsg struct{ Toast Toast }
|
|
||||||
|
|
||||||
func Show(title, message string, kind Kind, opts ...ToastOption) tea.Cmd {
|
|
||||||
t := newToast(title, message, kind, opts...)
|
|
||||||
return func() tea.Msg { return ShowMsg{Toast: t} }
|
|
||||||
}
|
|
||||||
|
|
||||||
type DismissMsg struct{ ID string }
|
|
||||||
|
|
||||||
func Dismiss(id string) tea.Cmd {
|
|
||||||
return func() tea.Msg { return DismissMsg{ID: id} }
|
|
||||||
}
|
|
||||||
|
|
||||||
type expireMsg struct{ id string }
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "ilovetui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"module": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": { "types": "./src/index.ts", "default": "./dist/index.js" },
|
||||||
|
"./solid": { "types": "./src/solid.ts", "default": "./dist/solid.js" },
|
||||||
|
"./context": { "types": "./src/context/index.ts", "default": "./dist/context/index.js" },
|
||||||
|
"./project-config": { "types": "./src/project-config.ts", "default": "./dist/project-config.js" },
|
||||||
|
"./keymap": { "types": "./src/keymap/provider.tsx", "default": "./dist/keymap/provider.js" },
|
||||||
|
"./components": { "types": "./src/components/index.ts", "default": "./dist/components/index.js" },
|
||||||
|
"./components/spinner": { "types": "./src/components/Spinner.tsx", "default": "./dist/components/Spinner.js" },
|
||||||
|
"./components/help": { "types": "./src/components/help.ts", "default": "./dist/components/help.js" }
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"files": ["dist", "src"],
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun run scripts/build.ts",
|
||||||
|
"prepare": "bun run scripts/build.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentui/core": ">=0.5.0",
|
||||||
|
"@opentui/solid": ">=0.5.0",
|
||||||
|
"@opentui/keymap": ">=0.5.0",
|
||||||
|
"solid-js": ">=1.9.0",
|
||||||
|
"opentui-spinner": ">=0.0.7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@opentui/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentui/solid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentui/keymap": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"solid-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"opentui-spinner": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.8.1",
|
||||||
|
"zod": "^4.5.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@opentui/core": "^0.5.9",
|
||||||
|
"@opentui/keymap": "^0.5.9",
|
||||||
|
"@opentui/solid": "^0.5.9",
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
|
"opentui-spinner": "^0.0.7",
|
||||||
|
"solid-js": "1.9.12",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin";
|
||||||
|
|
||||||
|
const entrypoints = await Array.fromAsync(new Bun.Glob("**/*.{ts,tsx}").scan({ cwd: "src" }));
|
||||||
|
|
||||||
|
await Bun.$`rm -rf dist`;
|
||||||
|
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: entrypoints.map((path) => `src/${path}`),
|
||||||
|
outdir: "dist",
|
||||||
|
root: "src",
|
||||||
|
target: "bun",
|
||||||
|
format: "esm",
|
||||||
|
splitting: true,
|
||||||
|
plugins: [createSolidTransformPlugin()],
|
||||||
|
external: ["@opentui/core", "@opentui/solid", "@opentui/keymap", "solid-js", "opentui-spinner", "yaml", "zod"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
for (const message of result.logs) console.error(message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Bun.write("dist/default.yaml", Bun.file("src/default.yaml"));
|
||||||
|
|
||||||
|
console.log(`Built ${result.outputs.length} files to dist/`);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { type ColorInput, TextAttributes } from "@opentui/core";
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { theme } from "../index.ts";
|
||||||
|
|
||||||
|
const CAP_LEFT = "";
|
||||||
|
const CAP_RIGHT = "";
|
||||||
|
|
||||||
|
export interface BadgeProps {
|
||||||
|
label: string;
|
||||||
|
color: ColorInput;
|
||||||
|
textColor?: ColorInput;
|
||||||
|
backgroundColor?: ColorInput;
|
||||||
|
bold?: boolean;
|
||||||
|
withNerdfont?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge(props: BadgeProps) {
|
||||||
|
const textColor = () => props.textColor ?? theme.background;
|
||||||
|
const backgroundColor = () => props.backgroundColor ?? theme.background;
|
||||||
|
const attributes = () => (props.bold === false ? undefined : TextAttributes.BOLD);
|
||||||
|
const nerdFonts = () => props.withNerdfont ?? theme.nerdFonts;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="row">
|
||||||
|
<Show when={nerdFonts()}>
|
||||||
|
<text fg={props.color} bg={backgroundColor()}>
|
||||||
|
{CAP_LEFT}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={textColor()} bg={props.color} attributes={attributes()}>
|
||||||
|
{nerdFonts() ? props.label : ` ${props.label} `}
|
||||||
|
</text>
|
||||||
|
<Show when={nerdFonts()}>
|
||||||
|
<text fg={props.color} bg={backgroundColor()}>
|
||||||
|
{CAP_RIGHT}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { type ColorInput, CliRenderEvents, TextAttributes, type BoxRenderable } from "@opentui/core";
|
||||||
|
import { onResize, useRenderer } from "@opentui/solid";
|
||||||
|
import { formatCommandBindings } from "@opentui/keymap/extras";
|
||||||
|
import { useKeymapSelector } from "@opentui/keymap/solid";
|
||||||
|
import { createMemo, createSignal, For } from "solid-js";
|
||||||
|
import { theme } from "../index.ts";
|
||||||
|
|
||||||
|
const KEY_DISPLAY = { up: "↑", down: "↓", left: "←", right: "→", enter: "Enter", escape: "Esc" };
|
||||||
|
const SEPARATOR = " ";
|
||||||
|
const ELLIPSIS = "…";
|
||||||
|
|
||||||
|
export interface HelpBarProps {
|
||||||
|
accentColor?: ColorInput;
|
||||||
|
mutedColor?: ColorInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HelpEntry {
|
||||||
|
command: string;
|
||||||
|
keys: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpBar(props: HelpBarProps = {}) {
|
||||||
|
const accent = () => props.accentColor ?? theme.primary;
|
||||||
|
const muted = () => props.mutedColor ?? theme.muted;
|
||||||
|
const renderer = useRenderer();
|
||||||
|
const [width, setWidth] = createSignal(0);
|
||||||
|
let container: BoxRenderable | undefined;
|
||||||
|
const measure = () => {
|
||||||
|
if (!container) return;
|
||||||
|
setWidth(container.width);
|
||||||
|
};
|
||||||
|
const remeasureNextFrame = () => renderer.once(CliRenderEvents.FRAME, measure);
|
||||||
|
onResize(remeasureNextFrame);
|
||||||
|
|
||||||
|
const entries = useKeymapSelector((keymap): HelpEntry[] =>
|
||||||
|
keymap
|
||||||
|
.getCommandEntries({ visibility: "active" })
|
||||||
|
.filter((entry) => entry.command.shortHelp === true)
|
||||||
|
.map((entry) => ({
|
||||||
|
command: entry.command.name,
|
||||||
|
keys: formatCommandBindings(entry.bindings, { keyNameAliases: KEY_DISPLAY }) ?? "",
|
||||||
|
label: typeof entry.command.desc === "string" ? entry.command.desc : entry.command.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const visible = createMemo((): HelpEntry[] => {
|
||||||
|
const available = width() - 2;
|
||||||
|
const shown: HelpEntry[] = [];
|
||||||
|
let used = 0;
|
||||||
|
for (const entry of entries()) {
|
||||||
|
const sepWidth = shown.length > 0 ? SEPARATOR.length : 0;
|
||||||
|
const entryWidth = entry.keys.length + 1 + entry.label.length;
|
||||||
|
if (used + sepWidth + entryWidth <= available) {
|
||||||
|
used += sepWidth + entryWidth;
|
||||||
|
shown.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const remaining = available - used - sepWidth;
|
||||||
|
const prefix = `${entry.keys} `;
|
||||||
|
if (remaining > prefix.length + ELLIPSIS.length) {
|
||||||
|
const labelBudget = remaining - prefix.length - ELLIPSIS.length;
|
||||||
|
shown.push({ ...entry, label: `${entry.label.slice(0, labelBudget)}${ELLIPSIS}` });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return shown;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
overflow="hidden"
|
||||||
|
ref={(el) => {
|
||||||
|
container = el;
|
||||||
|
remeasureNextFrame();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={visible()}>
|
||||||
|
{(entry, i) => (
|
||||||
|
<>
|
||||||
|
{i() > 0 && <text fg={muted()}>{SEPARATOR}</text>}
|
||||||
|
<text fg={accent()} attributes={TextAttributes.BOLD}>
|
||||||
|
{entry.keys}
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>{` ${entry.label}`}</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { type ColorInput, TextAttributes } from "@opentui/core";
|
||||||
|
import { formatCommandBindings } from "@opentui/keymap/extras";
|
||||||
|
import { useKeymapSelector } from "@opentui/keymap/solid";
|
||||||
|
import { helpOpen, toggleHelp } from "../context/help.ts";
|
||||||
|
import { Modal } from "./Modal.tsx";
|
||||||
|
|
||||||
|
const KEY_DISPLAY = { up: "↑", down: "↓", left: "←", right: "→", enter: "Enter", escape: "Esc" };
|
||||||
|
|
||||||
|
export interface HelpModalProps {
|
||||||
|
accentColor?: ColorInput;
|
||||||
|
backgroundColor?: ColorInput;
|
||||||
|
backdropColor?: ColorInput;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HelpEntry {
|
||||||
|
command: string;
|
||||||
|
keys: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpModal(props: HelpModalProps = {}) {
|
||||||
|
const entries = useKeymapSelector((keymap): HelpEntry[] =>
|
||||||
|
keymap.getCommandEntries({ visibility: "active" }).map((entry) => ({
|
||||||
|
command: entry.command.name,
|
||||||
|
keys: formatCommandBindings(entry.bindings, { keyNameAliases: KEY_DISPLAY }) ?? "",
|
||||||
|
label: typeof entry.command.desc === "string" ? entry.command.desc : entry.command.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={helpOpen()}
|
||||||
|
onDismiss={toggleHelp}
|
||||||
|
accentColor={props.accentColor}
|
||||||
|
backgroundColor={props.backgroundColor}
|
||||||
|
backdropColor={props.backdropColor}
|
||||||
|
width={props.width}
|
||||||
|
>
|
||||||
|
<text attributes={TextAttributes.BOLD}>Keybindings</text>
|
||||||
|
<text> </text>
|
||||||
|
<text>{entries().map((entry) => `${entry.keys.padEnd(10)}${entry.label}`).join("\n")}</text>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { ColorInput } from "@opentui/core";
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid";
|
||||||
|
import { Match, Switch, type ParentProps } from "solid-js";
|
||||||
|
import { theme } from "../index.ts";
|
||||||
|
|
||||||
|
export interface MinSizeGuardProps extends ParentProps {
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
backgroundColor?: ColorInput;
|
||||||
|
textColor?: ColorInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MinSizeGuard(props: MinSizeGuardProps) {
|
||||||
|
const dimensions = useTerminalDimensions();
|
||||||
|
const minWidth = () => props.minWidth ?? 80;
|
||||||
|
const minHeight = () => props.minHeight ?? 24;
|
||||||
|
const tooSmall = () => dimensions().width < minWidth() || dimensions().height < minHeight();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Match when={tooSmall()}>
|
||||||
|
<box
|
||||||
|
width={dimensions().width}
|
||||||
|
height={dimensions().height}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
backgroundColor={props.backgroundColor ?? theme.background}
|
||||||
|
>
|
||||||
|
<text fg={props.textColor ?? theme.muted}>
|
||||||
|
{`Terminal too small\nMinimum size: ${minWidth()}x${minHeight()} — current: ${dimensions().width}x${dimensions().height}`}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Match>
|
||||||
|
<Match when={!tooSmall()}>{props.children}</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { type ColorInput, RGBA } from "@opentui/core";
|
||||||
|
import { Portal, useTerminalDimensions } from "@opentui/solid";
|
||||||
|
import { Show, type ParentProps } from "solid-js";
|
||||||
|
import { theme } from "../index.ts";
|
||||||
|
|
||||||
|
export interface ModalProps extends ParentProps {
|
||||||
|
open: boolean;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
accentColor?: ColorInput;
|
||||||
|
backgroundColor?: ColorInput;
|
||||||
|
backdropColor?: ColorInput;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal(props: ModalProps) {
|
||||||
|
const dimensions = useTerminalDimensions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<Show when={props.open}>
|
||||||
|
<box
|
||||||
|
width={dimensions().width}
|
||||||
|
height={dimensions().height}
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
alignItems="center"
|
||||||
|
paddingTop={Math.floor(dimensions().height / 4)}
|
||||||
|
backgroundColor={props.backdropColor ?? RGBA.fromInts(0, 0, 0, 150)}
|
||||||
|
zIndex={2000}
|
||||||
|
onMouseDown={theme.mouse ? props.onDismiss : undefined}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
width={props.width ?? 50}
|
||||||
|
border
|
||||||
|
borderStyle={theme.borderStyle}
|
||||||
|
borderColor={props.accentColor ?? theme.primary}
|
||||||
|
backgroundColor={props.backgroundColor ?? theme.background}
|
||||||
|
flexDirection="column"
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ColorInput } from "@opentui/core";
|
||||||
|
import { Portal } from "@opentui/solid";
|
||||||
|
import { For } from "solid-js";
|
||||||
|
import { dismiss, notifications, type NotificationKind } from "../context/notifications.ts";
|
||||||
|
import { theme } from "../index.ts";
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<NotificationKind, string> = {
|
||||||
|
info: "Info",
|
||||||
|
success: "Success",
|
||||||
|
warning: "Warning",
|
||||||
|
error: "Error",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface NotificationHostProps {
|
||||||
|
width?: number;
|
||||||
|
colors?: Partial<Record<NotificationKind, ColorInput>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationHost(props: NotificationHostProps = {}) {
|
||||||
|
const defaultColor = (): Record<NotificationKind, ColorInput> => ({
|
||||||
|
info: theme.primary,
|
||||||
|
success: theme.success,
|
||||||
|
warning: theme.warning,
|
||||||
|
error: theme.error,
|
||||||
|
});
|
||||||
|
const colorFor = (kind: NotificationKind) => props.colors?.[kind] ?? defaultColor()[kind];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<box position="absolute" top={1} right={1} flexDirection="column" zIndex={1000}>
|
||||||
|
<For each={notifications()}>
|
||||||
|
{(toast) => (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderStyle={theme.borderStyle}
|
||||||
|
borderColor={colorFor(toast.kind)}
|
||||||
|
width={props.width ?? 32}
|
||||||
|
marginBottom={1}
|
||||||
|
onMouseDown={theme.mouse ? () => dismiss(toast.id) : undefined}
|
||||||
|
>
|
||||||
|
<text fg={colorFor(toast.kind)}>{toast.title ?? KIND_LABEL[toast.kind]}</text>
|
||||||
|
<text>{toast.message}</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { type ColorInput, parseColor, RGBA } from "@opentui/core";
|
||||||
|
import { createMemo, For } from "solid-js";
|
||||||
|
import { theme } from "../index.ts";
|
||||||
|
|
||||||
|
function sampleGradient(stops: ColorInput[], t: number): ColorInput {
|
||||||
|
if (stops.length === 1) return stops[0]!;
|
||||||
|
const clamped = Math.min(Math.max(t, 0), 1);
|
||||||
|
const segment = clamped * (stops.length - 1);
|
||||||
|
const i = Math.min(Math.floor(segment), stops.length - 2);
|
||||||
|
const localT = segment - i;
|
||||||
|
const [ar, ag, ab, aa] = parseColor(stops[i]!).toInts();
|
||||||
|
const [br, bg, bb, ba] = parseColor(stops[i + 1]!).toInts();
|
||||||
|
return RGBA.fromInts(
|
||||||
|
Math.round(ar + (br - ar) * localT),
|
||||||
|
Math.round(ag + (bg - ag) * localT),
|
||||||
|
Math.round(ab + (bb - ab) * localT),
|
||||||
|
Math.round(aa + (ba - aa) * localT),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressBarProps {
|
||||||
|
value: number;
|
||||||
|
width?: number;
|
||||||
|
color?: ColorInput | ColorInput[];
|
||||||
|
trackColor?: ColorInput;
|
||||||
|
fillChar?: string;
|
||||||
|
trackChar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar(props: ProgressBarProps) {
|
||||||
|
const width = () => props.width ?? 20;
|
||||||
|
const stops = createMemo(() => (Array.isArray(props.color) ? props.color : [props.color ?? theme.primary]));
|
||||||
|
const filled = createMemo(() => Math.round((width() * Math.min(Math.max(props.value, 0), 100)) / 100));
|
||||||
|
const cells = createMemo(() => Array.from({ length: width() }, (_, i) => i));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="row">
|
||||||
|
<For each={cells()}>
|
||||||
|
{(i) => {
|
||||||
|
const isFilled = () => i < filled();
|
||||||
|
const t = width() > 1 ? i / (width() - 1) : 0;
|
||||||
|
return (
|
||||||
|
<text fg={isFilled() ? sampleGradient(stops(), t) : (props.trackColor ?? theme.muted)}>
|
||||||
|
{isFilled() ? (props.fillChar ?? "█") : (props.trackChar ?? "░")}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { type ColorInput, type SelectOption, TextAttributes } from "@opentui/core";
|
||||||
|
import { theme } from "../index.ts";
|
||||||
|
|
||||||
|
export interface SidebarHandle {
|
||||||
|
moveUp(): void;
|
||||||
|
moveDown(): void;
|
||||||
|
selectCurrent(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
title: string;
|
||||||
|
items: SelectOption[];
|
||||||
|
width?: number;
|
||||||
|
focused?: boolean;
|
||||||
|
onSelect: (option: SelectOption) => void;
|
||||||
|
onConfirm?: (option: SelectOption) => void;
|
||||||
|
ref?: (handle: SidebarHandle | null) => void;
|
||||||
|
|
||||||
|
accentColor?: ColorInput;
|
||||||
|
mutedColor?: ColorInput;
|
||||||
|
|
||||||
|
backgroundColor?: ColorInput;
|
||||||
|
textColor?: ColorInput;
|
||||||
|
focusedBackgroundColor?: ColorInput;
|
||||||
|
focusedTextColor?: ColorInput;
|
||||||
|
selectedBackgroundColor?: ColorInput;
|
||||||
|
selectedTextColor?: ColorInput;
|
||||||
|
descriptionColor?: ColorInput;
|
||||||
|
selectedDescriptionColor?: ColorInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar(props: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
width={props.width ?? 24}
|
||||||
|
border
|
||||||
|
borderStyle={theme.borderStyle}
|
||||||
|
borderColor={props.focused ? (props.accentColor ?? theme.primary) : (props.mutedColor ?? theme.muted)}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||||
|
<select
|
||||||
|
ref={(el) => {
|
||||||
|
props.ref?.(
|
||||||
|
el && {
|
||||||
|
moveUp: () => el.moveUp(),
|
||||||
|
moveDown: () => el.moveDown(),
|
||||||
|
selectCurrent: () => el.selectCurrent(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
flexGrow={1}
|
||||||
|
focused={props.focused}
|
||||||
|
showScrollIndicator
|
||||||
|
options={props.items}
|
||||||
|
backgroundColor={props.backgroundColor}
|
||||||
|
textColor={props.textColor}
|
||||||
|
focusedBackgroundColor={props.focusedBackgroundColor}
|
||||||
|
focusedTextColor={props.focusedTextColor}
|
||||||
|
selectedBackgroundColor={props.selectedBackgroundColor}
|
||||||
|
selectedTextColor={props.selectedTextColor}
|
||||||
|
descriptionColor={props.descriptionColor}
|
||||||
|
selectedDescriptionColor={props.selectedDescriptionColor}
|
||||||
|
onChange={(_index, option) => {
|
||||||
|
if (!option) return;
|
||||||
|
props.onSelect(option);
|
||||||
|
}}
|
||||||
|
onSelect={(_index, option) => {
|
||||||
|
if (!option) return;
|
||||||
|
props.onConfirm?.(option);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ColorInput } from "@opentui/core";
|
||||||
|
import { For } from "solid-js";
|
||||||
|
import { theme } from "../index.ts";
|
||||||
|
|
||||||
|
const BARS = ["▁", "▃", "▅", "▇"] as const;
|
||||||
|
|
||||||
|
export interface SignalBarsProps {
|
||||||
|
signal: number;
|
||||||
|
filledColor?: ColorInput;
|
||||||
|
emptyColor?: ColorInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignalBars(props: SignalBarsProps) {
|
||||||
|
const level = () => Math.floor((props.signal * BARS.length) / 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="row">
|
||||||
|
<For each={BARS}>
|
||||||
|
{(bar, i) => <text fg={i() < level() ? props.filledColor : (props.emptyColor ?? theme.muted)}>{bar}</text>}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ColorInput } from "@opentui/core";
|
||||||
|
import "opentui-spinner/solid";
|
||||||
|
import type { ColorGenerator, SpinnerOptions } from "opentui-spinner";
|
||||||
|
import { theme } from "../index.ts";
|
||||||
|
|
||||||
|
export interface SpinnerProps {
|
||||||
|
name?: SpinnerOptions["name"];
|
||||||
|
frames?: string[];
|
||||||
|
interval?: number;
|
||||||
|
autoplay?: boolean;
|
||||||
|
color?: ColorInput | ColorGenerator;
|
||||||
|
backgroundColor?: ColorInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner(props: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<spinner
|
||||||
|
name={props.name}
|
||||||
|
frames={props.frames}
|
||||||
|
interval={props.interval}
|
||||||
|
autoplay={props.autoplay ?? !theme.reducedMotion}
|
||||||
|
color={props.color ?? theme.primary}
|
||||||
|
backgroundColor={props.backgroundColor}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { type ColorInput, CliRenderEvents, TextAttributes, type BoxRenderable } from "@opentui/core";
|
||||||
|
import { onResize, useRenderer } from "@opentui/solid";
|
||||||
|
import { createMemo, createSignal, For } from "solid-js";
|
||||||
|
import { theme } from "../index.ts";
|
||||||
|
|
||||||
|
export interface TabItem {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabsProps {
|
||||||
|
items: TabItem[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
focused?: boolean;
|
||||||
|
accentColor?: ColorInput;
|
||||||
|
mutedColor?: ColorInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepTabValue(items: TabItem[], value: string, delta: number): string {
|
||||||
|
const index = items.findIndex((t) => t.value === value);
|
||||||
|
if (index === -1) return value;
|
||||||
|
return items[(index + delta + items.length) % items.length]!.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function innerWidth(label: string): number {
|
||||||
|
return label.length + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentWidth(label: string): number {
|
||||||
|
return innerWidth(label) + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function topEdge(label: string): string {
|
||||||
|
return `╭${"─".repeat(innerWidth(label))}╮`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bottomEdge(label: string, isFirst: boolean, isLast: boolean, isActive: boolean): string {
|
||||||
|
const left = isFirst ? (isActive ? "│" : "├") : isActive ? "╯" : "┴";
|
||||||
|
const right = isLast ? (isActive ? "│" : "┤") : isActive ? "╰" : "┴";
|
||||||
|
const fill = isActive ? " " : "─";
|
||||||
|
return left + fill.repeat(innerWidth(label)) + right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBottomRow(segments: { label: string; isActive: boolean }[], available: number): string {
|
||||||
|
const tabsWidth = segments.reduce((sum, s) => sum + segmentWidth(s.label), 0);
|
||||||
|
const remainder = available - tabsWidth;
|
||||||
|
const extending = remainder > 0;
|
||||||
|
|
||||||
|
const tabs = segments
|
||||||
|
.map((s, i) => bottomEdge(s.label, i === 0, i === segments.length - 1 && !extending, s.isActive))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return extending ? tabs + "─".repeat(remainder - 1) + "╮" : tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitCount(items: TabItem[], start: number, available: number): number {
|
||||||
|
const hiddenBefore = start;
|
||||||
|
let used = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = start; i < items.length; i++) {
|
||||||
|
const width = segmentWidth(items[i]!.label);
|
||||||
|
const hiddenAfter = items.length - start - count - 1;
|
||||||
|
const totalHidden = hiddenBefore + hiddenAfter;
|
||||||
|
const reserve = totalHidden > 0 ? segmentWidth(`+${totalHidden}`) : 0;
|
||||||
|
if (used + width + reserve > available) break;
|
||||||
|
used += width;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return Math.max(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs(props: TabsProps) {
|
||||||
|
const renderer = useRenderer();
|
||||||
|
const [width, setWidth] = createSignal(0);
|
||||||
|
let container: BoxRenderable | undefined;
|
||||||
|
const measure = () => {
|
||||||
|
if (!container) return;
|
||||||
|
setWidth(container.width);
|
||||||
|
};
|
||||||
|
const remeasureNextFrame = () => renderer.once(CliRenderEvents.FRAME, measure);
|
||||||
|
onResize(remeasureNextFrame);
|
||||||
|
|
||||||
|
const segments = createMemo(() => {
|
||||||
|
const items = props.items;
|
||||||
|
const activeIndex = Math.max(
|
||||||
|
0,
|
||||||
|
items.findIndex((t) => t.value === props.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let count = fitCount(items, start, width());
|
||||||
|
if (activeIndex >= start + count) {
|
||||||
|
start = activeIndex;
|
||||||
|
count = fitCount(items, start, width());
|
||||||
|
}
|
||||||
|
|
||||||
|
const shownItems = items.slice(start, start + count);
|
||||||
|
const shown = shownItems.map((item) => ({
|
||||||
|
value: item.value as string | undefined,
|
||||||
|
label: item.label,
|
||||||
|
isActive: item.value === props.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hidden = items.length - count;
|
||||||
|
if (hidden > 0) {
|
||||||
|
const used = shownItems.reduce((sum, item) => sum + segmentWidth(item.label), 0);
|
||||||
|
const tag = `+${hidden}`;
|
||||||
|
if (shown.length === 0 || used + segmentWidth(tag) <= width()) {
|
||||||
|
shown.push({ value: undefined, label: tag, isActive: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shown;
|
||||||
|
});
|
||||||
|
|
||||||
|
const accent = () => props.accentColor ?? theme.primary;
|
||||||
|
const muted = () => props.mutedColor ?? theme.muted;
|
||||||
|
|
||||||
|
const borderColor = () => (props.focused ? accent() : muted());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
ref={(el) => {
|
||||||
|
container = el;
|
||||||
|
remeasureNextFrame();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={borderColor()}>{segments().map((s) => topEdge(s.label)).join("")}</text>
|
||||||
|
<box flexDirection="row">
|
||||||
|
<For each={segments()}>
|
||||||
|
{(s) => (
|
||||||
|
<>
|
||||||
|
<text fg={borderColor()}>│</text>
|
||||||
|
<text
|
||||||
|
fg={s.isActive ? accent() : muted()}
|
||||||
|
attributes={s.isActive ? TextAttributes.BOLD : undefined}
|
||||||
|
onMouseDown={theme.mouse && s.value !== undefined ? () => props.onChange(s.value!) : undefined}
|
||||||
|
>
|
||||||
|
{` ${s.label} `}
|
||||||
|
</text>
|
||||||
|
<text fg={borderColor()}>│</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
<text fg={borderColor()}>{buildBottomRow(segments(), width())}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { type ColorInput, TextAttributes } from "@opentui/core";
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { theme } from "../index.ts";
|
||||||
|
import { Badge } from "./Badge.tsx";
|
||||||
|
|
||||||
|
export interface ToggleProps {
|
||||||
|
on: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
onColor?: ColorInput;
|
||||||
|
offColor?: ColorInput;
|
||||||
|
withNerdfont?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toggle(props: ToggleProps) {
|
||||||
|
const onColor = () => props.onColor ?? theme.success;
|
||||||
|
const offColor = () => props.offColor ?? theme.muted;
|
||||||
|
const nerdFonts = () => props.withNerdfont ?? theme.nerdFonts;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box onMouseDown={theme.mouse ? props.onToggle : undefined}>
|
||||||
|
<Show
|
||||||
|
when={nerdFonts()}
|
||||||
|
fallback={
|
||||||
|
<text fg={props.on ? onColor() : offColor()} attributes={TextAttributes.BOLD}>
|
||||||
|
{props.on ? "on" : "off"}
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Badge label={props.on ? " ●" : "● "} color={props.on ? onColor() : offColor()} withNerdfont />
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { HelpBar, type HelpBarProps } from "./HelpBar.tsx";
|
||||||
|
export { HelpModal, type HelpModalProps } from "./HelpModal.tsx";
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { Badge, type BadgeProps } from "./Badge.tsx";
|
||||||
|
export { MinSizeGuard, type MinSizeGuardProps } from "./MinSizeGuard.tsx";
|
||||||
|
export { Modal, type ModalProps } from "./Modal.tsx";
|
||||||
|
export { NotificationHost, type NotificationHostProps } from "./NotificationHost.tsx";
|
||||||
|
export { ProgressBar, type ProgressBarProps } from "./ProgressBar.tsx";
|
||||||
|
export { Sidebar, type SidebarHandle, type SidebarProps } from "./Sidebar.tsx";
|
||||||
|
export { SignalBars, type SignalBarsProps } from "./SignalBars.tsx";
|
||||||
|
export { stepTabValue, Tabs, type TabItem, type TabsProps } from "./Tabs.tsx";
|
||||||
|
export { Toggle, type ToggleProps } from "./Toggle.tsx";
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
export interface ColorsYAML {
|
||||||
|
base00?: string;
|
||||||
|
base01?: string;
|
||||||
|
base02?: string;
|
||||||
|
base03?: string;
|
||||||
|
base04?: string;
|
||||||
|
base05?: string;
|
||||||
|
base06?: string;
|
||||||
|
base07?: string;
|
||||||
|
base08?: string;
|
||||||
|
base09?: string;
|
||||||
|
base0a?: string;
|
||||||
|
base0b?: string;
|
||||||
|
base0c?: string;
|
||||||
|
base0d?: string;
|
||||||
|
base0e?: string;
|
||||||
|
base0f?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigYAML {
|
||||||
|
nerd_fonts?: boolean;
|
||||||
|
emoji_fonts?: boolean;
|
||||||
|
reduced_motion?: boolean;
|
||||||
|
mouse?: boolean;
|
||||||
|
border?: string;
|
||||||
|
colors?: ColorsYAML;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLOR_KEYS = [
|
||||||
|
"base00",
|
||||||
|
"base01",
|
||||||
|
"base02",
|
||||||
|
"base03",
|
||||||
|
"base04",
|
||||||
|
"base05",
|
||||||
|
"base06",
|
||||||
|
"base07",
|
||||||
|
"base08",
|
||||||
|
"base09",
|
||||||
|
"base0a",
|
||||||
|
"base0b",
|
||||||
|
"base0c",
|
||||||
|
"base0d",
|
||||||
|
"base0e",
|
||||||
|
"base0f",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ResolvedColors = Record<(typeof COLOR_KEYS)[number], string>;
|
||||||
|
|
||||||
|
export function mergeColors(
|
||||||
|
base: ColorsYAML,
|
||||||
|
user: ColorsYAML,
|
||||||
|
): ResolvedColors {
|
||||||
|
const merged = {} as ResolvedColors;
|
||||||
|
for (const key of COLOR_KEYS) {
|
||||||
|
merged[key] = user[key] || base[key] || "";
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedConfig {
|
||||||
|
nerdFonts: boolean;
|
||||||
|
emojiFonts: boolean;
|
||||||
|
reducedMotion: boolean;
|
||||||
|
mouse: boolean;
|
||||||
|
border: string;
|
||||||
|
colors: ResolvedColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeConfig(
|
||||||
|
base: ConfigYAML,
|
||||||
|
user: ConfigYAML,
|
||||||
|
): ResolvedConfig {
|
||||||
|
return {
|
||||||
|
nerdFonts: user.nerd_fonts ?? base.nerd_fonts ?? false,
|
||||||
|
emojiFonts: user.emoji_fonts ?? base.emoji_fonts ?? false,
|
||||||
|
reducedMotion: user.reduced_motion ?? base.reduced_motion ?? false,
|
||||||
|
mouse: user.mouse ?? base.mouse ?? true,
|
||||||
|
border: user.border || base.border || "rounded",
|
||||||
|
colors: mergeColors(base.colors ?? {}, user.colors ?? {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeColor(hex: string): string {
|
||||||
|
const trimmed = hex.trim();
|
||||||
|
if (trimmed && !trimmed.startsWith("#")) return `#${trimmed}`;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeColors(colors: ResolvedColors): ResolvedColors {
|
||||||
|
const out = {} as ResolvedColors;
|
||||||
|
for (const key of COLOR_KEYS) out[key] = normalizeColor(colors[key]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenTUIBorderStyle = "single" | "double" | "rounded" | "heavy";
|
||||||
|
|
||||||
|
const OPENTUI_BORDER_STYLES: readonly OpenTUIBorderStyle[] = [
|
||||||
|
"single",
|
||||||
|
"double",
|
||||||
|
"rounded",
|
||||||
|
"heavy",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function resolveBorderStyle(name: string): OpenTUIBorderStyle {
|
||||||
|
return (OPENTUI_BORDER_STYLES as readonly string[]).includes(name)
|
||||||
|
? (name as OpenTUIBorderStyle)
|
||||||
|
: "rounded";
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createSignal } from "solid-js";
|
||||||
|
|
||||||
|
const [helpOpen, setHelpOpen] = createSignal(false);
|
||||||
|
export { helpOpen };
|
||||||
|
|
||||||
|
export function toggleHelp(): void {
|
||||||
|
setHelpOpen((open) => !open);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createContext, useContext, type ParentProps } from "solid-js";
|
||||||
|
|
||||||
|
export function createSimpleContext<T, Props extends Record<string, any> = {}>(input: {
|
||||||
|
name: string;
|
||||||
|
init: (props: Props) => T;
|
||||||
|
}) {
|
||||||
|
const ctx = createContext<T>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: (props: ParentProps<Props>) => {
|
||||||
|
const value = input.init(props);
|
||||||
|
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
||||||
|
},
|
||||||
|
use() {
|
||||||
|
const value = useContext(ctx);
|
||||||
|
if (!value) throw new Error(`${input.name} context must be used within its provider`);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { createSimpleContext } from "./helper.tsx";
|
||||||
|
export { notify, dismiss, notifications, type Notification, type NotificationKind, type NotifyOptions } from "./notifications.ts";
|
||||||
|
export { helpOpen, toggleHelp } from "./help.ts";
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createSignal } from "solid-js";
|
||||||
|
|
||||||
|
export type NotificationKind = "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
kind: NotificationKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyOptions {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
kind?: NotificationKind;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DURATION = 3000;
|
||||||
|
|
||||||
|
const [notifications, setNotifications] = createSignal<Notification[]>([]);
|
||||||
|
export { notifications };
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
export function notify(message: string, options: NotifyOptions = {}): string {
|
||||||
|
const id = options.id ?? `toast-${nextId++}`;
|
||||||
|
const toast: Notification = { id, title: options.title, message, kind: options.kind ?? "info" };
|
||||||
|
|
||||||
|
setNotifications((prev) => [...prev.filter((n) => n.id !== id), toast]);
|
||||||
|
|
||||||
|
const duration = options.duration ?? DEFAULT_DURATION;
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => dismiss(id), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismiss(id: string): void {
|
||||||
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||||
|
}
|
||||||
@@ -2,19 +2,20 @@
|
|||||||
# Copy to ~/.config/ilovetui/config.yaml and edit to customize.
|
# Copy to ~/.config/ilovetui/config.yaml and edit to customize.
|
||||||
|
|
||||||
# Whether components may use Nerd Font glyphs (requires a patched font).
|
# Whether components may use Nerd Font glyphs (requires a patched font).
|
||||||
# Leave false for maximum terminal/font compatibility.
|
nerd_fonts: true
|
||||||
nerd_fonts: false
|
|
||||||
|
# Whether the terminal font can render emoji glyphs.
|
||||||
|
emoji_fonts: true
|
||||||
|
|
||||||
|
# Disable spinner animation and other motion across all ilovetui-based TUIs.
|
||||||
|
reduced_motion: false
|
||||||
|
|
||||||
|
# Whether components may register mouse handlers (e.g. click-to-dismiss).
|
||||||
|
mouse: true
|
||||||
|
|
||||||
# Border style used by panels across all ilovetui-based TUIs.
|
# Border style used by panels across all ilovetui-based TUIs.
|
||||||
# One of: rounded, normal, thick, double, hidden, ascii.
|
|
||||||
border: rounded
|
border: rounded
|
||||||
|
|
||||||
# How sidebar+content layouts draw their borders. One of:
|
|
||||||
# full: both the sidebar and content panes get a full titled border.
|
|
||||||
# sidebar: only the sidebar gets a full border
|
|
||||||
# separator: neither pane is bordered; a single vertical line divides them.
|
|
||||||
layout_border: full
|
|
||||||
|
|
||||||
colors:
|
colors:
|
||||||
base00: "#110F12" # Background
|
base00: "#110F12" # Background
|
||||||
base01: "#1C1920" # Lighter Background / Status Bars
|
base01: "#1C1920" # Lighter Background / Status Bars
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
type ConfigYAML,
|
||||||
|
type OpenTUIBorderStyle,
|
||||||
|
mergeConfig,
|
||||||
|
normalizeColors,
|
||||||
|
resolveBorderStyle,
|
||||||
|
} from "./config.ts";
|
||||||
|
import { buildPresets } from "./presets.ts";
|
||||||
|
import { configPath, readYamlFile } from "./yaml.ts";
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
base00: string;
|
||||||
|
base01: string;
|
||||||
|
base02: string;
|
||||||
|
base03: string;
|
||||||
|
base04: string;
|
||||||
|
base05: string;
|
||||||
|
base06: string;
|
||||||
|
base07: string;
|
||||||
|
base08: string;
|
||||||
|
base09: string;
|
||||||
|
base0a: string;
|
||||||
|
base0b: string;
|
||||||
|
base0c: string;
|
||||||
|
base0d: string;
|
||||||
|
base0e: string;
|
||||||
|
base0f: string;
|
||||||
|
|
||||||
|
background: string;
|
||||||
|
subtleBg: string;
|
||||||
|
selection: string;
|
||||||
|
subtle: string;
|
||||||
|
muted: string;
|
||||||
|
text: string;
|
||||||
|
primary: string;
|
||||||
|
success: string;
|
||||||
|
warning: string;
|
||||||
|
error: string;
|
||||||
|
|
||||||
|
nerdFonts: boolean;
|
||||||
|
emojiFonts: boolean;
|
||||||
|
reducedMotion: boolean;
|
||||||
|
mouse: boolean;
|
||||||
|
borderStyle: OpenTUIBorderStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { configPath } from "./yaml.ts";
|
||||||
|
|
||||||
|
function buildTheme(): Theme {
|
||||||
|
const defaultConfigPath = join(import.meta.dir, "default.yaml");
|
||||||
|
const base = readYamlFile<ConfigYAML>(defaultConfigPath) ?? {};
|
||||||
|
const user = readYamlFile<ConfigYAML>(configPath()) ?? {};
|
||||||
|
const merged = mergeConfig(base, user);
|
||||||
|
const colors = normalizeColors(merged.colors);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...colors,
|
||||||
|
|
||||||
|
background: colors.base00,
|
||||||
|
subtleBg: colors.base01,
|
||||||
|
selection: colors.base02,
|
||||||
|
subtle: colors.base03,
|
||||||
|
muted: colors.base04,
|
||||||
|
text: colors.base05,
|
||||||
|
primary: colors.base0d,
|
||||||
|
success: colors.base0b,
|
||||||
|
warning: colors.base09,
|
||||||
|
error: colors.base08,
|
||||||
|
|
||||||
|
nerdFonts: merged.nerdFonts,
|
||||||
|
emojiFonts: merged.emojiFonts,
|
||||||
|
reducedMotion: merged.reducedMotion,
|
||||||
|
mouse: merged.mouse,
|
||||||
|
borderStyle: resolveBorderStyle(merged.border),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const theme: Theme = buildTheme();
|
||||||
|
|
||||||
|
export const presets = buildPresets(theme);
|
||||||
|
|
||||||
|
export type { OpenTUIBorderStyle } from "./config.ts";
|
||||||
|
export type { Presets, SelectPreset, TabSelectPreset, InputPreset, BoxPreset } from "./presets.ts";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui";
|
||||||
|
import { registerCommaBindings } from "@opentui/keymap/addons";
|
||||||
|
import { KeymapProvider as BaseKeymapProvider } from "@opentui/keymap/solid";
|
||||||
|
import { useRenderer } from "@opentui/solid";
|
||||||
|
import { onCleanup, type ParentProps } from "solid-js";
|
||||||
|
|
||||||
|
export { useKeymap, useBindings, useKeymapSelector, reactiveMatcherFromSignal } from "@opentui/keymap/solid";
|
||||||
|
|
||||||
|
export function KeymapProvider(props: ParentProps) {
|
||||||
|
const renderer = useRenderer();
|
||||||
|
const keymap = createDefaultOpenTuiKeymap(renderer);
|
||||||
|
onCleanup(registerCommaBindings(keymap));
|
||||||
|
|
||||||
|
return <BaseKeymapProvider keymap={keymap}>{props.children}</BaseKeymapProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { OpenTUIBorderStyle, Theme } from "./index.ts";
|
||||||
|
|
||||||
|
export interface SelectPreset {
|
||||||
|
backgroundColor: string;
|
||||||
|
textColor: string;
|
||||||
|
focusedBackgroundColor: string;
|
||||||
|
focusedTextColor: string;
|
||||||
|
selectedBackgroundColor: string;
|
||||||
|
selectedTextColor: string;
|
||||||
|
descriptionColor: string;
|
||||||
|
selectedDescriptionColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabSelectPreset {
|
||||||
|
backgroundColor: string;
|
||||||
|
textColor: string;
|
||||||
|
focusedBackgroundColor: string;
|
||||||
|
focusedTextColor: string;
|
||||||
|
selectedBackgroundColor: string;
|
||||||
|
selectedTextColor: string;
|
||||||
|
selectedDescriptionColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputPreset {
|
||||||
|
backgroundColor: string;
|
||||||
|
textColor: string;
|
||||||
|
focusedBackgroundColor: string;
|
||||||
|
focusedTextColor: string;
|
||||||
|
placeholderColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoxPreset {
|
||||||
|
border: true;
|
||||||
|
borderStyle: OpenTUIBorderStyle;
|
||||||
|
borderColor: string;
|
||||||
|
focusedBorderColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Presets {
|
||||||
|
select: SelectPreset;
|
||||||
|
tabSelect: TabSelectPreset;
|
||||||
|
input: InputPreset;
|
||||||
|
textarea: InputPreset;
|
||||||
|
box: BoxPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPresets(theme: Theme): Presets {
|
||||||
|
const select: SelectPreset = {
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
textColor: theme.text,
|
||||||
|
focusedBackgroundColor: theme.subtleBg,
|
||||||
|
focusedTextColor: theme.text,
|
||||||
|
selectedBackgroundColor: theme.selection,
|
||||||
|
selectedTextColor: theme.primary,
|
||||||
|
descriptionColor: theme.muted,
|
||||||
|
selectedDescriptionColor: theme.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabSelect: TabSelectPreset = {
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
textColor: theme.text,
|
||||||
|
focusedBackgroundColor: theme.subtleBg,
|
||||||
|
focusedTextColor: theme.text,
|
||||||
|
selectedBackgroundColor: theme.selection,
|
||||||
|
selectedTextColor: theme.primary,
|
||||||
|
selectedDescriptionColor: theme.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
const input: InputPreset = {
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
textColor: theme.text,
|
||||||
|
focusedBackgroundColor: theme.subtleBg,
|
||||||
|
focusedTextColor: theme.text,
|
||||||
|
placeholderColor: theme.muted,
|
||||||
|
};
|
||||||
|
|
||||||
|
const box: BoxPreset = {
|
||||||
|
border: true,
|
||||||
|
borderStyle: theme.borderStyle,
|
||||||
|
borderColor: theme.subtle,
|
||||||
|
focusedBorderColor: theme.primary,
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { select, tabSelect, input, textarea: input, box };
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { parse as parseYAML } from "yaml";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { notify } from "./context/notifications.ts";
|
||||||
|
|
||||||
|
export interface ProjectConfigOptions<TDefault, TUser> {
|
||||||
|
projectName: string;
|
||||||
|
defaultConfigPath: string;
|
||||||
|
defaultSchema: z.ZodType<TDefault>;
|
||||||
|
userSchema: z.ZodType<TUser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectConfig<TDefault, TUser> {
|
||||||
|
defaults: TDefault;
|
||||||
|
user: TUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userConfigPath(projectName: string): string {
|
||||||
|
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
||||||
|
return join(configHome, projectName, "config.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readYaml(path: string): unknown {
|
||||||
|
return parseYAML(readFileSync(path, "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function warn(projectName: string, path: string, message: string): void {
|
||||||
|
const text = `${path}: ${message}`;
|
||||||
|
console.error(`[${projectName} config] ${text}`);
|
||||||
|
notify(text, { kind: "warning", duration: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadProjectConfig<TDefault, TUser>(
|
||||||
|
options: ProjectConfigOptions<TDefault, TUser>,
|
||||||
|
): ProjectConfig<TDefault, TUser> {
|
||||||
|
const { projectName, defaultConfigPath, defaultSchema, userSchema } = options;
|
||||||
|
|
||||||
|
let rawDefaults: unknown;
|
||||||
|
try {
|
||||||
|
rawDefaults = readYaml(defaultConfigPath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to read ${defaultConfigPath}: ${error instanceof Error ? error.message : error}`);
|
||||||
|
}
|
||||||
|
const defaultsResult = defaultSchema.safeParse(rawDefaults);
|
||||||
|
if (!defaultsResult.success) {
|
||||||
|
throw new Error(`${defaultConfigPath} is invalid:\n${z.prettifyError(defaultsResult.error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = userConfigPath(projectName);
|
||||||
|
const user = ((): TUser => {
|
||||||
|
if (!existsSync(path)) return {} as TUser;
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = readYaml(path);
|
||||||
|
} catch (error) {
|
||||||
|
warn(projectName, path, error instanceof Error ? error.message : String(error));
|
||||||
|
return {} as TUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = userSchema.safeParse(raw ?? {});
|
||||||
|
if (!result.success) {
|
||||||
|
warn(projectName, path, z.prettifyError(result.error));
|
||||||
|
return {} as TUser;
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { defaults: defaultsResult.data, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keybindsSchema<TName extends string>(
|
||||||
|
names: readonly TName[],
|
||||||
|
required: true,
|
||||||
|
): z.ZodObject<Record<TName, z.ZodString>>;
|
||||||
|
export function keybindsSchema<TName extends string>(
|
||||||
|
names: readonly TName[],
|
||||||
|
required: false,
|
||||||
|
): z.ZodObject<Record<TName, z.ZodOptional<z.ZodString>>>;
|
||||||
|
export function keybindsSchema<TName extends string>(names: readonly TName[], required: boolean) {
|
||||||
|
const shape = {} as Record<TName, z.ZodTypeAny>;
|
||||||
|
for (const name of names) {
|
||||||
|
shape[name] = required ? z.string().min(1) : z.string().min(1).optional();
|
||||||
|
}
|
||||||
|
return z.object(shape).strict();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeKeybinds<TName extends string>(
|
||||||
|
names: readonly TName[],
|
||||||
|
defaults: Record<TName, string>,
|
||||||
|
user: Partial<Record<TName, string>> | undefined,
|
||||||
|
): Record<TName, string> {
|
||||||
|
const merged = {} as Record<TName, string>;
|
||||||
|
for (const name of names) {
|
||||||
|
merged[name] = user?.[name] ?? defaults[name];
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { extend } from "@opentui/solid";
|
||||||
|
import {
|
||||||
|
ASCIIFontRenderable,
|
||||||
|
BoxRenderable,
|
||||||
|
InputRenderable,
|
||||||
|
SelectRenderable,
|
||||||
|
TabSelectRenderable,
|
||||||
|
TextareaRenderable,
|
||||||
|
TextRenderable,
|
||||||
|
} from "@opentui/core";
|
||||||
|
import { presets, theme } from "./index.ts";
|
||||||
|
|
||||||
|
type AnyCtor = new (...args: any[]) => any;
|
||||||
|
|
||||||
|
function withThemeDefaults<T extends AnyCtor>(Ctor: T, defaults: object, after?: (instance: any) => void): T {
|
||||||
|
class Themed extends Ctor {
|
||||||
|
constructor(...args: any[]) {
|
||||||
|
super(...args);
|
||||||
|
Object.assign(this, defaults);
|
||||||
|
after?.(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Themed as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { borderStyle, borderColor, focusedBorderColor } = presets.box;
|
||||||
|
const boxDefaults = { borderStyle, borderColor, focusedBorderColor };
|
||||||
|
|
||||||
|
extend({
|
||||||
|
box: withThemeDefaults(BoxRenderable, boxDefaults, (instance) => {
|
||||||
|
instance.border = false;
|
||||||
|
}),
|
||||||
|
select: withThemeDefaults(SelectRenderable, presets.select),
|
||||||
|
tab_select: withThemeDefaults(TabSelectRenderable, presets.tabSelect),
|
||||||
|
input: withThemeDefaults(InputRenderable, presets.input),
|
||||||
|
textarea: withThemeDefaults(TextareaRenderable, presets.textarea),
|
||||||
|
text: withThemeDefaults(TextRenderable, { fg: theme.text }),
|
||||||
|
ascii_font: withThemeDefaults(ASCIIFontRenderable, { color: theme.primary }),
|
||||||
|
});
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { parse as parseYAML } from "yaml";
|
||||||
|
|
||||||
|
export function configPath(): string {
|
||||||
|
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
||||||
|
return join(configDir, "ilovetui", "config.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readYamlFile<T>(path: string): T | undefined {
|
||||||
|
if (!existsSync(path)) return undefined;
|
||||||
|
try {
|
||||||
|
return (parseYAML(readFileSync(path, "utf8")) as T) ?? undefined;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`ilovetui: failed to parse ${path}, ignoring it.`, err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# style
|
|
||||||
|
|
||||||
Shared Base16 theming for bubbletea/lipgloss TUIs. Loaded automatically on import from
|
|
||||||
`~/.config/ilovetui/config.yaml` (embedded default as fallback), exposed as the package-level
|
|
||||||
`style.S`.
|
|
||||||
|
|
||||||
- `S.NerdFonts` and `S.BorderType` come from the same config as the colors. This package has no
|
|
||||||
icon registry: components that want icons read `NerdFonts` and pick their own glyphs.
|
|
||||||
- `RenderWithTitle` renders a bordered box with a title embedded in the top border, following
|
|
||||||
`S.BorderType`.
|
|
||||||
- Never imports `charm.land/bubbles/v2/*`. Anything that needs to know about a specific component
|
|
||||||
belongs in `bubbles/` instead.
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
Copy [`default.yaml`](default.yaml) to `~/.config/ilovetui/config.yaml` and edit it.
|
|
||||||
|
|
||||||
See `examples/style`.
|
|
||||||
-117
@@ -1,117 +0,0 @@
|
|||||||
package style
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"github.com/charmbracelet/x/ansi"
|
|
||||||
)
|
|
||||||
|
|
||||||
var borderTypes = map[string]lipgloss.Border{
|
|
||||||
"rounded": lipgloss.RoundedBorder(),
|
|
||||||
"normal": lipgloss.NormalBorder(),
|
|
||||||
"thick": lipgloss.ThickBorder(),
|
|
||||||
"double": lipgloss.DoubleBorder(),
|
|
||||||
"hidden": lipgloss.HiddenBorder(),
|
|
||||||
"ascii": lipgloss.ASCIIBorder(),
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveBorderType(name string) lipgloss.Border {
|
|
||||||
if b, ok := borderTypes[strings.ToLower(strings.TrimSpace(name))]; ok {
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
return lipgloss.RoundedBorder()
|
|
||||||
}
|
|
||||||
|
|
||||||
type LayoutBorderMode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
LayoutBorderFull LayoutBorderMode = "full"
|
|
||||||
LayoutBorderSidebar LayoutBorderMode = "sidebar"
|
|
||||||
LayoutBorderSeparator LayoutBorderMode = "separator"
|
|
||||||
)
|
|
||||||
|
|
||||||
func resolveLayoutBorder(name string) LayoutBorderMode {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
||||||
case string(LayoutBorderSidebar):
|
|
||||||
return LayoutBorderSidebar
|
|
||||||
case string(LayoutBorderSeparator):
|
|
||||||
return LayoutBorderSeparator
|
|
||||||
default:
|
|
||||||
return LayoutBorderFull
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ContentHeight(totalH int) int {
|
|
||||||
h := totalH - 2
|
|
||||||
if h < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
|
|
||||||
boxH := height - 1
|
|
||||||
if contentH := boxH - 1; contentH > 0 {
|
|
||||||
lines := strings.Split(content, "\n")
|
|
||||||
if len(lines) > contentH {
|
|
||||||
content = strings.Join(lines[:contentH], "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
|
|
||||||
|
|
||||||
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
|
|
||||||
|
|
||||||
b, _, _, _, _ := border.GetBorder()
|
|
||||||
topLeft, top, topRight := b.TopLeft, b.Top, b.TopRight
|
|
||||||
overhead := lipgloss.Width(topLeft) + lipgloss.Width(topRight) + 2
|
|
||||||
|
|
||||||
maxTitleW := max(boxWidth-overhead, 0)
|
|
||||||
if titleW := lipgloss.Width(title); titleW > maxTitleW {
|
|
||||||
title = ansi.Truncate(title, maxTitleW, "")
|
|
||||||
}
|
|
||||||
titleW := lipgloss.Width(title)
|
|
||||||
|
|
||||||
fillW := boxWidth - titleW - overhead
|
|
||||||
if fillW < 0 {
|
|
||||||
fillW = 0
|
|
||||||
}
|
|
||||||
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
|
|
||||||
topLine := bc.Render(topLeft+" ") + bc.Render(title) + bc.Render(" "+strings.Repeat(top, fillW)+topRight)
|
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RenderPlain(border lipgloss.Style, title, content string, width, height int) string {
|
|
||||||
if titleW := lipgloss.Width(title); titleW > width {
|
|
||||||
title = ansi.Truncate(title, width, "")
|
|
||||||
}
|
|
||||||
titleLine := lipgloss.NewStyle().
|
|
||||||
Bold(true).
|
|
||||||
Foreground(border.GetBorderTopForeground()).
|
|
||||||
Width(width).
|
|
||||||
Render(title)
|
|
||||||
|
|
||||||
contentH := max(height-1, 0)
|
|
||||||
lines := strings.Split(content, "\n")
|
|
||||||
if len(lines) > contentH {
|
|
||||||
content = strings.Join(lines[:contentH], "\n")
|
|
||||||
}
|
|
||||||
box := lipgloss.NewStyle().Width(width).Height(contentH).Render(content)
|
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, titleLine, box)
|
|
||||||
}
|
|
||||||
|
|
||||||
func VerticalDivider(border lipgloss.Style, height int) string {
|
|
||||||
b, _, _, _, _ := border.GetBorder()
|
|
||||||
ch := b.Left
|
|
||||||
if ch == "" {
|
|
||||||
ch = "│"
|
|
||||||
}
|
|
||||||
line := lipgloss.NewStyle().Foreground(border.GetBorderLeftForeground()).Render(ch)
|
|
||||||
rows := make([]string, max(height, 0))
|
|
||||||
for i := range rows {
|
|
||||||
rows[i] = line
|
|
||||||
}
|
|
||||||
return strings.Join(rows, "\n")
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package style
|
|
||||||
|
|
||||||
type colorsYAML struct {
|
|
||||||
Base00 string `yaml:"base00"`
|
|
||||||
Base01 string `yaml:"base01"`
|
|
||||||
Base02 string `yaml:"base02"`
|
|
||||||
Base03 string `yaml:"base03"`
|
|
||||||
Base04 string `yaml:"base04"`
|
|
||||||
Base05 string `yaml:"base05"`
|
|
||||||
Base06 string `yaml:"base06"`
|
|
||||||
Base07 string `yaml:"base07"`
|
|
||||||
Base08 string `yaml:"base08"`
|
|
||||||
Base09 string `yaml:"base09"`
|
|
||||||
Base0A string `yaml:"base0a"`
|
|
||||||
Base0B string `yaml:"base0b"`
|
|
||||||
Base0C string `yaml:"base0c"`
|
|
||||||
Base0D string `yaml:"base0d"`
|
|
||||||
Base0E string `yaml:"base0e"`
|
|
||||||
Base0F string `yaml:"base0f"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type configYAML struct {
|
|
||||||
Colors colorsYAML `yaml:"colors"`
|
|
||||||
NerdFonts bool `yaml:"nerd_fonts"`
|
|
||||||
Border string `yaml:"border"`
|
|
||||||
LayoutBorder string `yaml:"layout_border"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func pickString(base, user string) string {
|
|
||||||
if user != "" {
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeConfig(base, user configYAML) configYAML {
|
|
||||||
return configYAML{
|
|
||||||
Colors: mergeColors(base.Colors, user.Colors),
|
|
||||||
|
|
||||||
NerdFonts: base.NerdFonts || user.NerdFonts,
|
|
||||||
Border: pickString(base.Border, user.Border),
|
|
||||||
LayoutBorder: pickString(base.LayoutBorder, user.LayoutBorder),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeColors(base, user colorsYAML) colorsYAML {
|
|
||||||
return colorsYAML{
|
|
||||||
Base00: pickString(base.Base00, user.Base00),
|
|
||||||
Base01: pickString(base.Base01, user.Base01),
|
|
||||||
Base02: pickString(base.Base02, user.Base02),
|
|
||||||
Base03: pickString(base.Base03, user.Base03),
|
|
||||||
Base04: pickString(base.Base04, user.Base04),
|
|
||||||
Base05: pickString(base.Base05, user.Base05),
|
|
||||||
Base06: pickString(base.Base06, user.Base06),
|
|
||||||
Base07: pickString(base.Base07, user.Base07),
|
|
||||||
Base08: pickString(base.Base08, user.Base08),
|
|
||||||
Base09: pickString(base.Base09, user.Base09),
|
|
||||||
Base0A: pickString(base.Base0A, user.Base0A),
|
|
||||||
Base0B: pickString(base.Base0B, user.Base0B),
|
|
||||||
Base0C: pickString(base.Base0C, user.Base0C),
|
|
||||||
Base0D: pickString(base.Base0D, user.Base0D),
|
|
||||||
Base0E: pickString(base.Base0E, user.Base0E),
|
|
||||||
Base0F: pickString(base.Base0F, user.Base0F),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
package style
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"charm.land/glamour/v2/ansi"
|
|
||||||
)
|
|
||||||
|
|
||||||
func hexColor(c color.Color) *string {
|
|
||||||
r, g, b, _ := c.RGBA()
|
|
||||||
s := fmt.Sprintf("#%02X%02X%02X", r>>8, g>>8, b>>8)
|
|
||||||
return &s
|
|
||||||
}
|
|
||||||
|
|
||||||
func GlamourStyleConfig() ansi.StyleConfig {
|
|
||||||
str := func(s string) *string { return &s }
|
|
||||||
boolPtr := func(b bool) *bool { return &b }
|
|
||||||
uintPtr := func(u uint) *uint { return &u }
|
|
||||||
hex := hexColor
|
|
||||||
|
|
||||||
return ansi.StyleConfig{
|
|
||||||
Document: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
BlockPrefix: "\n",
|
|
||||||
BlockSuffix: "\n",
|
|
||||||
Color: hex(S.Base05),
|
|
||||||
},
|
|
||||||
Margin: uintPtr(2),
|
|
||||||
},
|
|
||||||
BlockQuote: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base03),
|
|
||||||
Italic: boolPtr(true),
|
|
||||||
},
|
|
||||||
Indent: uintPtr(1),
|
|
||||||
IndentToken: str("│ "),
|
|
||||||
},
|
|
||||||
List: ansi.StyleList{
|
|
||||||
LevelIndent: 2,
|
|
||||||
},
|
|
||||||
Heading: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
BlockSuffix: "\n",
|
|
||||||
Color: hex(S.Base0D),
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H1: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: " ",
|
|
||||||
Suffix: " ",
|
|
||||||
Color: hex(S.Base07),
|
|
||||||
BackgroundColor: hex(S.Base0D),
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H2: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: "## ",
|
|
||||||
Color: hex(S.Base0D),
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H3: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: "### ",
|
|
||||||
Color: hex(S.Base0C),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H4: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: "#### ",
|
|
||||||
Color: hex(S.Base0B),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H5: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: "##### ",
|
|
||||||
Color: hex(S.Base09),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H6: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: "###### ",
|
|
||||||
Color: hex(S.Base08),
|
|
||||||
Bold: boolPtr(false),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Strikethrough: ansi.StylePrimitive{
|
|
||||||
CrossedOut: boolPtr(true),
|
|
||||||
},
|
|
||||||
Emph: ansi.StylePrimitive{
|
|
||||||
Italic: boolPtr(true),
|
|
||||||
},
|
|
||||||
Strong: ansi.StylePrimitive{
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
HorizontalRule: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base03),
|
|
||||||
Format: "\n--------\n",
|
|
||||||
},
|
|
||||||
Item: ansi.StylePrimitive{
|
|
||||||
BlockPrefix: "• ",
|
|
||||||
},
|
|
||||||
Enumeration: ansi.StylePrimitive{
|
|
||||||
BlockPrefix: ". ",
|
|
||||||
},
|
|
||||||
Task: ansi.StyleTask{
|
|
||||||
Ticked: "[✓] ",
|
|
||||||
Unticked: "[ ] ",
|
|
||||||
},
|
|
||||||
Link: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0C),
|
|
||||||
Underline: boolPtr(true),
|
|
||||||
},
|
|
||||||
LinkText: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0D),
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
Image: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0C),
|
|
||||||
Underline: boolPtr(true),
|
|
||||||
},
|
|
||||||
ImageText: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base04),
|
|
||||||
Format: "Image: {{.text}} ->",
|
|
||||||
},
|
|
||||||
Code: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: " ",
|
|
||||||
Suffix: " ",
|
|
||||||
Color: hex(S.Base0B),
|
|
||||||
BackgroundColor: hex(S.Base01),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CodeBlock: ansi.StyleCodeBlock{
|
|
||||||
StyleBlock: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base04),
|
|
||||||
},
|
|
||||||
Margin: uintPtr(2),
|
|
||||||
},
|
|
||||||
Chroma: &ansi.Chroma{
|
|
||||||
Text: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base05),
|
|
||||||
},
|
|
||||||
Error: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base07),
|
|
||||||
BackgroundColor: hex(S.Base08),
|
|
||||||
},
|
|
||||||
Comment: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base03),
|
|
||||||
Italic: boolPtr(true),
|
|
||||||
},
|
|
||||||
CommentPreproc: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base09),
|
|
||||||
},
|
|
||||||
Keyword: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0E),
|
|
||||||
},
|
|
||||||
KeywordReserved: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0E),
|
|
||||||
},
|
|
||||||
KeywordNamespace: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0D),
|
|
||||||
},
|
|
||||||
KeywordType: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0A),
|
|
||||||
},
|
|
||||||
Operator: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base05),
|
|
||||||
},
|
|
||||||
Punctuation: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base05),
|
|
||||||
},
|
|
||||||
Name: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base05),
|
|
||||||
},
|
|
||||||
NameBuiltin: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0D),
|
|
||||||
},
|
|
||||||
NameTag: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base08),
|
|
||||||
},
|
|
||||||
NameAttribute: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0A),
|
|
||||||
},
|
|
||||||
NameClass: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0A),
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
Underline: boolPtr(true),
|
|
||||||
},
|
|
||||||
NameConstant: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base09),
|
|
||||||
},
|
|
||||||
NameDecorator: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0C),
|
|
||||||
},
|
|
||||||
NameFunction: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0D),
|
|
||||||
},
|
|
||||||
LiteralNumber: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base09),
|
|
||||||
},
|
|
||||||
LiteralString: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0B),
|
|
||||||
},
|
|
||||||
LiteralStringEscape: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0C),
|
|
||||||
},
|
|
||||||
GenericDeleted: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base08),
|
|
||||||
},
|
|
||||||
GenericEmph: ansi.StylePrimitive{
|
|
||||||
Italic: boolPtr(true),
|
|
||||||
},
|
|
||||||
GenericInserted: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base0B),
|
|
||||||
},
|
|
||||||
GenericStrong: ansi.StylePrimitive{
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
GenericSubheading: ansi.StylePrimitive{
|
|
||||||
Color: hex(S.Base04),
|
|
||||||
},
|
|
||||||
Background: ansi.StylePrimitive{
|
|
||||||
BackgroundColor: hex(S.Base01),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Table: ansi.StyleTable{
|
|
||||||
StyleBlock: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
DefinitionDescription: ansi.StylePrimitive{
|
|
||||||
BlockPrefix: "\n> ",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package style
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/x/ansi"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Overlay(base, top string, x, y int) string {
|
|
||||||
baseLines := strings.Split(base, "\n")
|
|
||||||
topLines := strings.Split(top, "\n")
|
|
||||||
|
|
||||||
for i, tl := range topLines {
|
|
||||||
row := y + i
|
|
||||||
if row < 0 || row >= len(baseLines) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
bl := baseLines[row]
|
|
||||||
tw := ansi.StringWidth(tl)
|
|
||||||
left := ansi.Cut(bl, 0, x)
|
|
||||||
right := ansi.Cut(bl, x+tw, ansi.StringWidth(bl))
|
|
||||||
baseLines[row] = left + tl + right
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(baseLines, "\n")
|
|
||||||
}
|
|
||||||
-117
@@ -1,117 +0,0 @@
|
|||||||
package style
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Styles struct {
|
|
||||||
Base00 color.Color
|
|
||||||
Base01 color.Color
|
|
||||||
Base02 color.Color
|
|
||||||
Base03 color.Color
|
|
||||||
Base04 color.Color
|
|
||||||
Base05 color.Color
|
|
||||||
Base06 color.Color
|
|
||||||
Base07 color.Color
|
|
||||||
Base08 color.Color
|
|
||||||
Base09 color.Color
|
|
||||||
Base0A color.Color
|
|
||||||
Base0B color.Color
|
|
||||||
Base0C color.Color
|
|
||||||
Base0D color.Color
|
|
||||||
Base0E color.Color
|
|
||||||
Base0F color.Color
|
|
||||||
|
|
||||||
Background color.Color
|
|
||||||
SubtleBg color.Color
|
|
||||||
Selection color.Color
|
|
||||||
Subtle color.Color
|
|
||||||
Muted color.Color
|
|
||||||
Text color.Color
|
|
||||||
Primary color.Color
|
|
||||||
Success color.Color
|
|
||||||
Warning color.Color
|
|
||||||
Error color.Color
|
|
||||||
|
|
||||||
Bold lipgloss.Style
|
|
||||||
Faint lipgloss.Style
|
|
||||||
|
|
||||||
NerdFonts bool
|
|
||||||
BorderType lipgloss.Border
|
|
||||||
LayoutBorder LayoutBorderMode
|
|
||||||
|
|
||||||
Panel lipgloss.Style
|
|
||||||
PanelFocused lipgloss.Style
|
|
||||||
|
|
||||||
PagerDotActive string
|
|
||||||
PagerDotInactive string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newStyles(c colorsYAML, nerdFonts bool, borderName, layoutBorderName string) Styles {
|
|
||||||
lc := func(s string) color.Color {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s != "" && s[0] != '#' {
|
|
||||||
s = "#" + s
|
|
||||||
}
|
|
||||||
return lipgloss.Color(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
b00 := lc(c.Base00)
|
|
||||||
b01 := lc(c.Base01)
|
|
||||||
b02 := lc(c.Base02)
|
|
||||||
b03 := lc(c.Base03)
|
|
||||||
b04 := lc(c.Base04)
|
|
||||||
b05 := lc(c.Base05)
|
|
||||||
b06 := lc(c.Base06)
|
|
||||||
b07 := lc(c.Base07)
|
|
||||||
b08 := lc(c.Base08)
|
|
||||||
b09 := lc(c.Base09)
|
|
||||||
b0A := lc(c.Base0A)
|
|
||||||
b0B := lc(c.Base0B)
|
|
||||||
b0C := lc(c.Base0C)
|
|
||||||
b0D := lc(c.Base0D)
|
|
||||||
b0E := lc(c.Base0E)
|
|
||||||
b0F := lc(c.Base0F)
|
|
||||||
|
|
||||||
borderType := resolveBorderType(borderName)
|
|
||||||
layoutBorder := resolveLayoutBorder(layoutBorderName)
|
|
||||||
|
|
||||||
return Styles{
|
|
||||||
Base00: b00, Base01: b01, Base02: b02, Base03: b03,
|
|
||||||
Base04: b04, Base05: b05, Base06: b06, Base07: b07,
|
|
||||||
Base08: b08, Base09: b09, Base0A: b0A, Base0B: b0B,
|
|
||||||
Base0C: b0C, Base0D: b0D, Base0E: b0E, Base0F: b0F,
|
|
||||||
|
|
||||||
Background: b00,
|
|
||||||
SubtleBg: b01,
|
|
||||||
Selection: b02,
|
|
||||||
Subtle: b03,
|
|
||||||
Muted: b04,
|
|
||||||
Text: b05,
|
|
||||||
Primary: b0D,
|
|
||||||
Success: b0B,
|
|
||||||
Warning: b09,
|
|
||||||
Error: b08,
|
|
||||||
|
|
||||||
Bold: lipgloss.NewStyle().Bold(true),
|
|
||||||
Faint: lipgloss.NewStyle().Foreground(b03).Faint(true),
|
|
||||||
|
|
||||||
NerdFonts: nerdFonts,
|
|
||||||
BorderType: borderType,
|
|
||||||
LayoutBorder: layoutBorder,
|
|
||||||
|
|
||||||
Panel: lipgloss.NewStyle().
|
|
||||||
Border(borderType).
|
|
||||||
BorderForeground(b03),
|
|
||||||
|
|
||||||
PanelFocused: lipgloss.NewStyle().
|
|
||||||
Border(borderType).
|
|
||||||
BorderForeground(b0D),
|
|
||||||
|
|
||||||
PagerDotActive: lipgloss.NewStyle().Foreground(b0D).SetString("•").String(),
|
|
||||||
PagerDotInactive: lipgloss.NewStyle().Foreground(b03).SetString("•").String(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package style
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed default.yaml
|
|
||||||
var DefaultConfig []byte
|
|
||||||
|
|
||||||
var S Styles
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
path := DefaultConfigPath()
|
|
||||||
if data, err := os.ReadFile(path); err == nil {
|
|
||||||
if s, err := stylesFromBytes(data); err == nil {
|
|
||||||
S = s
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s, _ := stylesFromBytes(DefaultConfig)
|
|
||||||
S = s
|
|
||||||
}
|
|
||||||
|
|
||||||
func Init() error {
|
|
||||||
path := DefaultConfigPath()
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
s, e := stylesFromBytes(DefaultConfig)
|
|
||||||
if e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
S = s
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return InitFromBytes(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func InitFrom(path string) error {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("style: read config: %w", err)
|
|
||||||
}
|
|
||||||
return InitFromBytes(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func InitFromBytes(data []byte) error {
|
|
||||||
s, err := stylesFromBytes(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
S = s
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultConfigPath() string {
|
|
||||||
return filepath.Join(configDir(), "ilovetui", "config.yaml")
|
|
||||||
}
|
|
||||||
|
|
||||||
func stylesFromBytes(data []byte) (Styles, error) {
|
|
||||||
var base configYAML
|
|
||||||
if err := yaml.Unmarshal(DefaultConfig, &base); err != nil {
|
|
||||||
return Styles{}, fmt.Errorf("style: parse default config: %w", err)
|
|
||||||
}
|
|
||||||
var user configYAML
|
|
||||||
if err := yaml.Unmarshal(data, &user); err != nil {
|
|
||||||
return Styles{}, fmt.Errorf("style: parse config: %w", err)
|
|
||||||
}
|
|
||||||
merged := mergeConfig(base, user)
|
|
||||||
return newStyles(merged.Colors, merged.NerdFonts, merged.Border, merged.LayoutBorder), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func configDir() string {
|
|
||||||
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
return filepath.Join(home, ".config")
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# tabs
|
|
||||||
|
|
||||||
A horizontal tab bar styled from `style.S`. Renders the bar and the `Content` frame around the
|
|
||||||
active item; the content itself runs and renders through the host, same as any other custom
|
|
||||||
component in this repo.
|
|
||||||
|
|
||||||
- `Focused()` controls the frame's border color, independent of which tab is active (that's shown
|
|
||||||
only by the title style).
|
|
||||||
- Tabs collapse into a trailing `+N` badge when they don't fit `Width`, keeping the active one
|
|
||||||
always visible.
|
|
||||||
- `WithLoop(false)` clamps navigation at either end instead of wrapping.
|
|
||||||
- The frame follows `style.S.BorderType`: corners and junctions are derived from the border's own
|
|
||||||
glyphs, not hardcoded.
|
|
||||||
|
|
||||||
See `examples/tabs`.
|
|
||||||
-454
@@ -1,454 +0,0 @@
|
|||||||
package tabs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"image/color"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Tab interface {
|
|
||||||
Init() tea.Cmd
|
|
||||||
Update(tea.Msg) (Tab, tea.Cmd)
|
|
||||||
View() string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Item struct {
|
|
||||||
Title string
|
|
||||||
Model Tab
|
|
||||||
}
|
|
||||||
|
|
||||||
type KeyMap struct {
|
|
||||||
Next key.Binding
|
|
||||||
Prev key.Binding
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultKeyMap() KeyMap {
|
|
||||||
return KeyMap{
|
|
||||||
Next: key.NewBinding(
|
|
||||||
key.WithKeys("right", "l", "tab"),
|
|
||||||
key.WithHelp("→/tab", "next tab"),
|
|
||||||
),
|
|
||||||
Prev: key.NewBinding(
|
|
||||||
key.WithKeys("left", "h", "shift+tab"),
|
|
||||||
key.WithHelp("←/shift+tab", "previous tab"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Styles struct {
|
|
||||||
ActiveTab lipgloss.Style
|
|
||||||
InactiveTab lipgloss.Style
|
|
||||||
|
|
||||||
ActiveTitle lipgloss.Style
|
|
||||||
InactiveTitle lipgloss.Style
|
|
||||||
|
|
||||||
Content lipgloss.Style
|
|
||||||
|
|
||||||
BorderType lipgloss.Border
|
|
||||||
|
|
||||||
FocusedBorder color.Color
|
|
||||||
BlurredBorder color.Color
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultStyles() Styles {
|
|
||||||
bt := style.S.BorderType
|
|
||||||
inactiveBorder, activeBorder := tabBorders(bt)
|
|
||||||
|
|
||||||
return Styles{
|
|
||||||
InactiveTab: lipgloss.NewStyle().
|
|
||||||
Border(inactiveBorder, true).
|
|
||||||
Padding(0, 1),
|
|
||||||
ActiveTab: lipgloss.NewStyle().
|
|
||||||
Border(activeBorder, true).
|
|
||||||
Padding(0, 1),
|
|
||||||
|
|
||||||
ActiveTitle: lipgloss.NewStyle().Foreground(style.S.Primary).Bold(true),
|
|
||||||
InactiveTitle: lipgloss.NewStyle().Foreground(style.S.Subtle),
|
|
||||||
|
|
||||||
Content: lipgloss.NewStyle().
|
|
||||||
Border(bt).
|
|
||||||
UnsetBorderTop().
|
|
||||||
Padding(1, 2),
|
|
||||||
|
|
||||||
BorderType: bt,
|
|
||||||
|
|
||||||
FocusedBorder: style.S.Primary,
|
|
||||||
BlurredBorder: style.S.Subtle,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func tabBorders(bt lipgloss.Border) (inactive, active lipgloss.Border) {
|
|
||||||
inactive = bt
|
|
||||||
inactive.BottomLeft = bt.MiddleBottom
|
|
||||||
inactive.BottomRight = bt.MiddleBottom
|
|
||||||
|
|
||||||
active = bt
|
|
||||||
active.Bottom = " "
|
|
||||||
active.BottomLeft = bt.BottomRight
|
|
||||||
active.BottomRight = bt.BottomLeft
|
|
||||||
|
|
||||||
return inactive, active
|
|
||||||
}
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
items []Item
|
|
||||||
active int
|
|
||||||
focused bool
|
|
||||||
loop bool
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
|
|
||||||
styles Styles
|
|
||||||
keyMap KeyMap
|
|
||||||
}
|
|
||||||
|
|
||||||
type Option func(*Model)
|
|
||||||
|
|
||||||
func WithStyles(s Styles) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.styles = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithKeyMap(k KeyMap) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.keyMap = k
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithActive(i int) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.active = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithLoop(l bool) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.loop = l
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithFocus(f bool) Option {
|
|
||||||
return func(m *Model) {
|
|
||||||
m.focused = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(items []Item, opts ...Option) Model {
|
|
||||||
m := Model{
|
|
||||||
items: items,
|
|
||||||
focused: true,
|
|
||||||
loop: true,
|
|
||||||
styles: DefaultStyles(),
|
|
||||||
keyMap: DefaultKeyMap(),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&m)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.active = clamp(m.active, 0, len(m.items)-1)
|
|
||||||
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
|
||||||
cmds := make([]tea.Cmd, len(m.items))
|
|
||||||
for i, item := range m.items {
|
|
||||||
cmds[i] = item.Model.Init()
|
|
||||||
}
|
|
||||||
return tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Active() int {
|
|
||||||
return m.active
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) SetActive(i int) {
|
|
||||||
m.active = clamp(i, 0, len(m.items)-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Items() []Item {
|
|
||||||
return m.items
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) ActiveItem() Item {
|
|
||||||
return m.items[m.active]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Focused() bool {
|
|
||||||
return m.focused
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) Focus() {
|
|
||||||
m.focused = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) Blur() {
|
|
||||||
m.focused = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Loop() bool {
|
|
||||||
return m.loop
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) SetLoop(l bool) {
|
|
||||||
m.loop = l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Width() int {
|
|
||||||
return m.width
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) SetWidth(w int) {
|
|
||||||
m.width = w
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Height() int {
|
|
||||||
return m.height
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) SetHeight(h int) {
|
|
||||||
m.height = h
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) SetSize(w, h int) {
|
|
||||||
m.width = w
|
|
||||||
m.height = h
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) ContentWidth() int {
|
|
||||||
if len(m.items) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
w := lipgloss.Width(m.renderBar(m.collapsedSegments(m.width), m.styles.BlurredBorder, false))
|
|
||||||
if m.width > w {
|
|
||||||
w = m.width
|
|
||||||
}
|
|
||||||
if inner := w - m.styles.Content.GetHorizontalFrameSize(); inner > 0 {
|
|
||||||
return inner
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) ContentHeight() int {
|
|
||||||
if len(m.items) == 0 || m.height <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
barHeight := lipgloss.Height(m.renderBar(m.collapsedSegments(m.width), m.styles.BlurredBorder, false))
|
|
||||||
if inner := m.height - barHeight - m.styles.Content.GetVerticalFrameSize(); inner > 0 {
|
|
||||||
return inner
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|
||||||
if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
|
|
||||||
switch {
|
|
||||||
case key.Matches(keyMsg, m.keyMap.Next):
|
|
||||||
m.active = m.step(1)
|
|
||||||
return m, nil
|
|
||||||
case key.Matches(keyMsg, m.keyMap.Prev):
|
|
||||||
m.active = m.step(-1)
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(m.items) == 0 {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.items[m.active].Model, cmd = m.items[m.active].Model.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
type tabSegment struct {
|
|
||||||
title string
|
|
||||||
isActive bool
|
|
||||||
isMore bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) segments() []tabSegment {
|
|
||||||
segs := make([]tabSegment, len(m.items))
|
|
||||||
for i, item := range m.items {
|
|
||||||
segs[i] = tabSegment{title: item.Title, isActive: i == m.active}
|
|
||||||
}
|
|
||||||
return segs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) segmentWidth(seg tabSegment) int {
|
|
||||||
tabStyle := m.styles.InactiveTab
|
|
||||||
titleStyle := m.styles.InactiveTitle
|
|
||||||
if seg.isActive {
|
|
||||||
tabStyle = m.styles.ActiveTab
|
|
||||||
titleStyle = m.styles.ActiveTitle
|
|
||||||
}
|
|
||||||
return lipgloss.Width(tabStyle.Render(titleStyle.Render(seg.title)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) collapsedSegments(budget int) []tabSegment {
|
|
||||||
segs := m.segments()
|
|
||||||
if budget <= 0 {
|
|
||||||
return segs
|
|
||||||
}
|
|
||||||
|
|
||||||
widths := make([]int, len(segs))
|
|
||||||
total := 0
|
|
||||||
for i, seg := range segs {
|
|
||||||
widths[i] = m.segmentWidth(seg)
|
|
||||||
total += widths[i]
|
|
||||||
}
|
|
||||||
if total <= budget {
|
|
||||||
return segs
|
|
||||||
}
|
|
||||||
|
|
||||||
moreWidth := m.segmentWidth(tabSegment{title: fmt.Sprintf("+%d", len(segs)-1), isMore: true})
|
|
||||||
fitBudget := budget - moreWidth
|
|
||||||
if fitBudget < widths[m.active] {
|
|
||||||
fitBudget = widths[m.active]
|
|
||||||
}
|
|
||||||
|
|
||||||
start, end := m.active, m.active
|
|
||||||
used := widths[m.active]
|
|
||||||
for {
|
|
||||||
grew := false
|
|
||||||
if start > 0 && used+widths[start-1] <= fitBudget {
|
|
||||||
start--
|
|
||||||
used += widths[start]
|
|
||||||
grew = true
|
|
||||||
}
|
|
||||||
if end < len(segs)-1 && used+widths[end+1] <= fitBudget {
|
|
||||||
end++
|
|
||||||
used += widths[end]
|
|
||||||
grew = true
|
|
||||||
}
|
|
||||||
if !grew {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hidden := len(segs) - (end - start + 1)
|
|
||||||
if hidden <= 0 {
|
|
||||||
return segs
|
|
||||||
}
|
|
||||||
|
|
||||||
visible := append([]tabSegment{}, segs[start:end+1]...)
|
|
||||||
visible = append(visible, tabSegment{title: fmt.Sprintf("+%d", hidden), isMore: true})
|
|
||||||
return visible
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View() string {
|
|
||||||
if len(m.items) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
borderColor := m.styles.BlurredBorder
|
|
||||||
if m.focused {
|
|
||||||
borderColor = m.styles.FocusedBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
segs := m.collapsedSegments(m.width)
|
|
||||||
|
|
||||||
bar := m.renderBar(segs, borderColor, false)
|
|
||||||
contentWidth := lipgloss.Width(bar)
|
|
||||||
|
|
||||||
if m.width > contentWidth {
|
|
||||||
bar = m.extendBarCap(m.renderBar(segs, borderColor, true), m.width, borderColor)
|
|
||||||
contentWidth = m.width
|
|
||||||
}
|
|
||||||
contentStyle := m.styles.Content.
|
|
||||||
BorderForeground(borderColor).
|
|
||||||
Width(contentWidth)
|
|
||||||
if m.height > 0 {
|
|
||||||
if h := m.height - lipgloss.Height(bar); h > 0 {
|
|
||||||
contentStyle = contentStyle.Height(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
content := contentStyle.Render(m.items[m.active].Model.View())
|
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, bar, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderBar(segs []tabSegment, borderColor color.Color, extendCap bool) string {
|
|
||||||
rendered := make([]string, len(segs))
|
|
||||||
|
|
||||||
for i, seg := range segs {
|
|
||||||
isFirst, isLast := i == 0, i == len(segs)-1
|
|
||||||
|
|
||||||
tabStyle := m.styles.InactiveTab
|
|
||||||
titleStyle := m.styles.InactiveTitle
|
|
||||||
if seg.isActive {
|
|
||||||
tabStyle = m.styles.ActiveTab
|
|
||||||
titleStyle = m.styles.ActiveTitle
|
|
||||||
}
|
|
||||||
tabStyle = tabStyle.BorderForeground(borderColor)
|
|
||||||
|
|
||||||
bt := m.styles.BorderType
|
|
||||||
border, _, _, _, _ := tabStyle.GetBorder()
|
|
||||||
switch {
|
|
||||||
case isFirst && seg.isActive:
|
|
||||||
border.BottomLeft = bt.Left
|
|
||||||
case isFirst && !seg.isActive:
|
|
||||||
border.BottomLeft = bt.MiddleLeft
|
|
||||||
case isLast && seg.isActive && !extendCap:
|
|
||||||
border.BottomRight = bt.Right
|
|
||||||
case isLast && !seg.isActive && !extendCap:
|
|
||||||
border.BottomRight = bt.MiddleRight
|
|
||||||
}
|
|
||||||
|
|
||||||
tabStyle = tabStyle.Border(border)
|
|
||||||
|
|
||||||
rendered[i] = tabStyle.Render(titleStyle.Render(seg.title))
|
|
||||||
}
|
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) extendBarCap(bar string, width int, borderColor color.Color) string {
|
|
||||||
gap := width - lipgloss.Width(bar)
|
|
||||||
if gap <= 0 {
|
|
||||||
return bar
|
|
||||||
}
|
|
||||||
|
|
||||||
bt := m.styles.BorderType
|
|
||||||
fill := lipgloss.NewStyle().
|
|
||||||
Foreground(borderColor).
|
|
||||||
Render(strings.Repeat(bt.Bottom, gap-1) + bt.TopRight)
|
|
||||||
|
|
||||||
lines := strings.Split(bar, "\n")
|
|
||||||
lines[len(lines)-1] += fill
|
|
||||||
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) step(delta int) int {
|
|
||||||
n := len(m.items)
|
|
||||||
if n == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if m.loop {
|
|
||||||
return ((m.active+delta)%n + n) % n
|
|
||||||
}
|
|
||||||
return clamp(m.active+delta, 0, n-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clamp(v, low, high int) int {
|
|
||||||
if high < low {
|
|
||||||
return low
|
|
||||||
}
|
|
||||||
if v < low {
|
|
||||||
return low
|
|
||||||
}
|
|
||||||
if v > high {
|
|
||||||
return high
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "@opentui/solid",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user