From 73c2009139a6ff3f83b935785a5ee1a5e84cab5e Mon Sep 17 00:00:00 2001 From: Bartal Laearsson Date: Thu, 27 Aug 2026 17:57:23 +0100 Subject: [PATCH] tui better --- internal/tui/checkbox.go | 17 ++- internal/tui/form.go | 256 +++++++++++++++++++++++---------------- internal/tui/keys.go | 6 +- internal/tui/model.go | 26 +++- internal/tui/styles.go | 48 ++++++-- 5 files changed, 230 insertions(+), 123 deletions(-) diff --git a/internal/tui/checkbox.go b/internal/tui/checkbox.go index f53f6d1..f72e2d3 100644 --- a/internal/tui/checkbox.go +++ b/internal/tui/checkbox.go @@ -10,15 +10,22 @@ func (c *Checkbox) Toggle() { } func (c Checkbox) Render(focused bool) string { - marker := checkBoxUnchecked + marker := "[ ]" if c.Checked { - marker = checkBoxChecked + marker = "[x]" } - style := labelStyle + if c.Checked { + marker = checkedStyle.Render(marker) + } else { + marker = uncheckedStyle.Render(marker) + } + + label := labelStyle.Render(c.Label) if focused { - style = focusedStyle.Bold(true) + label = focusedStyle.Render("▶ " + marker + " " + c.Label) + return label } - return style.Render(marker + " " + c.Label) + return " " + marker + " " + label } diff --git a/internal/tui/form.go b/internal/tui/form.go index 0fd7177..786c326 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -7,6 +7,7 @@ import ( "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "dirmd/internal/config" ) @@ -57,29 +58,30 @@ func NewForm() FormModel { inputDir := textinput.New() inputDir.Placeholder = "." inputDir.SetValue(".") - inputDir.SetWidth(40) + inputDir.SetWidth(60) outputFile := textinput.New() - outputFile.Placeholder = "./output.md" - outputFile.SetValue("./output.md") - outputFile.SetWidth(40) + outputFile.Placeholder = "./output_dirmd/index.md" + outputFile.SetValue("./output_dirmd/index.md") + outputFile.SetWidth(60) drivePath := textinput.New() drivePath.Placeholder = "/my-files/md/" - drivePath.SetValue("") - drivePath.SetWidth(40) + drivePath.SetWidth(60) maxDepth := textinput.New() maxDepth.SetValue("20") - maxDepth.SetWidth(5) + maxDepth.SetWidth(10) maxSize := textinput.New() maxSize.SetValue("512") - maxSize.SetWidth(5) + maxSize.SetWidth(10) ignoreArea := textarea.New() - ignoreArea.Placeholder = "node_modules\n.venv" - ignoreArea.SetHeight(3) + ignoreArea.Placeholder = "One pattern per line..." + ignoreArea.SetHeight(5) + ignoreArea.ShowLineNumbers = false + ignoreArea.CharLimit = 0 m := FormModel{ inputDir: inputDir, @@ -114,17 +116,29 @@ func (m *FormModel) SetDark(isDark bool) { } func (m *FormModel) applyStyles() { - tiStyles := textinput.DefaultStyles(m.isDark) + tiStyles := getDefaultInputStyles(m.isDark) m.inputDir.SetStyles(tiStyles) m.outputFile.SetStyles(tiStyles) m.drivePath.SetStyles(tiStyles) m.maxDepth.SetStyles(tiStyles) m.maxSize.SetStyles(tiStyles) - taStyles := textarea.DefaultStyles(m.isDark) + taStyles := getDefaultTextareaStyles(m.isDark) m.ignoreArea.SetStyles(taStyles) } +func (m *FormModel) shouldSkipField(idx int) bool { + return idx == fieldDrivePath && !m.protonDrive.Checked +} + +func isTextInputField(idx int) bool { + switch idx { + case fieldInputDir, fieldOutputFile, fieldDrivePath, fieldMaxDepth, fieldMaxSize, fieldIgnorePatterns: + return true + } + return false +} + func (m *FormModel) focusActiveField() tea.Cmd { m.inputDir.Blur() m.outputFile.Blur() @@ -157,57 +171,96 @@ func (m FormModel) Update(msg tea.Msg) (FormModel, tea.Cmd) { var cmds []tea.Cmd if k, ok := msg.(tea.KeyPressMsg); ok { - switch { - case isNavigateDown(k): - m.focusIndex++ - if m.focusIndex >= fieldCount { - m.focusIndex = 0 + if isTextInputField(m.focusIndex) { + if isNavigateDown(k) || isNavigateUp(k) { + if isNavigateDown(k) { + m.focusIndex++ + if m.focusIndex >= fieldCount { + m.focusIndex = 0 + } + for m.shouldSkipField(m.focusIndex) { + m.focusIndex++ + if m.focusIndex >= fieldCount { + m.focusIndex = 0 + } + } + } else { + m.focusIndex-- + if m.focusIndex < 0 { + m.focusIndex = fieldCount - 1 + } + for m.shouldSkipField(m.focusIndex) { + m.focusIndex-- + if m.focusIndex < 0 { + m.focusIndex = fieldCount - 1 + } + } + } + cmd := m.focusActiveField() + if cmd != nil { + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) } - if m.focusIndex == fieldDrivePath && !m.protonDrive.Checked { - m.focusIndex = fieldProtonDrive - } - cmd := m.focusActiveField() - if cmd != nil { - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) - case isNavigateUp(k): - m.focusIndex-- - if m.focusIndex < 0 { - m.focusIndex = fieldCount - 1 - } - if m.focusIndex == fieldDrivePath && !m.protonDrive.Checked { - m.focusIndex = fieldProtonDrive - } - cmd := m.focusActiveField() - if cmd != nil { - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) - case isActivate(k): - switch m.focusIndex { - case fieldAppend: - m.appendMode.Toggle() - case fieldForce: - m.force.Toggle() - case fieldVerticalSlices: - m.verticalSlices.Toggle() - case fieldSkipFrontend: - m.skipFrontend.Toggle() - case fieldFrontendOnly: - m.frontendOnly.Toggle() - case fieldOmitTest: - m.omitTest.Toggle() - case fieldOmitMd: - m.omitMd.Toggle() - case fieldProtonDrive: - m.protonDrive.Toggle() - case fieldGenerateBtn: + } else { + switch { + case isNavigateDown(k): + m.focusIndex++ + if m.focusIndex >= fieldCount { + m.focusIndex = 0 + } + for m.shouldSkipField(m.focusIndex) { + m.focusIndex++ + if m.focusIndex >= fieldCount { + m.focusIndex = 0 + } + } + cmd := m.focusActiveField() + if cmd != nil { + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + case isNavigateUp(k): + m.focusIndex-- + if m.focusIndex < 0 { + m.focusIndex = fieldCount - 1 + } + for m.shouldSkipField(m.focusIndex) { + m.focusIndex-- + if m.focusIndex < 0 { + m.focusIndex = fieldCount - 1 + } + } + cmd := m.focusActiveField() + if cmd != nil { + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + case isActivate(k): + switch m.focusIndex { + case fieldAppend: + m.appendMode.Toggle() + case fieldForce: + m.force.Toggle() + case fieldVerticalSlices: + m.verticalSlices.Toggle() + case fieldSkipFrontend: + m.skipFrontend.Toggle() + case fieldFrontendOnly: + m.frontendOnly.Toggle() + case fieldOmitTest: + m.omitTest.Toggle() + case fieldOmitMd: + m.omitMd.Toggle() + case fieldProtonDrive: + m.protonDrive.Toggle() + case fieldGenerateBtn: + return m, nil + case fieldCancelBtn: + return m, tea.Quit + } return m, nil - case fieldCancelBtn: - return m, tea.Quit } - return m, nil } } @@ -242,6 +295,18 @@ func (m FormModel) Update(msg tea.Msg) (FormModel, tea.Cmd) { } func (m FormModel) View() string { + usableWidth := m.width - 30 + if usableWidth < 50 { + usableWidth = 50 + } + if usableWidth > 80 { + usableWidth = 80 + } + m.inputDir.SetWidth(usableWidth) + m.outputFile.SetWidth(usableWidth) + m.drivePath.SetWidth(usableWidth) + m.ignoreArea.SetWidth(usableWidth) + var b strings.Builder b.WriteString(titleStyle.Render("dirmd")) @@ -270,64 +335,61 @@ func (m FormModel) View() string { b.WriteString("\n") if m.protonDrive.Checked { - b.WriteString(" ") b.WriteString(m.renderTextField("Remote path", m.drivePath, m.focusIndex == fieldDrivePath)) b.WriteString("\n") } b.WriteString("\n") - depthLabel := labelStyle.Render("Max depth:") - if m.focusIndex == fieldMaxDepth { - depthLabel = focusedStyle.Bold(true).Render("Max depth:") - } - sizeLabel := labelStyle.Render("Max size (KB):") - if m.focusIndex == fieldMaxSize { - sizeLabel = focusedStyle.Bold(true).Render("Max size (KB):") - } - b.WriteString(fmt.Sprintf("%s %s %s %s", - depthLabel, m.maxDepth.View(), - sizeLabel, m.maxSize.View(), - )) + b.WriteString(m.renderTextField("Max depth", m.maxDepth, m.focusIndex == fieldMaxDepth)) + b.WriteString("\n") + b.WriteString(m.renderTextField("Max size (KB)", m.maxSize, m.focusIndex == fieldMaxSize)) b.WriteString("\n\n") - ignoreLabel := labelStyle.Render("Ignore patterns:") - if m.focusIndex == fieldIgnorePatterns { - ignoreLabel = focusedStyle.Bold(true).Render("Ignore patterns:") + ignoreFocused := m.focusIndex == fieldIgnorePatterns + if ignoreFocused { + b.WriteString(focusedStyle.Render("▶ Ignore patterns:")) + } else { + b.WriteString(" " + labelStyle.Render("Ignore patterns:")) } - b.WriteString(ignoreLabel) b.WriteString("\n") b.WriteString(m.ignoreArea.View()) b.WriteString("\n\n") - btnStyle := labelStyle - if m.focusIndex == fieldGenerateBtn { - btnStyle = focusedStyle.Bold(true) + btnFocused := m.focusIndex == fieldGenerateBtn + if btnFocused { + b.WriteString(focusedStyle.Render("▶ [ Generate ]")) + } else { + b.WriteString(" " + labelStyle.Render("[ Generate ]")) } - b.WriteString(btnStyle.Render("[ Generate ]")) - b.WriteString(" ") - cancelStyle := labelStyle - if m.focusIndex == fieldCancelBtn { - cancelStyle = focusedStyle.Bold(true) + b.WriteString(" ") + cancelFocused := m.focusIndex == fieldCancelBtn + if cancelFocused { + b.WriteString(focusedStyle.Render("▶ [ Cancel ]")) + } else { + b.WriteString(" " + labelStyle.Render("[ Cancel ]")) } - b.WriteString(cancelStyle.Render("[ Cancel ]")) if m.err != "" { b.WriteString("\n\n") b.WriteString(errorStyle.Render(m.err)) } - b.WriteString("\n") - b.WriteString(footerStyle.Render("Tab/j/k: navigate Space: toggle Enter: submit Esc: quit")) + b.WriteString("\n\n") + b.WriteString(footerStyle.Render("Tab/↑↓: navigate Space: toggle Enter: submit Esc: quit")) - return appBorder.Render(b.String()) + content := appBorder.Render(b.String()) + + if m.width > 0 && m.height > 0 { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) + } + return content } func (m FormModel) renderTextField(label string, input textinput.Model, focused bool) string { - style := labelStyle if focused { - style = focusedStyle + return focusedStyle.Render("▶ "+label+":") + " " + input.View() } - return fmt.Sprintf("%s %s", style.Render(label+":"), input.View()) + return " " + labelStyle.Render(label+":") + " " + input.View() } func (m FormModel) ToConfig() (*config.Config, error) { @@ -406,16 +468,6 @@ func (m *FormModel) SetError(msg string) { func (m *FormModel) SetSize(w, h int) { m.width = w m.height = h - usable := w - 30 - if usable < 20 { - usable = 20 - } - if usable > 60 { - usable = 60 - } - m.inputDir.SetWidth(usable) - m.outputFile.SetWidth(usable) - m.drivePath.SetWidth(usable) } func parseIntDefault(s string, def int) (int, error) { diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 59e00e1..94b983e 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -6,17 +6,17 @@ import ( func isQuit(msg tea.KeyPressMsg) bool { s := msg.String() - return s == "q" || s == "ctrl+c" || s == "esc" + return s == "ctrl+c" || s == "esc" } func isNavigateDown(msg tea.KeyPressMsg) bool { s := msg.String() - return s == "down" || s == "j" || s == "tab" + return s == "down" || s == "tab" } func isNavigateUp(msg tea.KeyPressMsg) bool { s := msg.String() - return s == "up" || s == "k" || s == "shift+tab" + return s == "up" || s == "shift+tab" } func isActivate(msg tea.KeyPressMsg) bool { diff --git a/internal/tui/model.go b/internal/tui/model.go index 91eed28..4b2e017 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -6,6 +6,7 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) type State int @@ -42,8 +43,8 @@ type Model struct { func New() Model { return Model{ - state: StateForm, - form: NewForm(), + state: StateForm, + form: NewForm(), isDark: true, } } @@ -139,7 +140,12 @@ func (m Model) renderGenerating() string { b.WriteString(fmt.Sprintf(" %s Generating...\n", m.spinner.View())) b.WriteString("\n") b.WriteString(footerStyle.Render("Please wait.")) - return appBorder.Render(b.String()) + + content := appBorder.Render(b.String()) + if m.width > 0 && m.height > 0 { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) + } + return content } func (m Model) renderDone() string { @@ -156,7 +162,12 @@ func (m Model) renderDone() string { } b.WriteString("\n\n") b.WriteString(footerStyle.Render("Press any key to exit.")) - return appBorder.Render(b.String()) + + content := appBorder.Render(b.String()) + if m.width > 0 && m.height > 0 { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) + } + return content } func (m Model) renderError() string { @@ -168,5 +179,10 @@ func (m Model) renderError() string { b.WriteString(fmt.Sprintf(" %s\n", m.result.Error.Error())) b.WriteString("\n") b.WriteString(footerStyle.Render("Press any key to exit.")) - return appBorder.Render(b.String()) + + content := appBorder.Render(b.String()) + if m.width > 0 && m.height > 0 { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) + } + return content } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index cdc4424..1da1d97 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -1,36 +1,68 @@ package tui import ( + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" "charm.land/lipgloss/v2" ) var ( appBorder = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). Padding(1, 2) titleStyle = lipgloss.NewStyle(). Bold(true). + Foreground(lipgloss.Color("117")). MarginBottom(1) labelStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("244")). - Width(20) + Foreground(lipgloss.Color("250")) focusedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("39")) + Bold(true). + Foreground(lipgloss.Color("117")) - checkBoxChecked = "[x]" - checkBoxUnchecked = "[ ]" + checkedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("82")) + + uncheckedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("250")) footerStyle = lipgloss.NewStyle(). MarginTop(1). - Foreground(lipgloss.Color("241")) + Foreground(lipgloss.Color("248")) errorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")). + Foreground(lipgloss.Color("203")). Bold(true) successStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("46")) + Foreground(lipgloss.Color("82")). + Bold(true) ) + +func getDefaultInputStyles(isDark bool) textinput.Styles { + s := textinput.DefaultStyles(isDark) + s.Focused.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("117")).Bold(true) + s.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) + s.Focused.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) + s.Blurred.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + s.Blurred.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + s.Blurred.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + return s +} + +func getDefaultTextareaStyles(isDark bool) textarea.Styles { + s := textarea.DefaultStyles(isDark) + s.Focused.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("117")).Bold(true) + s.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) + s.Focused.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) + s.Focused.Base = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) + s.Blurred.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + s.Blurred.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + s.Blurred.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + s.Blurred.Base = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + return s +}