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 fieldOutputDir fieldAppend fieldForce fieldVerticalSlices fieldSkipFrontend fieldFrontendOnly fieldOmitTest fieldOmitMd 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 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) outputDir := textinput.New() outputDir.Placeholder = "./output_dirmd" outputDir.SetValue("./output_dirmd") outputDir.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, outputDir: outputDir, 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.outputDir.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, fieldOutputDir, fieldDrivePath, fieldMaxDepth, fieldMaxSize, fieldIgnorePatterns: return true } return false } func (m *FormModel) focusActiveField() tea.Cmd { m.inputDir.Blur() m.outputDir.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 fieldOutputDir: cmd = m.outputDir.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 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 { usableWidth := m.width - 30 if usableWidth < 50 { usableWidth = 50 } if usableWidth > 80 { usableWidth = 80 } m.inputDir.SetWidth(usableWidth) m.outputDir.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 directory", m.outputDir, m.focusIndex == fieldOutputDir)) 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 = "." } 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) } } } 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: outputDir, 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 }