Files
dirmd/internal/tui/form.go
T
2026-08-27 17:57:23 +01:00

488 lines
12 KiB
Go

package tui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"dirmd/internal/config"
)
const (
fieldInputDir = iota
fieldOutputFile
fieldAppend
fieldForce
fieldVerticalSlices
fieldSkipFrontend
fieldFrontendOnly
fieldOmitTest
fieldOmitMd
fieldProtonDrive
fieldDrivePath
fieldMaxDepth
fieldMaxSize
fieldIgnorePatterns
fieldGenerateBtn
fieldCancelBtn
fieldCount
)
type FormModel struct {
focusIndex int
inputDir textinput.Model
outputFile textinput.Model
drivePath textinput.Model
maxDepth textinput.Model
maxSize textinput.Model
ignoreArea textarea.Model
appendMode Checkbox
force Checkbox
verticalSlices Checkbox
skipFrontend Checkbox
frontendOnly Checkbox
omitTest Checkbox
omitMd Checkbox
protonDrive Checkbox
isDark bool
width int
height int
err string
}
func NewForm() FormModel {
inputDir := textinput.New()
inputDir.Placeholder = "."
inputDir.SetValue(".")
inputDir.SetWidth(60)
outputFile := textinput.New()
outputFile.Placeholder = "./output_dirmd/index.md"
outputFile.SetValue("./output_dirmd/index.md")
outputFile.SetWidth(60)
drivePath := textinput.New()
drivePath.Placeholder = "/my-files/md/"
drivePath.SetWidth(60)
maxDepth := textinput.New()
maxDepth.SetValue("20")
maxDepth.SetWidth(10)
maxSize := textinput.New()
maxSize.SetValue("512")
maxSize.SetWidth(10)
ignoreArea := textarea.New()
ignoreArea.Placeholder = "One pattern per line..."
ignoreArea.SetHeight(5)
ignoreArea.ShowLineNumbers = false
ignoreArea.CharLimit = 0
m := FormModel{
inputDir: inputDir,
outputFile: outputFile,
drivePath: drivePath,
maxDepth: maxDepth,
maxSize: maxSize,
ignoreArea: ignoreArea,
appendMode: Checkbox{Label: "Append to existing file"},
force: Checkbox{Label: "Force overwrite"},
verticalSlices: Checkbox{Label: "Vertical slices"},
skipFrontend: Checkbox{Label: "Skip frontend files"},
frontendOnly: Checkbox{Label: "Frontend only"},
omitTest: Checkbox{Label: "Omit test files"},
omitMd: Checkbox{Label: "Omit non-README markdown"},
protonDrive: Checkbox{Label: "Proton Drive upload"},
focusIndex: fieldInputDir,
isDark: true,
}
m.applyStyles()
m.focusActiveField()
return m
}
func (m *FormModel) SetDark(isDark bool) {
if m.isDark == isDark {
return
}
m.isDark = isDark
m.applyStyles()
}
func (m *FormModel) applyStyles() {
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 := 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()
m.drivePath.Blur()
m.maxDepth.Blur()
m.maxSize.Blur()
m.ignoreArea.Blur()
var cmd tea.Cmd
switch m.focusIndex {
case fieldInputDir:
cmd = m.inputDir.Focus()
case fieldOutputFile:
cmd = m.outputFile.Focus()
case fieldDrivePath:
cmd = m.drivePath.Focus()
case fieldMaxDepth:
cmd = m.maxDepth.Focus()
case fieldMaxSize:
cmd = m.maxSize.Focus()
case fieldIgnorePatterns:
cmd = m.ignoreArea.Focus()
}
return cmd
}
func (m FormModel) Update(msg tea.Msg) (FormModel, tea.Cmd) {
var cmds []tea.Cmd
if k, ok := msg.(tea.KeyPressMsg); ok {
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...)
}
} 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
}
}
}
switch m.focusIndex {
case fieldInputDir:
var cmd tea.Cmd
m.inputDir, cmd = m.inputDir.Update(msg)
cmds = append(cmds, cmd)
case fieldOutputFile:
var cmd tea.Cmd
m.outputFile, cmd = m.outputFile.Update(msg)
cmds = append(cmds, cmd)
case fieldDrivePath:
var cmd tea.Cmd
m.drivePath, cmd = m.drivePath.Update(msg)
cmds = append(cmds, cmd)
case fieldMaxDepth:
var cmd tea.Cmd
m.maxDepth, cmd = m.maxDepth.Update(msg)
cmds = append(cmds, cmd)
case fieldMaxSize:
var cmd tea.Cmd
m.maxSize, cmd = m.maxSize.Update(msg)
cmds = append(cmds, cmd)
case fieldIgnorePatterns:
var cmd tea.Cmd
m.ignoreArea, cmd = m.ignoreArea.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
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"))
b.WriteString("\n\n")
b.WriteString(m.renderTextField("Input directory", m.inputDir, m.focusIndex == fieldInputDir))
b.WriteString("\n")
b.WriteString(m.renderTextField("Output file", m.outputFile, m.focusIndex == fieldOutputFile))
b.WriteString("\n\n")
b.WriteString(m.appendMode.Render(m.focusIndex == fieldAppend))
b.WriteString("\n")
b.WriteString(m.force.Render(m.focusIndex == fieldForce))
b.WriteString("\n")
b.WriteString(m.verticalSlices.Render(m.focusIndex == fieldVerticalSlices))
b.WriteString("\n")
b.WriteString(m.skipFrontend.Render(m.focusIndex == fieldSkipFrontend))
b.WriteString("\n")
b.WriteString(m.frontendOnly.Render(m.focusIndex == fieldFrontendOnly))
b.WriteString("\n")
b.WriteString(m.omitTest.Render(m.focusIndex == fieldOmitTest))
b.WriteString("\n")
b.WriteString(m.omitMd.Render(m.focusIndex == fieldOmitMd))
b.WriteString("\n")
b.WriteString(m.protonDrive.Render(m.focusIndex == fieldProtonDrive))
b.WriteString("\n")
if m.protonDrive.Checked {
b.WriteString(m.renderTextField("Remote path", m.drivePath, m.focusIndex == fieldDrivePath))
b.WriteString("\n")
}
b.WriteString("\n")
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")
ignoreFocused := m.focusIndex == fieldIgnorePatterns
if ignoreFocused {
b.WriteString(focusedStyle.Render("▶ Ignore patterns:"))
} else {
b.WriteString(" " + labelStyle.Render("Ignore patterns:"))
}
b.WriteString("\n")
b.WriteString(m.ignoreArea.View())
b.WriteString("\n\n")
btnFocused := m.focusIndex == fieldGenerateBtn
if btnFocused {
b.WriteString(focusedStyle.Render("▶ [ Generate ]"))
} else {
b.WriteString(" " + labelStyle.Render("[ Generate ]"))
}
b.WriteString(" ")
cancelFocused := m.focusIndex == fieldCancelBtn
if cancelFocused {
b.WriteString(focusedStyle.Render("▶ [ Cancel ]"))
} else {
b.WriteString(" " + labelStyle.Render("[ Cancel ]"))
}
if m.err != "" {
b.WriteString("\n\n")
b.WriteString(errorStyle.Render(m.err))
}
b.WriteString("\n\n")
b.WriteString(footerStyle.Render("Tab/↑↓: navigate Space: toggle Enter: submit Esc: quit"))
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 {
if focused {
return focusedStyle.Render("▶ "+label+":") + " " + input.View()
}
return " " + labelStyle.Render(label+":") + " " + input.View()
}
func (m FormModel) ToConfig() (*config.Config, error) {
inputDir := m.inputDir.Value()
if inputDir == "" {
inputDir = "."
}
outputFile := m.outputFile.Value()
if outputFile == "" {
return nil, fmt.Errorf("output file required")
}
maxDepth, err := parseIntDefault(m.maxDepth.Value(), 20)
if err != nil {
return nil, fmt.Errorf("max depth: %w", err)
}
maxSizeKB, err := parseIntDefault(m.maxSize.Value(), 512)
if err != nil {
return nil, fmt.Errorf("max size: %w", err)
}
var ignores []string
raw := strings.TrimSpace(m.ignoreArea.Value())
if raw != "" {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line != "" {
ignores = append(ignores, line)
}
}
}
if m.skipFrontend.Checked && m.frontendOnly.Checked {
return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
}
if m.appendMode.Checked && m.verticalSlices.Checked {
return nil, fmt.Errorf("--append and --vertical-slices are mutually exclusive")
}
excludedExts := append([]string{}, config.DefaultExts...)
if m.skipFrontend.Checked {
excludedExts = append(excludedExts, config.FrontendExts...)
}
allIgnores := append([]string{}, config.DefaultIgnores...)
allIgnores = append(allIgnores, ignores...)
cfg := &config.Config{
InputPath: inputDir,
OutputPath: outputFile,
AppendMode: m.appendMode.Checked,
Force: m.force.Checked,
MaxSize: int64(maxSizeKB) * 1024,
MaxDepth: maxDepth,
Ignores: allIgnores,
Extensions: excludedExts,
ProtonDrive: m.protonDrive.Checked,
DrivePath: m.drivePath.Value(),
VerticalSlices: m.verticalSlices.Checked,
SkipFrontend: m.skipFrontend.Checked,
FrontendOnly: m.frontendOnly.Checked,
OmitTest: m.omitTest.Checked,
OmitMd: m.omitMd.Checked,
}
return cfg, nil
}
func (m *FormModel) SetError(msg string) {
m.err = msg
}
func (m *FormModel) SetSize(w, h int) {
m.width = w
m.height = h
}
func parseIntDefault(s string, def int) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
return def, nil
}
var n int
_, err := fmt.Sscanf(s, "%d", &n)
if err != nil {
return 0, err
}
if n <= 0 {
return def, nil
}
return n, nil
}