charm bracelet init, brb

This commit is contained in:
2026-03-19 22:14:42 +00:00
parent e88e3a4bb0
commit a997b184f5
5 changed files with 713 additions and 185 deletions

414
tui.go Normal file
View 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
}