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 }