474 lines
12 KiB
Go
474 lines
12 KiB
Go
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
|
|
headerBar lipgloss.Style
|
|
badge lipgloss.Style
|
|
chrome lipgloss.Style
|
|
title lipgloss.Style
|
|
subtitle lipgloss.Style
|
|
sectionTitle lipgloss.Style
|
|
body lipgloss.Style
|
|
muted lipgloss.Style
|
|
footer lipgloss.Style
|
|
panel lipgloss.Style
|
|
panelAccent lipgloss.Style
|
|
row lipgloss.Style
|
|
rowSelected lipgloss.Style
|
|
rowNumber lipgloss.Style
|
|
rowNumberOn lipgloss.Style
|
|
rowKey lipgloss.Style
|
|
rowKeySelected lipgloss.Style
|
|
rowTitle lipgloss.Style
|
|
rowTitleOn 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("#5A6772")
|
|
borderSoft := lipgloss.Color("#31414A")
|
|
text := lipgloss.Color("#ECE4D8")
|
|
muted := lipgloss.Color("#A49788")
|
|
accent := lipgloss.Color("#D9733F")
|
|
accentSoft := lipgloss.Color("#F2D2B5")
|
|
panel := lipgloss.Color("#161C20")
|
|
panelAlt := lipgloss.Color("#212A30")
|
|
panelLift := lipgloss.Color("#2B363D")
|
|
info := lipgloss.Color("#86B7C6")
|
|
warn := lipgloss.Color("#D8AB63")
|
|
success := lipgloss.Color("#91B57F")
|
|
|
|
return tuiTheme{
|
|
outer: lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(border).
|
|
Padding(1, 2),
|
|
headerBar: lipgloss.NewStyle().
|
|
Foreground(borderSoft),
|
|
chrome: lipgloss.NewStyle().
|
|
Foreground(muted),
|
|
badge: lipgloss.NewStyle().
|
|
Foreground(panel).
|
|
Background(accentSoft).
|
|
Bold(true).
|
|
Padding(0, 1),
|
|
subtitle: lipgloss.NewStyle().
|
|
Foreground(muted),
|
|
title: lipgloss.NewStyle().
|
|
Foreground(text).
|
|
Bold(true),
|
|
sectionTitle: lipgloss.NewStyle().
|
|
Foreground(accentSoft).
|
|
Bold(true).
|
|
Border(lipgloss.NormalBorder(), false, false, true, false).
|
|
BorderForeground(borderSoft).
|
|
PaddingBottom(0).
|
|
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(borderSoft).
|
|
PaddingTop(1).
|
|
MarginTop(1),
|
|
panel: lipgloss.NewStyle().
|
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
|
BorderForeground(borderSoft).
|
|
PaddingLeft(1).
|
|
MarginBottom(1),
|
|
panelAccent: lipgloss.NewStyle().
|
|
Border(lipgloss.ThickBorder(), false, false, false, true).
|
|
BorderForeground(accent).
|
|
PaddingLeft(1).
|
|
MarginBottom(1),
|
|
row: lipgloss.NewStyle().
|
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
|
BorderForeground(panelLift).
|
|
Padding(0, 1).
|
|
MarginBottom(1),
|
|
rowSelected: lipgloss.NewStyle().
|
|
Border(lipgloss.ThickBorder(), false, false, false, true).
|
|
BorderForeground(accent).
|
|
Background(panel).
|
|
Padding(0, 1).
|
|
MarginBottom(1),
|
|
rowNumber: lipgloss.NewStyle().
|
|
Foreground(muted),
|
|
rowNumberOn: lipgloss.NewStyle().
|
|
Foreground(accentSoft).
|
|
Bold(true),
|
|
rowKey: lipgloss.NewStyle().
|
|
Foreground(accent).
|
|
Bold(true).
|
|
Background(panelAlt).
|
|
Padding(0, 1),
|
|
rowKeySelected: lipgloss.NewStyle().
|
|
Foreground(accentSoft).
|
|
Background(panelLift).
|
|
Bold(true).
|
|
Padding(0, 1),
|
|
rowTitle: lipgloss.NewStyle().
|
|
Foreground(text).
|
|
Bold(true),
|
|
rowTitleOn: lipgloss.NewStyle().
|
|
Foreground(text).
|
|
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).
|
|
Background(panelAlt),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
headerLeft := lipgloss.JoinHorizontal(lipgloss.Top, t.badge.Render("SAMBA CONFIGER"), " ", t.chrome.Render("share editor"))
|
|
headerRule := t.headerBar.Render(strings.Repeat("─", max(0, contentWidth-lipgloss.Width(headerLeft)-1)))
|
|
header := lipgloss.JoinHorizontal(lipgloss.Center, headerLeft, " ", headerRule)
|
|
|
|
sections := []string{header, 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(max(1, 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),
|
|
)
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
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
|
|
titleStyle := m.theme.rowTitle
|
|
hintStyle := m.theme.rowHint
|
|
numberStyle := m.theme.rowNumber
|
|
prefix := numberStyle.Render(fmt.Sprintf("%02d", i+1))
|
|
if i == m.cursor {
|
|
rowStyle = m.theme.rowSelected
|
|
keyStyle = m.theme.rowKeySelected
|
|
titleStyle = m.theme.rowTitleOn
|
|
hintStyle = m.theme.rowHintActive
|
|
numberStyle = m.theme.rowNumberOn
|
|
prefix = numberStyle.Render(">>")
|
|
}
|
|
|
|
top := lipgloss.JoinHorizontal(
|
|
lipgloss.Center,
|
|
prefix,
|
|
" ",
|
|
keyStyle.Render(strings.ToUpper(option.Key)),
|
|
" ",
|
|
titleStyle.Render(option.Label),
|
|
)
|
|
|
|
rowLines := []string{top}
|
|
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)}
|
|
lines = append(lines, m.theme.panelAccent.Width(contentWidth).Render(m.theme.inputBox.Width(max(8, contentWidth-2)).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
|
|
}
|