'more bubbletea, less look bad plz'

This commit is contained in:
2026-03-19 22:51:41 +00:00
parent a79666f5a6
commit db6b693e53
3 changed files with 327 additions and 32 deletions

301
tui.go
View File

@@ -332,7 +332,7 @@ func (m menuModel) View() string {
)
rowLines := []string{top}
if option.Description != "" {
if option.Description != "" && width >= 72 {
rowLines = append(rowLines, hintStyle.Render(option.Description))
}
rows = append(rows, rowStyle.Width(contentWidth).Render(strings.Join(rowLines, "\n")))
@@ -445,7 +445,7 @@ func runMenuPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle
options: options,
}
result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out)).Run()
result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out), tea.WithAltScreen()).Run()
if err != nil {
return "", err
}
@@ -460,7 +460,7 @@ func runMenuPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle
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()
result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out), tea.WithAltScreen()).Run()
if err != nil {
return "", err
}
@@ -471,3 +471,298 @@ func runTextPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle,
}
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
}