package main import ( "bytes" "fmt" "os" "path/filepath" "strconv" "strings" "github.com/charmbracelet/bubbles/filepicker" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "bspviz/internal/app" "bspviz/internal/geom" ) const ( inputWadPath = iota inputMapMarker inputExtract inputOutDir inputDotOut inputTreePNG inputOverlay inputAlpha inputBeta inputEps inputLeaf inputDepth inputCands inputSeed inputCount ) const ( toggleListOnly = iota toggleInfo toggleGeomTest toggleBuildBSP toggleCount ) var ( indicatorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) boxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Padding(0, 1) ) type runResultMsg struct { output string err error } type model struct { inputs []textinput.Model inputLabels []string toggles []bool toggleLabels []string focusIndex int status string output string errMsg string running bool showPicker bool picker filepicker.Model } func initialModel() model { m := model{ inputs: make([]textinput.Model, inputCount), inputLabels: []string{ "WAD", "Map", "Extract", "OutDir", "DotOut", "TreePNG", "Overlay", "Alpha", "Beta", "Eps", "LeafMax", "MaxDepth", "Cands", "Seed", }, toggles: make([]bool, toggleCount), toggleLabels: []string{"List directory", "Info", "Geom test", "Build BSP"}, status: "Bereit", } for i := range m.inputs { ti := textinput.New() ti.CharLimit = 0 ti.Prompt = "" switch i { case inputWadPath: ti.Placeholder = "Pfad zur WAD" case inputMapMarker: ti.SetValue("MAP01") case inputExtract: ti.Placeholder = "z.B. VERTEXES,LINEDEFS" case inputOutDir: ti.SetValue(".") case inputAlpha: ti.SetValue(fmt.Sprintf("%g", 10.0)) case inputBeta: ti.SetValue(fmt.Sprintf("%g", 1.0)) case inputEps: ti.SetValue(fmt.Sprintf("%g", geom.EPS)) case inputLeaf: ti.SetValue("12") case inputDepth: ti.SetValue("32") case inputCands: ti.SetValue("16") case inputSeed: ti.SetValue("0") } m.inputs[i] = ti } picker := filepicker.New() picker.AllowedTypes = []string{".wad", ".WAD"} picker.DirAllowed = false picker.FileAllowed = true picker.ShowPermissions = false picker.ShowSize = true picker.ShowHidden = false picker.AutoHeight = false picker.Height = 12 m.picker = picker m.setFocus(0) return m } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.showPicker { switch key := msg.(type) { case tea.KeyMsg: if key.Type == tea.KeyEsc { m.showPicker = false m.status = "Dateiauswahl abgebrochen" return m, nil } } var cmd tea.Cmd m.picker, cmd = m.picker.Update(msg) if selected, path := m.picker.DidSelectFile(msg); selected { m.inputs[inputWadPath].SetValue(path) m.showPicker = false m.status = "WAD gewählt" m.errMsg = "" m.setFocus(inputWadPath) return m, cmd } return m, cmd } switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": return m, tea.Quit case "tab", "down": m.setFocus(m.focusIndex + 1) case "shift+tab", "up": m.setFocus(m.focusIndex - 1) case "enter": if m.focusIndex < len(m.inputs) { m.setFocus(m.focusIndex + 1) } else { idx := m.focusIndex - len(m.inputs) if idx >= 0 && idx < len(m.toggles) { m.toggles[idx] = !m.toggles[idx] } } case " ": if m.focusIndex >= len(m.inputs) { idx := m.focusIndex - len(m.inputs) if idx >= 0 && idx < len(m.toggles) { m.toggles[idx] = !m.toggles[idx] } } case "f2": if !m.running { current := strings.TrimSpace(m.inputs[inputWadPath].Value()) if current != "" { info, err := os.Stat(current) if err == nil { if info.IsDir() { m.picker.CurrentDirectory = current } else { m.picker.CurrentDirectory = filepath.Dir(current) } } else { m.picker.CurrentDirectory = filepath.Dir(current) } } else { m.picker.CurrentDirectory = "." } m.showPicker = true m.status = "Datei auswählen" m.errMsg = "" return m, m.picker.Init() } case "ctrl+r": if !m.running { var cmd tea.Cmd m, cmd = m.run() return m, cmd } } case runResultMsg: m.running = false if msg.err != nil { m.errMsg = msg.err.Error() m.status = "Fehlgeschlagen" } else { m.errMsg = "" m.status = "Fertig" } m.output = strings.TrimSpace(msg.output) return m, nil } cmds := make([]tea.Cmd, len(m.inputs)) for i := range m.inputs { m.inputs[i], cmds[i] = m.inputs[i].Update(msg) } return m, tea.Batch(cmds...) } func (m model) View() string { var b strings.Builder b.WriteString("BSPViz Bubble Tea UI\n") b.WriteString("====================\n\n") labelWidth := 12 for i := range m.inputs { indicator := " " if m.focusIndex == i { indicator = indicatorStyle.Render(">") } label := fmt.Sprintf("%-*s", labelWidth, m.inputLabels[i]+":") fmt.Fprintf(&b, "%s %s %s\n", indicator, label, m.inputs[i].View()) } b.WriteString("\n") for i := range m.toggles { indicator := " " if m.focusIndex == len(m.inputs)+i { indicator = indicatorStyle.Render(">") } mark := " " if m.toggles[i] { mark = "x" } fmt.Fprintf(&b, "%s [%s] %s\n", indicator, mark, m.toggleLabels[i]) } b.WriteString("\n") statusText := "Bereit" if m.running { statusText = "Läuft..." } else if m.status != "" { statusText = m.status } b.WriteString(statusStyle.Render("Status: " + statusText)) b.WriteString("\n") if m.errMsg != "" { b.WriteString(errorStyle.Render("Fehler: " + m.errMsg)) b.WriteString("\n") } if m.showPicker { b.WriteString("\nDatei auswählen (Enter übernehmen, Esc abbrechen)\n") b.WriteString(boxStyle.Render(m.picker.View())) b.WriteString("\n") } if m.output != "" { b.WriteString("\n") b.WriteString(boxStyle.Render(m.output)) b.WriteString("\n") } b.WriteString("\n") b.WriteString("Steuerung: TAB/Shift+TAB navigieren • Space toggeln • Ctrl+R ausführen • F2 Datei wählen • Ctrl+C beenden") b.WriteString("\n") return b.String() } func (m model) run() (model, tea.Cmd) { opts, err := m.buildOptions() if err != nil { m.errMsg = err.Error() m.status = "Fehler" return m, nil } m.running = true m.status = "Läuft..." m.errMsg = "" m.output = "" return m, runAppCmd(opts) } func (m model) buildOptions() (app.Options, error) { var opts app.Options wadPath := strings.TrimSpace(m.inputs[inputWadPath].Value()) if wadPath == "" { return opts, fmt.Errorf("WAD-Pfad darf nicht leer sein") } opts.WadPath = wadPath mapMarker := strings.TrimSpace(m.inputs[inputMapMarker].Value()) if mapMarker == "" { mapMarker = "MAP01" } opts.MapMarker = mapMarker extractRaw := strings.TrimSpace(m.inputs[inputExtract].Value()) if extractRaw != "" { parts := strings.Split(extractRaw, ",") opts.Extract = make([]string, 0, len(parts)) for _, p := range parts { n := strings.ToUpper(strings.TrimSpace(p)) if n == "" { continue } opts.Extract = append(opts.Extract, n) } } outDir := strings.TrimSpace(m.inputs[inputOutDir].Value()) if outDir == "" { outDir = "." } opts.OutDir = outDir opts.DotOut = strings.TrimSpace(m.inputs[inputDotOut].Value()) opts.TreePNG = strings.TrimSpace(m.inputs[inputTreePNG].Value()) opts.Overlay = strings.TrimSpace(m.inputs[inputOverlay].Value()) alphaStr := strings.TrimSpace(m.inputs[inputAlpha].Value()) if alphaStr == "" { opts.Alpha = 10.0 } else { v, err := strconv.ParseFloat(alphaStr, 64) if err != nil { return opts, fmt.Errorf("Alpha ungültig: %w", err) } opts.Alpha = v } betaStr := strings.TrimSpace(m.inputs[inputBeta].Value()) if betaStr == "" { opts.Beta = 1.0 } else { v, err := strconv.ParseFloat(betaStr, 64) if err != nil { return opts, fmt.Errorf("Beta ungültig: %w", err) } opts.Beta = v } epsStr := strings.TrimSpace(m.inputs[inputEps].Value()) if epsStr == "" { opts.Eps = geom.EPS } else { v, err := strconv.ParseFloat(epsStr, 64) if err != nil { return opts, fmt.Errorf("Eps ungültig: %w", err) } opts.Eps = v } leafStr := strings.TrimSpace(m.inputs[inputLeaf].Value()) if leafStr == "" { opts.LeafMax = 12 } else { v, err := strconv.Atoi(leafStr) if err != nil { return opts, fmt.Errorf("LeafMax ungültig: %w", err) } opts.LeafMax = v } depthStr := strings.TrimSpace(m.inputs[inputDepth].Value()) if depthStr == "" { opts.MaxDepth = 32 } else { v, err := strconv.Atoi(depthStr) if err != nil { return opts, fmt.Errorf("MaxDepth ungültig: %w", err) } opts.MaxDepth = v } candsStr := strings.TrimSpace(m.inputs[inputCands].Value()) if candsStr == "" { opts.Cands = 16 } else { v, err := strconv.Atoi(candsStr) if err != nil { return opts, fmt.Errorf("Cands ungültig: %w", err) } opts.Cands = v } seedStr := strings.TrimSpace(m.inputs[inputSeed].Value()) if seedStr == "" { opts.Seed = 0 } else { v, err := strconv.ParseInt(seedStr, 10, 64) if err != nil { return opts, fmt.Errorf("Seed ungültig: %w", err) } opts.Seed = v } opts.ListOnly = m.toggles[toggleListOnly] opts.Info = m.toggles[toggleInfo] opts.GeomTest = m.toggles[toggleGeomTest] opts.BuildBSP = m.toggles[toggleBuildBSP] return opts, nil } func runAppCmd(opts app.Options) tea.Cmd { return func() tea.Msg { var buf bytes.Buffer opts.Out = &buf err := app.Run(opts) return runResultMsg{output: buf.String(), err: err} } } func (m *model) setFocus(index int) { total := len(m.inputs) + len(m.toggles) if total == 0 { return } if index < 0 { index = total - 1 } if index >= total { index = 0 } m.focusIndex = index for i := range m.inputs { if i == m.focusIndex { m.inputs[i].Focus() } else { m.inputs[i].Blur() } } } func main() { if _, err := tea.NewProgram(initialModel(), tea.WithAltScreen()).Run(); err != nil { fmt.Fprintf(os.Stderr, "UI error: %v\n", err) os.Exit(1) } }