tui better

This commit is contained in:
2026-08-27 17:57:23 +01:00
parent 71961f68da
commit 73c2009139
5 changed files with 230 additions and 123 deletions
+12 -5
View File
@@ -10,15 +10,22 @@ func (c *Checkbox) Toggle() {
} }
func (c Checkbox) Render(focused bool) string { func (c Checkbox) Render(focused bool) string {
marker := checkBoxUnchecked marker := "[ ]"
if c.Checked { 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 { if focused {
style = focusedStyle.Bold(true) label = focusedStyle.Render("▶ " + marker + " " + c.Label)
return label
} }
return style.Render(marker + " " + c.Label) return " " + marker + " " + label
} }
+154 -102
View File
@@ -7,6 +7,7 @@ import (
"charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"dirmd/internal/config" "dirmd/internal/config"
) )
@@ -57,29 +58,30 @@ func NewForm() FormModel {
inputDir := textinput.New() inputDir := textinput.New()
inputDir.Placeholder = "." inputDir.Placeholder = "."
inputDir.SetValue(".") inputDir.SetValue(".")
inputDir.SetWidth(40) inputDir.SetWidth(60)
outputFile := textinput.New() outputFile := textinput.New()
outputFile.Placeholder = "./output.md" outputFile.Placeholder = "./output_dirmd/index.md"
outputFile.SetValue("./output.md") outputFile.SetValue("./output_dirmd/index.md")
outputFile.SetWidth(40) outputFile.SetWidth(60)
drivePath := textinput.New() drivePath := textinput.New()
drivePath.Placeholder = "/my-files/md/" drivePath.Placeholder = "/my-files/md/"
drivePath.SetValue("") drivePath.SetWidth(60)
drivePath.SetWidth(40)
maxDepth := textinput.New() maxDepth := textinput.New()
maxDepth.SetValue("20") maxDepth.SetValue("20")
maxDepth.SetWidth(5) maxDepth.SetWidth(10)
maxSize := textinput.New() maxSize := textinput.New()
maxSize.SetValue("512") maxSize.SetValue("512")
maxSize.SetWidth(5) maxSize.SetWidth(10)
ignoreArea := textarea.New() ignoreArea := textarea.New()
ignoreArea.Placeholder = "node_modules\n.venv" ignoreArea.Placeholder = "One pattern per line..."
ignoreArea.SetHeight(3) ignoreArea.SetHeight(5)
ignoreArea.ShowLineNumbers = false
ignoreArea.CharLimit = 0
m := FormModel{ m := FormModel{
inputDir: inputDir, inputDir: inputDir,
@@ -114,17 +116,29 @@ func (m *FormModel) SetDark(isDark bool) {
} }
func (m *FormModel) applyStyles() { func (m *FormModel) applyStyles() {
tiStyles := textinput.DefaultStyles(m.isDark) tiStyles := getDefaultInputStyles(m.isDark)
m.inputDir.SetStyles(tiStyles) m.inputDir.SetStyles(tiStyles)
m.outputFile.SetStyles(tiStyles) m.outputFile.SetStyles(tiStyles)
m.drivePath.SetStyles(tiStyles) m.drivePath.SetStyles(tiStyles)
m.maxDepth.SetStyles(tiStyles) m.maxDepth.SetStyles(tiStyles)
m.maxSize.SetStyles(tiStyles) m.maxSize.SetStyles(tiStyles)
taStyles := textarea.DefaultStyles(m.isDark) taStyles := getDefaultTextareaStyles(m.isDark)
m.ignoreArea.SetStyles(taStyles) 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 { func (m *FormModel) focusActiveField() tea.Cmd {
m.inputDir.Blur() m.inputDir.Blur()
m.outputFile.Blur() m.outputFile.Blur()
@@ -157,57 +171,96 @@ func (m FormModel) Update(msg tea.Msg) (FormModel, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
if k, ok := msg.(tea.KeyPressMsg); ok { if k, ok := msg.(tea.KeyPressMsg); ok {
switch { if isTextInputField(m.focusIndex) {
case isNavigateDown(k): if isNavigateDown(k) || isNavigateUp(k) {
m.focusIndex++ if isNavigateDown(k) {
if m.focusIndex >= fieldCount { m.focusIndex++
m.focusIndex = 0 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 { } else {
m.focusIndex = fieldProtonDrive switch {
} case isNavigateDown(k):
cmd := m.focusActiveField() m.focusIndex++
if cmd != nil { if m.focusIndex >= fieldCount {
cmds = append(cmds, cmd) m.focusIndex = 0
} }
return m, tea.Batch(cmds...) for m.shouldSkipField(m.focusIndex) {
case isNavigateUp(k): m.focusIndex++
m.focusIndex-- if m.focusIndex >= fieldCount {
if m.focusIndex < 0 { m.focusIndex = 0
m.focusIndex = fieldCount - 1 }
} }
if m.focusIndex == fieldDrivePath && !m.protonDrive.Checked { cmd := m.focusActiveField()
m.focusIndex = fieldProtonDrive if cmd != nil {
} cmds = append(cmds, cmd)
cmd := m.focusActiveField() }
if cmd != nil { return m, tea.Batch(cmds...)
cmds = append(cmds, cmd) case isNavigateUp(k):
} m.focusIndex--
return m, tea.Batch(cmds...) if m.focusIndex < 0 {
case isActivate(k): m.focusIndex = fieldCount - 1
switch m.focusIndex { }
case fieldAppend: for m.shouldSkipField(m.focusIndex) {
m.appendMode.Toggle() m.focusIndex--
case fieldForce: if m.focusIndex < 0 {
m.force.Toggle() m.focusIndex = fieldCount - 1
case fieldVerticalSlices: }
m.verticalSlices.Toggle() }
case fieldSkipFrontend: cmd := m.focusActiveField()
m.skipFrontend.Toggle() if cmd != nil {
case fieldFrontendOnly: cmds = append(cmds, cmd)
m.frontendOnly.Toggle() }
case fieldOmitTest: return m, tea.Batch(cmds...)
m.omitTest.Toggle() case isActivate(k):
case fieldOmitMd: switch m.focusIndex {
m.omitMd.Toggle() case fieldAppend:
case fieldProtonDrive: m.appendMode.Toggle()
m.protonDrive.Toggle() case fieldForce:
case fieldGenerateBtn: 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 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 { 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 var b strings.Builder
b.WriteString(titleStyle.Render("dirmd")) b.WriteString(titleStyle.Render("dirmd"))
@@ -270,64 +335,61 @@ func (m FormModel) View() string {
b.WriteString("\n") b.WriteString("\n")
if m.protonDrive.Checked { if m.protonDrive.Checked {
b.WriteString(" ")
b.WriteString(m.renderTextField("Remote path", m.drivePath, m.focusIndex == fieldDrivePath)) b.WriteString(m.renderTextField("Remote path", m.drivePath, m.focusIndex == fieldDrivePath))
b.WriteString("\n") b.WriteString("\n")
} }
b.WriteString("\n") b.WriteString("\n")
depthLabel := labelStyle.Render("Max depth:") b.WriteString(m.renderTextField("Max depth", m.maxDepth, m.focusIndex == fieldMaxDepth))
if m.focusIndex == fieldMaxDepth { b.WriteString("\n")
depthLabel = focusedStyle.Bold(true).Render("Max depth:") b.WriteString(m.renderTextField("Max size (KB)", m.maxSize, m.focusIndex == fieldMaxSize))
}
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("\n\n") b.WriteString("\n\n")
ignoreLabel := labelStyle.Render("Ignore patterns:") ignoreFocused := m.focusIndex == fieldIgnorePatterns
if m.focusIndex == fieldIgnorePatterns { if ignoreFocused {
ignoreLabel = focusedStyle.Bold(true).Render("Ignore patterns:") b.WriteString(focusedStyle.Render("▶ Ignore patterns:"))
} else {
b.WriteString(" " + labelStyle.Render("Ignore patterns:"))
} }
b.WriteString(ignoreLabel)
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.ignoreArea.View()) b.WriteString(m.ignoreArea.View())
b.WriteString("\n\n") b.WriteString("\n\n")
btnStyle := labelStyle btnFocused := m.focusIndex == fieldGenerateBtn
if m.focusIndex == fieldGenerateBtn { if btnFocused {
btnStyle = focusedStyle.Bold(true) b.WriteString(focusedStyle.Render("▶ [ Generate ]"))
} else {
b.WriteString(" " + labelStyle.Render("[ Generate ]"))
} }
b.WriteString(btnStyle.Render("[ Generate ]")) b.WriteString(" ")
b.WriteString(" ") cancelFocused := m.focusIndex == fieldCancelBtn
cancelStyle := labelStyle if cancelFocused {
if m.focusIndex == fieldCancelBtn { b.WriteString(focusedStyle.Render("▶ [ Cancel ]"))
cancelStyle = focusedStyle.Bold(true) } else {
b.WriteString(" " + labelStyle.Render("[ Cancel ]"))
} }
b.WriteString(cancelStyle.Render("[ Cancel ]"))
if m.err != "" { if m.err != "" {
b.WriteString("\n\n") b.WriteString("\n\n")
b.WriteString(errorStyle.Render(m.err)) b.WriteString(errorStyle.Render(m.err))
} }
b.WriteString("\n") b.WriteString("\n\n")
b.WriteString(footerStyle.Render("Tab/j/k: navigate Space: toggle Enter: submit Esc: quit")) 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 { func (m FormModel) renderTextField(label string, input textinput.Model, focused bool) string {
style := labelStyle
if focused { 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) { func (m FormModel) ToConfig() (*config.Config, error) {
@@ -406,16 +468,6 @@ func (m *FormModel) SetError(msg string) {
func (m *FormModel) SetSize(w, h int) { func (m *FormModel) SetSize(w, h int) {
m.width = w m.width = w
m.height = h 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) { func parseIntDefault(s string, def int) (int, error) {
+3 -3
View File
@@ -6,17 +6,17 @@ import (
func isQuit(msg tea.KeyPressMsg) bool { func isQuit(msg tea.KeyPressMsg) bool {
s := msg.String() s := msg.String()
return s == "q" || s == "ctrl+c" || s == "esc" return s == "ctrl+c" || s == "esc"
} }
func isNavigateDown(msg tea.KeyPressMsg) bool { func isNavigateDown(msg tea.KeyPressMsg) bool {
s := msg.String() s := msg.String()
return s == "down" || s == "j" || s == "tab" return s == "down" || s == "tab"
} }
func isNavigateUp(msg tea.KeyPressMsg) bool { func isNavigateUp(msg tea.KeyPressMsg) bool {
s := msg.String() s := msg.String()
return s == "up" || s == "k" || s == "shift+tab" return s == "up" || s == "shift+tab"
} }
func isActivate(msg tea.KeyPressMsg) bool { func isActivate(msg tea.KeyPressMsg) bool {
+21 -5
View File
@@ -6,6 +6,7 @@ import (
"charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
) )
type State int type State int
@@ -42,8 +43,8 @@ type Model struct {
func New() Model { func New() Model {
return Model{ return Model{
state: StateForm, state: StateForm,
form: NewForm(), form: NewForm(),
isDark: true, isDark: true,
} }
} }
@@ -139,7 +140,12 @@ func (m Model) renderGenerating() string {
b.WriteString(fmt.Sprintf(" %s Generating...\n", m.spinner.View())) b.WriteString(fmt.Sprintf(" %s Generating...\n", m.spinner.View()))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(footerStyle.Render("Please wait.")) 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 { func (m Model) renderDone() string {
@@ -156,7 +162,12 @@ func (m Model) renderDone() string {
} }
b.WriteString("\n\n") b.WriteString("\n\n")
b.WriteString(footerStyle.Render("Press any key to exit.")) 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 { 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(fmt.Sprintf(" %s\n", m.result.Error.Error()))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(footerStyle.Render("Press any key to exit.")) 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
} }
+40 -8
View File
@@ -1,36 +1,68 @@
package tui package tui
import ( import (
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
) )
var ( var (
appBorder = lipgloss.NewStyle(). appBorder = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(1, 2) Padding(1, 2)
titleStyle = lipgloss.NewStyle(). titleStyle = lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(lipgloss.Color("117")).
MarginBottom(1) MarginBottom(1)
labelStyle = lipgloss.NewStyle(). labelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("244")). Foreground(lipgloss.Color("250"))
Width(20)
focusedStyle = lipgloss.NewStyle(). focusedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("39")) Bold(true).
Foreground(lipgloss.Color("117"))
checkBoxChecked = "[x]" checkedStyle = lipgloss.NewStyle().
checkBoxUnchecked = "[ ]" Foreground(lipgloss.Color("82"))
uncheckedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("250"))
footerStyle = lipgloss.NewStyle(). footerStyle = lipgloss.NewStyle().
MarginTop(1). MarginTop(1).
Foreground(lipgloss.Color("241")) Foreground(lipgloss.Color("248"))
errorStyle = lipgloss.NewStyle(). errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")). Foreground(lipgloss.Color("203")).
Bold(true) Bold(true)
successStyle = lipgloss.NewStyle(). 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
}