Files
bspviz/cmd/tui/main.go

472 lines
10 KiB
Go

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)
}
}