Files
dirmd/internal/tui/form.go
T
2026-08-27 15:24:18 +01:00

363 lines
8.3 KiB
Go

package tui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"dirmd/internal/config"
)
const (
fieldInputDir = iota
fieldOutputDir
fieldForce
fieldVerticalSlices
fieldSkipFrontend
fieldProtonDrive
fieldDrivePath
fieldMaxDepth
fieldMaxSize
fieldIgnorePatterns
fieldGenerateBtn
fieldCancelBtn
fieldCount
)
type FormModel struct {
focusIndex int
inputDir textinput.Model
outputDir textinput.Model
drivePath textinput.Model
maxDepth textinput.Model
maxSize textinput.Model
ignoreArea textarea.Model
force Checkbox
verticalSlices Checkbox
skipFrontend Checkbox
protonDrive Checkbox
width int
height int
err string
}
func NewForm() FormModel {
inputDir := textinput.New()
inputDir.Placeholder = "."
inputDir.SetValue(".")
inputDir.SetWidth(40)
outputDir := textinput.New()
outputDir.Placeholder = "./dirmd_output"
outputDir.SetValue("./dirmd_output")
outputDir.SetWidth(40)
drivePath := textinput.New()
drivePath.Placeholder = "/my-files/md/"
drivePath.SetValue("")
drivePath.SetWidth(40)
maxDepth := textinput.New()
maxDepth.SetValue("20")
maxDepth.SetWidth(5)
maxSize := textinput.New()
maxSize.SetValue("512")
maxSize.SetWidth(5)
ignoreArea := textarea.New()
ignoreArea.Placeholder = "node_modules\n.venv"
ignoreArea.SetHeight(3)
m := FormModel{
inputDir: inputDir,
outputDir: outputDir,
drivePath: drivePath,
maxDepth: maxDepth,
maxSize: maxSize,
ignoreArea: ignoreArea,
force: Checkbox{Label: "Force overwrite"},
verticalSlices: Checkbox{Label: "Vertical slices"},
skipFrontend: Checkbox{Label: "Skip frontend files"},
protonDrive: Checkbox{Label: "Proton Drive upload"},
focusIndex: fieldInputDir,
}
m.focusActiveField()
return m
}
func (m *FormModel) focusActiveField() {
m.inputDir.Blur()
m.outputDir.Blur()
m.drivePath.Blur()
m.maxDepth.Blur()
m.maxSize.Blur()
m.ignoreArea.Blur()
switch m.focusIndex {
case fieldInputDir:
m.inputDir.Focus()
case fieldOutputDir:
m.outputDir.Focus()
case fieldDrivePath:
m.drivePath.Focus()
case fieldMaxDepth:
m.maxDepth.Focus()
case fieldMaxSize:
m.maxSize.Focus()
case fieldIgnorePatterns:
m.ignoreArea.Focus()
}
}
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 m.focusIndex == fieldDrivePath && !m.protonDrive.Checked {
m.focusIndex = fieldMaxDepth
}
m.focusActiveField()
return m, nil
case isNavigateUp(k):
m.focusIndex--
if m.focusIndex < 0 {
m.focusIndex = fieldCount - 1
}
if m.focusIndex == fieldDrivePath && !m.protonDrive.Checked {
m.focusIndex = fieldProtonDrive
}
m.focusActiveField()
return m, nil
case isActivate(k):
switch m.focusIndex {
case fieldForce:
m.force.Toggle()
case fieldVerticalSlices:
m.verticalSlices.Toggle()
case fieldSkipFrontend:
m.skipFrontend.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 fieldOutputDir:
var cmd tea.Cmd
m.outputDir, cmd = m.outputDir.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 {
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 directory", m.outputDir, m.focusIndex == fieldOutputDir))
b.WriteString("\n\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.protonDrive.Render(m.focusIndex == fieldProtonDrive))
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("\n\n")
ignoreLabel := labelStyle.Render("Ignore patterns:")
if m.focusIndex == fieldIgnorePatterns {
ignoreLabel = focusedStyle.Bold(true).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)
}
b.WriteString(btnStyle.Render("[ Generate ]"))
b.WriteString(" ")
cancelStyle := labelStyle
if m.focusIndex == fieldCancelBtn {
cancelStyle = focusedStyle.Bold(true)
}
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"))
return appBorder.Render(b.String())
}
func (m FormModel) renderTextField(label string, input textinput.Model, focused bool) string {
style := labelStyle
if focused {
style = focusedStyle
}
return fmt.Sprintf("%s %s", style.Render(label+":"), input.View())
}
func (m FormModel) ToConfig() (*config.Config, error) {
inputDir := m.inputDir.Value()
if inputDir == "" {
inputDir = "."
}
outputDir := m.outputDir.Value()
if outputDir == "" {
return nil, fmt.Errorf("output directory 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)
}
}
}
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: outputDir,
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,
}
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
usable := w - 30
if usable < 20 {
usable = 20
}
if usable > 60 {
usable = 60
}
m.inputDir.SetWidth(usable)
m.outputDir.SetWidth(usable)
m.drivePath.SetWidth(usable)
}
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
}