472 lines
10 KiB
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)
|
|
}
|
|
}
|