Files
samba-configer/tui.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
}