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 }