charm bracelet init, brb
This commit is contained in:
414
tui.go
Normal file
414
tui.go
Normal file
@@ -0,0 +1,414 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type tuiTheme struct {
|
||||
outer lipgloss.Style
|
||||
badge lipgloss.Style
|
||||
title lipgloss.Style
|
||||
subtitle lipgloss.Style
|
||||
sectionTitle lipgloss.Style
|
||||
body lipgloss.Style
|
||||
muted lipgloss.Style
|
||||
footer lipgloss.Style
|
||||
panel lipgloss.Style
|
||||
row lipgloss.Style
|
||||
rowSelected lipgloss.Style
|
||||
rowKey lipgloss.Style
|
||||
rowKeySelected lipgloss.Style
|
||||
rowHint lipgloss.Style
|
||||
rowHintActive lipgloss.Style
|
||||
statusInfo lipgloss.Style
|
||||
statusWarn lipgloss.Style
|
||||
statusSuccess lipgloss.Style
|
||||
inputLabel lipgloss.Style
|
||||
inputBox lipgloss.Style
|
||||
}
|
||||
|
||||
func newTUITheme() tuiTheme {
|
||||
border := lipgloss.Color("#4B5D6B")
|
||||
text := lipgloss.Color("#E9E1D4")
|
||||
muted := lipgloss.Color("#9E9385")
|
||||
accent := lipgloss.Color("#D97745")
|
||||
accentSoft := lipgloss.Color("#F4D8BD")
|
||||
panel := lipgloss.Color("#192126")
|
||||
panelAlt := lipgloss.Color("#232E36")
|
||||
info := lipgloss.Color("#7FB0C2")
|
||||
warn := lipgloss.Color("#D7A55A")
|
||||
success := lipgloss.Color("#8AAC83")
|
||||
|
||||
return tuiTheme{
|
||||
outer: lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(border).
|
||||
Padding(1, 2),
|
||||
badge: lipgloss.NewStyle().
|
||||
Foreground(panel).
|
||||
Background(accentSoft).
|
||||
Bold(true).
|
||||
Padding(0, 1),
|
||||
title: lipgloss.NewStyle().
|
||||
Foreground(text).
|
||||
Bold(true),
|
||||
subtitle: lipgloss.NewStyle().
|
||||
Foreground(muted),
|
||||
sectionTitle: lipgloss.NewStyle().
|
||||
Foreground(accentSoft).
|
||||
Bold(true).
|
||||
MarginBottom(1),
|
||||
body: lipgloss.NewStyle().
|
||||
Foreground(text),
|
||||
muted: lipgloss.NewStyle().
|
||||
Foreground(muted),
|
||||
footer: lipgloss.NewStyle().
|
||||
Foreground(muted).
|
||||
Border(lipgloss.NormalBorder(), true, false, false, false).
|
||||
BorderForeground(border).
|
||||
PaddingTop(1),
|
||||
panel: lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(border).
|
||||
PaddingLeft(1),
|
||||
row: lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder(), false, false, false, true).
|
||||
BorderForeground(panelAlt).
|
||||
PaddingLeft(1),
|
||||
rowSelected: lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder(), false, false, false, true).
|
||||
BorderForeground(accent).
|
||||
Background(panel).
|
||||
PaddingLeft(1),
|
||||
rowKey: lipgloss.NewStyle().
|
||||
Foreground(accent).
|
||||
Bold(true),
|
||||
rowKeySelected: lipgloss.NewStyle().
|
||||
Foreground(accentSoft).
|
||||
Bold(true),
|
||||
rowHint: lipgloss.NewStyle().
|
||||
Foreground(muted),
|
||||
rowHintActive: lipgloss.NewStyle().
|
||||
Foreground(text),
|
||||
statusInfo: lipgloss.NewStyle().
|
||||
Foreground(info).
|
||||
Bold(true),
|
||||
statusWarn: lipgloss.NewStyle().
|
||||
Foreground(warn).
|
||||
Bold(true),
|
||||
statusSuccess: lipgloss.NewStyle().
|
||||
Foreground(success).
|
||||
Bold(true),
|
||||
inputLabel: lipgloss.NewStyle().
|
||||
Foreground(accentSoft).
|
||||
Bold(true),
|
||||
inputBox: lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(border).
|
||||
Padding(0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func clampFrameWidth(windowWidth int) int {
|
||||
const fallback = 84
|
||||
if windowWidth <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if windowWidth <= 44 {
|
||||
return windowWidth
|
||||
}
|
||||
if windowWidth > 92 {
|
||||
return 92
|
||||
}
|
||||
return windowWidth - 2
|
||||
}
|
||||
|
||||
func innerWidth(frameWidth int) int {
|
||||
if frameWidth <= 10 {
|
||||
return frameWidth
|
||||
}
|
||||
return frameWidth - 6
|
||||
}
|
||||
|
||||
func (t tuiTheme) renderFrame(width int, title, subtitle, body, footer string) string {
|
||||
frameWidth := clampFrameWidth(width)
|
||||
contentWidth := innerWidth(frameWidth)
|
||||
placeWidth := width
|
||||
if placeWidth <= 0 {
|
||||
placeWidth = frameWidth
|
||||
}
|
||||
|
||||
sections := []string{
|
||||
t.badge.Render("SAMBA CONFIGER"),
|
||||
t.title.Width(contentWidth).Render(title),
|
||||
}
|
||||
if subtitle != "" {
|
||||
sections = append(sections, t.subtitle.Width(contentWidth).Render(subtitle))
|
||||
}
|
||||
if body != "" {
|
||||
sections = append(sections, body)
|
||||
}
|
||||
if footer != "" {
|
||||
sections = append(sections, t.footer.Width(contentWidth).Render(footer))
|
||||
}
|
||||
|
||||
return lipgloss.PlaceHorizontal(placeWidth, lipgloss.Center, t.outer.Width(frameWidth).Render(strings.Join(sections, "\n\n")))
|
||||
}
|
||||
|
||||
func (t tuiTheme) renderSection(width int, title string, lines []string) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
contentWidth := innerWidth(clampFrameWidth(width))
|
||||
rendered := make([]string, 0, len(lines)+1)
|
||||
if title != "" {
|
||||
rendered = append(rendered, t.sectionTitle.Width(contentWidth).Render(title))
|
||||
}
|
||||
for _, line := range lines {
|
||||
rendered = append(rendered, t.panel.Width(contentWidth).Render(t.body.Width(contentWidth-2).Render(line)))
|
||||
}
|
||||
return strings.Join(rendered, "\n")
|
||||
}
|
||||
|
||||
func (t tuiTheme) renderMessage(width int, kind, text string) string {
|
||||
label := t.statusInfo
|
||||
switch kind {
|
||||
case "warn":
|
||||
label = t.statusWarn
|
||||
case "success":
|
||||
label = t.statusSuccess
|
||||
}
|
||||
return t.panel.Width(innerWidth(clampFrameWidth(width))).Render(
|
||||
label.Render(strings.ToUpper(kind)) + " " + t.body.Render(text),
|
||||
)
|
||||
}
|
||||
|
||||
type menuOption struct {
|
||||
Key string
|
||||
Value string
|
||||
Label string
|
||||
Description string
|
||||
}
|
||||
|
||||
type menuModel struct {
|
||||
theme tuiTheme
|
||||
title string
|
||||
subtitle string
|
||||
intro []string
|
||||
options []menuOption
|
||||
cursor int
|
||||
choice string
|
||||
cancel bool
|
||||
width int
|
||||
}
|
||||
|
||||
func (m menuModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m menuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
case tea.KeyMsg:
|
||||
key := strings.ToLower(msg.String())
|
||||
switch key {
|
||||
case "ctrl+c", "esc":
|
||||
m.cancel = true
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if len(m.options) > 0 {
|
||||
m.cursor = (m.cursor - 1 + len(m.options)) % len(m.options)
|
||||
}
|
||||
case "down", "j":
|
||||
if len(m.options) > 0 {
|
||||
m.cursor = (m.cursor + 1) % len(m.options)
|
||||
}
|
||||
case "enter":
|
||||
if len(m.options) > 0 {
|
||||
m.choice = m.options[m.cursor].Value
|
||||
return m, tea.Quit
|
||||
}
|
||||
default:
|
||||
for i, option := range m.options {
|
||||
if key == strings.ToLower(option.Key) || key == fmt.Sprintf("%d", i+1) {
|
||||
m.cursor = i
|
||||
m.choice = option.Value
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m menuModel) View() string {
|
||||
width := clampFrameWidth(m.width)
|
||||
if width == 0 {
|
||||
width = clampFrameWidth(84)
|
||||
}
|
||||
contentWidth := innerWidth(width)
|
||||
|
||||
rows := make([]string, 0, len(m.options))
|
||||
for i, option := range m.options {
|
||||
rowStyle := m.theme.row
|
||||
keyStyle := m.theme.rowKey
|
||||
hintStyle := m.theme.rowHint
|
||||
prefix := m.theme.muted.Render(fmt.Sprintf("%02d", i+1))
|
||||
if i == m.cursor {
|
||||
rowStyle = m.theme.rowSelected
|
||||
keyStyle = m.theme.rowKeySelected
|
||||
hintStyle = m.theme.rowHintActive
|
||||
prefix = keyStyle.Render(">>")
|
||||
}
|
||||
|
||||
label := fmt.Sprintf("%s %s %s", prefix, keyStyle.Render("["+option.Key+"]"), option.Label)
|
||||
rowLines := []string{label}
|
||||
if option.Description != "" {
|
||||
rowLines = append(rowLines, hintStyle.Render(option.Description))
|
||||
}
|
||||
rows = append(rows, rowStyle.Width(contentWidth).Render(strings.Join(rowLines, "\n")))
|
||||
}
|
||||
|
||||
bodyParts := []string{}
|
||||
if len(m.intro) > 0 {
|
||||
bodyParts = append(bodyParts, m.theme.renderSection(width, "Context", m.intro))
|
||||
}
|
||||
bodyParts = append(bodyParts, m.theme.sectionTitle.Width(contentWidth).Render("Actions"))
|
||||
bodyParts = append(bodyParts, strings.Join(rows, "\n"))
|
||||
|
||||
return m.theme.renderFrame(
|
||||
m.width,
|
||||
m.title,
|
||||
m.subtitle,
|
||||
strings.Join(bodyParts, "\n\n"),
|
||||
"enter select • j/k or arrows move • letter/number picks instantly • esc cancel",
|
||||
)
|
||||
}
|
||||
|
||||
type textPromptModel struct {
|
||||
theme tuiTheme
|
||||
title string
|
||||
subtitle string
|
||||
label string
|
||||
defaultValue string
|
||||
input textinput.Model
|
||||
value string
|
||||
cancel bool
|
||||
width int
|
||||
}
|
||||
|
||||
func newTextPromptModel(theme tuiTheme, title, subtitle, label, defaultValue string) textPromptModel {
|
||||
input := textinput.New()
|
||||
input.Prompt = ""
|
||||
input.Placeholder = defaultValue
|
||||
input.Focus()
|
||||
input.CharLimit = 512
|
||||
input.Width = 48
|
||||
|
||||
return textPromptModel{
|
||||
theme: theme,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
label: label,
|
||||
defaultValue: defaultValue,
|
||||
input: input,
|
||||
}
|
||||
}
|
||||
|
||||
func (m textPromptModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m textPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
available := innerWidth(clampFrameWidth(m.width)) - 4
|
||||
if available < 16 {
|
||||
available = 16
|
||||
}
|
||||
m.input.Width = available
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
m.cancel = true
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
value := strings.TrimSpace(m.input.Value())
|
||||
if value == "" {
|
||||
value = m.defaultValue
|
||||
}
|
||||
m.value = value
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m textPromptModel) View() string {
|
||||
width := clampFrameWidth(m.width)
|
||||
contentWidth := innerWidth(width)
|
||||
|
||||
lines := []string{
|
||||
m.theme.inputLabel.Width(contentWidth).Render(m.label),
|
||||
m.theme.inputBox.Width(contentWidth).Render(m.input.View()),
|
||||
}
|
||||
if m.defaultValue != "" {
|
||||
lines = append(lines, m.theme.muted.Width(contentWidth).Render("Enter keeps: "+m.defaultValue))
|
||||
}
|
||||
|
||||
return m.theme.renderFrame(
|
||||
m.width,
|
||||
m.title,
|
||||
m.subtitle,
|
||||
strings.Join(lines, "\n\n"),
|
||||
"enter confirm • esc cancel",
|
||||
)
|
||||
}
|
||||
|
||||
func runMenuPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle string, intro []string, options []menuOption) (string, error) {
|
||||
model := menuModel{
|
||||
theme: theme,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
intro: intro,
|
||||
options: options,
|
||||
}
|
||||
|
||||
result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out)).Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
final := result.(menuModel)
|
||||
if final.cancel {
|
||||
return "", ErrCancelled
|
||||
}
|
||||
return final.choice, nil
|
||||
}
|
||||
|
||||
func runTextPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle, label, defaultValue string) (string, error) {
|
||||
model := newTextPromptModel(theme, title, subtitle, label, defaultValue)
|
||||
|
||||
result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out)).Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
final := result.(textPromptModel)
|
||||
if final.cancel {
|
||||
return "", ErrCancelled
|
||||
}
|
||||
return final.value, nil
|
||||
}
|
||||
Reference in New Issue
Block a user