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 != "" && width >= 72 { 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), tea.WithAltScreen()).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), tea.WithAltScreen()).Run() if err != nil { return "", err } final := result.(textPromptModel) if final.cancel { return "", ErrCancelled } return final.value, nil } type cifsFormTextField struct { key string label string required bool defaultValue string input textinput.Model } type cifsFormToggle struct { label string value bool } type cifsMountFormModel struct { theme tuiTheme title string subtitle string help []string fields []cifsFormTextField toggles []cifsFormToggle cursor int submitted bool cancel bool width int errMessage string result CIFSMountConfig } func newCIFSMountFormModel(theme tuiTheme, title, subtitle string, defaults CIFSMountConfig) cifsMountFormModel { makeInput := func(value string, password bool) textinput.Model { input := textinput.New() input.Prompt = "" input.SetValue(value) input.Focus() input.CharLimit = 512 input.Width = 48 if password { input.EchoMode = textinput.EchoPassword input.EchoCharacter = '•' } return input } fields := []cifsFormTextField{ {key: "server", label: "Server name or IP", required: true, defaultValue: defaults.Server, input: makeInput(defaults.Server, false)}, {key: "share", label: "Share name on that server", required: true, defaultValue: defaults.Share, input: makeInput(defaults.Share, false)}, {key: "mount", label: "Local mount folder", required: true, defaultValue: defaults.MountPoint, input: makeInput(defaults.MountPoint, false)}, {key: "username", label: "Username for the remote share", required: true, defaultValue: defaults.Username, input: makeInput(defaults.Username, false)}, {key: "password", label: "Password for the remote share", required: true, defaultValue: defaults.Password, input: makeInput(defaults.Password, true)}, {key: "domain", label: "Domain or workgroup", defaultValue: defaults.Domain, input: makeInput(defaults.Domain, false)}, {key: "uid", label: "Local owner username or uid", defaultValue: defaults.UID, input: makeInput(defaults.UID, false)}, {key: "gid", label: "Local group name or gid", defaultValue: defaults.GID, input: makeInput(defaults.GID, false)}, {key: "filemode", label: "File permissions", defaultValue: defaultString(defaults.FileMode, "0664"), input: makeInput(defaultString(defaults.FileMode, "0664"), false)}, {key: "dirmode", label: "Folder permissions", defaultValue: defaultString(defaults.DirMode, "0775"), input: makeInput(defaultString(defaults.DirMode, "0775"), false)}, } model := cifsMountFormModel{ theme: theme, title: title, subtitle: subtitle, help: []string{ "Tab or arrows move between fields.", "Enter toggles switches or submits when Save is selected.", }, fields: fields, toggles: []cifsFormToggle{ {label: "Mount automatically at startup", value: defaults.AutoMount || (defaults.Server == "" && defaults.Share == "" && defaults.MountPoint == "")}, {label: "Make this mount read-only", value: defaults.ReadOnly}, }, } model.focusCursor() return model } func (m *cifsMountFormModel) totalItems() int { return len(m.fields) + len(m.toggles) + 2 } func (m *cifsMountFormModel) focusCursor() { for i := range m.fields { if i == m.cursor { m.fields[i].input.Focus() } else { m.fields[i].input.Blur() } } } func (m *cifsMountFormModel) moveCursor(delta int) { total := m.totalItems() if total == 0 { return } m.cursor = (m.cursor + delta + total) % total m.focusCursor() m.errMessage = "" } func (m *cifsMountFormModel) currentConfig() CIFSMountConfig { values := map[string]string{} for _, field := range m.fields { value := strings.TrimSpace(field.input.Value()) if value == "" { value = strings.TrimSpace(field.defaultValue) } values[field.key] = value } return CIFSMountConfig{ Server: values["server"], Share: values["share"], MountPoint: values["mount"], Username: values["username"], Password: values["password"], Domain: values["domain"], UID: values["uid"], GID: values["gid"], FileMode: values["filemode"], DirMode: values["dirmode"], AutoMount: m.toggles[0].value, ReadOnly: m.toggles[1].value, } } func (m *cifsMountFormModel) validate() string { for _, field := range m.fields { if !field.required { continue } value := strings.TrimSpace(field.input.Value()) if value == "" { value = strings.TrimSpace(field.defaultValue) } if value == "" { return field.label + " is required." } } return "" } func (m cifsMountFormModel) Init() tea.Cmd { return textinput.Blink } func (m cifsMountFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width available := innerWidth(clampFrameWidth(m.width)) - 8 if available < 16 { available = 16 } for i := range m.fields { m.fields[i].input.Width = available } case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": m.cancel = true return m, tea.Quit case "shift+tab", "up", "k": m.moveCursor(-1) return m, nil case "tab", "down", "j": m.moveCursor(1) return m, nil case "enter": textCount := len(m.fields) toggleStart := textCount saveIndex := textCount + len(m.toggles) cancelIndex := saveIndex + 1 switch { case m.cursor < textCount: m.moveCursor(1) return m, nil case m.cursor >= toggleStart && m.cursor < saveIndex: idx := m.cursor - toggleStart m.toggles[idx].value = !m.toggles[idx].value return m, nil case m.cursor == saveIndex: if errMessage := m.validate(); errMessage != "" { m.errMessage = errMessage return m, nil } m.result = m.currentConfig() m.submitted = true return m, tea.Quit case m.cursor == cancelIndex: m.cancel = true return m, tea.Quit } } } if m.cursor < len(m.fields) { var cmd tea.Cmd m.fields[m.cursor].input, cmd = m.fields[m.cursor].input.Update(msg) return m, cmd } return m, nil } func (m cifsMountFormModel) View() string { width := clampFrameWidth(m.width) if width == 0 { width = clampFrameWidth(84) } contentWidth := innerWidth(width) formRows := make([]string, 0, len(m.fields)+len(m.toggles)+3) for i, field := range m.fields { active := i == m.cursor boxStyle := m.theme.panel if active { boxStyle = m.theme.panelAccent } label := field.label if field.required { label += " *" } fieldLines := []string{m.theme.inputLabel.Render(label)} fieldLines = append(fieldLines, m.theme.inputBox.Width(max(12, contentWidth-6)).Render(field.input.View())) formRows = append(formRows, boxStyle.Width(contentWidth).Render(strings.Join(fieldLines, "\n"))) } toggleStart := len(m.fields) for i, toggle := range m.toggles { active := toggleStart+i == m.cursor rowStyle := m.theme.row marker := "[ ]" if toggle.value { marker = "[x]" } if active { rowStyle = m.theme.rowSelected } formRows = append(formRows, rowStyle.Width(contentWidth).Render( m.theme.rowTitle.Render(marker+" "+toggle.label), )) } saveIndex := len(m.fields) + len(m.toggles) saveStyle := m.theme.row cancelStyle := m.theme.row if m.cursor == saveIndex { saveStyle = m.theme.rowSelected } if m.cursor == saveIndex+1 { cancelStyle = m.theme.rowSelected } formRows = append(formRows, saveStyle.Width(contentWidth).Render(m.theme.rowTitle.Render("Save mount"))) formRows = append(formRows, cancelStyle.Width(contentWidth).Render(m.theme.rowTitle.Render("Cancel"))) previewCfg := m.currentConfig() previewLines := []string{ buildFstabLine(previewCfg), "Passwords are written directly into /etc/fstab by this workflow.", } bodyParts := []string{ m.theme.renderSection(width, "Workflow", m.help), m.theme.sectionTitle.Width(contentWidth).Render("Mount Details"), strings.Join(formRows, "\n"), m.theme.renderSection(width, "Preview", previewLines), } if m.errMessage != "" { bodyParts = append(bodyParts, m.theme.renderMessage(width, "warn", m.errMessage)) } return m.theme.renderFrame( m.width, m.title, m.subtitle, strings.Join(bodyParts, "\n\n"), "tab move • enter next/toggle/save • esc cancel", ) } func runCIFSMountForm(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle string, defaults CIFSMountConfig) (CIFSMountConfig, error) { model := newCIFSMountFormModel(theme, title, subtitle, defaults) result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out), tea.WithAltScreen()).Run() if err != nil { return CIFSMountConfig{}, err } final := result.(cifsMountFormModel) if final.cancel { return CIFSMountConfig{}, ErrCancelled } return final.result, nil }