From b8e473b069d9c81e2f6a471fa7636c27cee63fae Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 23 Jun 2026 04:38:06 -0400 Subject: initial commit --- Makefile | 14 + README.md | 59 ++ app.go | 2248 ++++++++++++++++++++++++++++++++++++++++++++++++++ config.go | 114 +++ config.yaml | 334 ++++++++ go.mod | 34 + go.sum | 56 ++ io.go | 222 +++++ model.go | 267 ++++++ tools.go | 385 +++++++++ tui-asci-mapper.webp | Bin 0 -> 156004 bytes tui-ascii-mapper | Bin 0 -> 5926300 bytes 12 files changed, 3733 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 app.go create mode 100644 config.go create mode 100644 config.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 io.go create mode 100644 model.go create mode 100644 tools.go create mode 100644 tui-asci-mapper.webp create mode 100755 tui-ascii-mapper diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..079a468 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +.PHONY: build run test clean + +build: + go build -o tui-ascii-mapper . + +run: build + ./tui-ascii-mapper + +test: + go vet ./... + go run . 2>&1 | head -5 + +clean: + rm -f tui-ascii-mapper diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e509ef --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# tui-ascii-mapper + +A terminal-based map editor for creating ASCII maps with Unicode symbols and ANSI 256-color support. It supports both keyboard and mouse. Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea). + +![demo image](tui-asci-mapper.webp) + +## Features + +- **8 drawing tools**: Brush, Select, Erase, Fill (flood fill), Line, Rectangle, Circle, Text labels +- **30 built-in terrain types**: Or make your own. Choose multiple colors! +- **Unicode/ASCII toggle**: switch between Unicode symbols and ASCII fallbacks +- **256-color ANSI support**: built-in color picker to assign colors per terrain or text label +- **Submaps**: press enter to drill down into a sub-map (e.g. a town map) +- **Text labels**: place movable text strings on a layer above the map +- **Save/Load**: maps save in a custom YAML format, but have easy copy/paste + +## Controls + +### Keyboard + +| Key | Action | +| ------------------------- | ------------------------------------------- | +| Arrow keys / h/j/k/l | Move cursor | +| Space / Enter | Use tool at cursor / move text / make shape | +| 0-9 | Select symbol | +| Shift + 1-8 | Select tool | +| - / = | Previous/next palette page | +| [ / ] | Decrease/increase brush width | +| f | Toggle fill shapes | +| u | Toggle Unicode/ASCII modes | +| c (in Text tool) | Open color picker for text label | +| e (in Text tool) | Edit text label at cursor | +| Esc | Cancel / go up 1 map level | +| Backspace / x | Erase cell / delete text label | +| arrow keys (in text drag) | Move dragged text label | +| u | Undo | +| r | Redo | +| s | Save map | +| q | Quit | + + +## Configuration + +Edit `config.yaml` to customize terrain symbols, colors, keybindings, and default map size. The config is loaded from the directory next to the binary, or from `$XDG_CONFIG_HOME/tui-ascii-mapper/config.yaml`. + +## Building + +```bash +go build -o tui-ascii-mapper +./tui-ascii-mapper +``` + +# AI Disclosure + +This is 100% unreviewed, vibe coded slop. I needed a TUI ASCII map-making tool and couldn't find one and threw this together quickly. It has bugs. + +# License + +0BSD. Do anything you want. diff --git a/app.go b/app.go new file mode 100644 index 0000000..9de0476 --- /dev/null +++ b/app.go @@ -0,0 +1,2248 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const sidebarW = 18 + +var ( + toolbarStyle = lipgloss.NewStyle().Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252")) + sidebarStyle = lipgloss.NewStyle().Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252")) + statusStyle = lipgloss.NewStyle().Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252")) + accentBg = lipgloss.Color("33") + accentFg = lipgloss.Color("255") +) + +type AppModel struct { + map_ *Map + rootMap *Map + cursor Point + offset Point + prevCursor Point // cursor position before entering submap + + tool Tool + selected int + brushWidth int // 1, 3, or 5 + unicode bool + colorMode bool + fillShapes bool + + mode Mode + dialog DialogType + + ti textinput.Model + + lineStart Point + linePreview []Point + rectStart Point + rectPreview []Point + circleCenter Point + circlePreview []Point + + textEditing bool + textInput textinput.Model + textColor string + movingLabel int + dragLabelOrigin Point + dragMouseOrigin Point + textCursorStart Point // offset from mouse to text label start during drag + + mouseDown bool + mouseBtn int + mouseStart Point + lastPaint Point + drawHeld bool // space held down + eraseHeld bool // backspace held down + + undo *UndoStack + + width int + height int + quitting bool + cfg Config + dialogMsg string + + colorPicker *ColorPickerState + dirty bool + undoPosAtSave int + hotkeySelect [10]int + palettePage int // sidebar palette page (0 = symbols 0-9, 1 = 10-19, etc.) // which offset when multiple symbols share a hotkey + filePicker *FilePickerState +} + +type FilePickerState struct { + Files []os.DirEntry + CurDir string + Selected int + PopupX int + PopupY int + ListTop int +} + +type ColorPickerState struct { + Active bool + Cursor Point + Selected []string + ForText bool // true = editing text label color + GridX int + GridY int +} + +func (m *AppModel) curMap() *Map { return m.map_ } + +func (m *AppModel) curPalette() []Terrain { + if m.map_ != nil { + return m.map_.Palette + } + return nil +} + +func NewAppModel(cfg Config) *AppModel { + palette := make([]Terrain, len(cfg.Symbols)) + copy(palette, cfg.Symbols) + root := NewMap("untitled", cfg.DefaultMapWidth, cfg.DefaultMapHeight, palette) + + undo := &UndoStack{} + undo.Push(root) + undoAtSave := undo.pos // initial save point matches initial state + + ti := textinput.New() + ti.Placeholder = "" + ti.Prompt = "" + ti.CharLimit = 64 + + textTI := textinput.New() + textTI.Placeholder = "" + textTI.Prompt = "" + textTI.CharLimit = 256 + + return &AppModel{ + map_: root, + rootMap: root, + cursor: Point{X: 0, Y: 0}, + prevCursor: Point{X: -1, Y: -1}, + tool: ToolBrush, + brushWidth: 1, + selected: 0, + unicode: true, + colorMode: true, + mode: ModeNormal, + undo: undo, + cfg: cfg, + ti: ti, + textInput: textTI, + movingLabel: -1, + lastPaint: Point{X: -1, Y: -1}, + undoPosAtSave: undoAtSave, + } +} + +func (m *AppModel) Init() tea.Cmd { + return tea.Batch( + textinput.Blink, + tea.EnableMouseCellMotion, + ) +} + +func (m *AppModel) loadMapCmd(path string) tea.Cmd { + return func() tea.Msg { + md, err := DeserializeMap(path) + return mapLoadedMsg{data: md, err: err} + } +} + +type mapLoadedMsg struct { + data *Map + err error +} + +func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case mapLoadedMsg: + if msg.err != nil { + m.dialogMsg = fmt.Sprintf("Load error: %v", msg.err) + m.mode = ModeNormal + m.dialog = DialogNone + return m, nil + } + m.map_ = msg.data + m.rootMap = msg.data + m.undo = &UndoStack{} + m.undo.Push(m.map_) + m.cursor = Point{X: 0, Y: 0} + m.offset = Point{X: 0, Y: 0} + m.dialogMsg = fmt.Sprintf("Loaded %s", msg.data.Filename) + m.mode = ModeNormal + m.dialog = DialogNone + m.dirty = false + m.undoPosAtSave = m.undo.pos + return m, nil + + case tea.MouseMsg: + return m.handleMouse(msg) + + case tea.KeyMsg: + return m.handleKey(msg) + } + + if m.mode == ModeDialog || m.mode == ModeTextEdit { + var cmd tea.Cmd + m.ti, cmd = m.ti.Update(msg) + if cmd != nil { + return m, cmd + } + m.textInput, cmd = m.textInput.Update(msg) + if cmd != nil { + return m, cmd + } + } + return m, nil +} + +// --- Mouse handling --- + +func (m *AppModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + x, y := msg.X, msg.Y + + if m.colorPicker != nil && m.colorPicker.Active { + return m.handleColorPickerMouse(msg) + } + + isFilePicker := m.dialog == DialogFileSave || m.dialog == DialogFileOpen || m.dialog == DialogSaveAs || m.dialog == DialogOpenMap + if isFilePicker && m.filePicker != nil && m.filePicker.PopupY > 0 { + return m.handleFilePickerMouse(msg) + } + + isPress := msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress + isRelease := msg.Action == tea.MouseActionRelease + isRightPress := msg.Button == tea.MouseButtonRight && msg.Action == tea.MouseActionPress + + gridAreaW := m.width - sidebarW + gridH := m.height - 1 + if m.height > 6 { + gridH -= 2 + } + if gridH < 1 { + gridH = 1 + } + if gridAreaW < 1 { + gridAreaW = 1 + } + + if isPress && y == 0 { + m.handleToolbarClick(x) + if m.quitting { + return m, tea.Quit + } + return m, nil + } + + gridY := y - 1 + onSidebar := gridAreaW > 0 && x >= gridAreaW + + if isPress && onSidebar && gridY >= 0 { + m.handleSidebarClick(x, y) + return m, nil + } + + gx := x + m.offset.X + gy := gridY + m.offset.Y + inGrid := gridY >= 0 && gridY < gridH && x < gridAreaW + + if isPress { + m.mouseDown = true + m.lastPaint = Point{X: -1, Y: -1} + if inGrid { + p := Point{gx, gy} + switch m.tool { + case ToolLine: + m.mouseStart = p + m.mode = ModeLinePreview + m.linePreview = nil + case ToolRect: + m.mouseStart = p + m.mode = ModeRectPreview + m.rectPreview = nil + case ToolCircle: + m.mouseStart = p + m.mode = ModeCirclePreview + m.circlePreview = nil + case ToolSelect: + m.cursor = p + m.clampCursor() + default: + return m.mouseDraw(p.X, p.Y) + } + } + return m, nil + } + + if isRelease { + if m.mouseDown { + m.mouseRelease(gx, gy) + } + m.mouseDown = false + m.lastPaint = Point{X: -1, Y: -1} + return m, nil + } + + if isRightPress { + if inGrid { + if m.tool == ToolText { + idx := FindTextLabelAt(m.curMap(), Point{gx, gy}) + if idx >= 0 { + m.dirty = true + m.undo.Push(m.curMap()) + RemoveTextLabel(m.curMap(), m.curMap().TextLabels[idx].Start) + m.movingLabel = -1 + } + } else { + m.dirty = true + m.undo.Push(m.curMap()) + m.applyBrush(Point{gx, gy}, true) + m.lastPaint = Point{gx, gy} + } + } + return m, nil + } + + if m.mouseDown && inGrid && (gx != m.lastPaint.X || gy != m.lastPaint.Y) { + if m.movingLabel >= 0 && m.tool == ToolText { + m.cursor = Point{gx, gy} + m.clampCursor() + } else if m.tool == ToolLine { + m.linePreview = ThickenPoints(BresenhamLine(m.mouseStart, Point{gx, gy}), m.brushWidth) + m.lastPaint = Point{gx, gy} + } else if m.tool == ToolRect { + m.rectPreview = ThickenPoints(DrawRect(m.mouseStart, Point{gx, gy}, m.fillShapes), m.brushWidth) + m.lastPaint = Point{gx, gy} + } else if m.tool == ToolCircle { + m.circlePreview = ThickenPoints(DrawCircle(m.mouseStart, Point{gx, gy}, m.fillShapes), m.brushWidth) + m.lastPaint = Point{gx, gy} + } else if m.tool != ToolText { + m.mouseDraw(gx, gy) + } + } + + switch msg.Button { + case tea.MouseButtonWheelUp: + if m.offset.Y > 0 { + m.offset.Y-- + } + case tea.MouseButtonWheelDown: + maxY := m.curMap().Height - gridH + if maxY < 0 { + maxY = 0 + } + if m.offset.Y < maxY { + m.offset.Y++ + } + } + + return m, nil +} + +func (m *AppModel) mouseDraw(gx, gy int) (tea.Model, tea.Cmd) { + p := Point{gx, gy} + switch m.tool { + case ToolBrush: + m.dirty = true + m.undo.Push(m.curMap()) + m.applyBrush(p) + m.lastPaint = p + case ToolErase: + m.dirty = true + m.undo.Push(m.curMap()) + m.applyBrush(p, true) + m.lastPaint = p + case ToolFill: + m.dirty = true + m.undo.Push(m.curMap()) + m.fillAt(p) + m.lastPaint = p + case ToolText: + idx := FindTextLabelAt(m.curMap(), p) + if idx >= 0 { + m.movingLabel = idx + tl := m.curMap().TextLabels[idx] + m.dragLabelOrigin = tl.Start + m.dragMouseOrigin = p + m.lastPaint = p + } + } + m.cursor = p + m.clampCursor() + return m, nil +} + +func (m *AppModel) mouseRelease(gx, gy int) { + p := Point{gx, gy} + switch m.tool { + case ToolLine: + m.dirty = true + m.undo.Push(m.curMap()) + m.finishShape(m.mouseStart, p, ToolLine) + m.linePreview = nil + m.mode = ModeNormal + case ToolRect: + m.dirty = true + m.undo.Push(m.curMap()) + m.finishShape(m.mouseStart, p, ToolRect) + m.rectPreview = nil + m.mode = ModeNormal + case ToolCircle: + m.dirty = true + m.undo.Push(m.curMap()) + m.finishShape(m.mouseStart, p, ToolCircle) + m.circlePreview = nil + m.mode = ModeNormal + case ToolText: + if m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) { + old := m.curMap().TextLabels[m.movingLabel].Start + newPos := m.labelDragPos() + if old != newPos && m.curMap().InBounds(newPos) { + m.dirty = true + m.undo.Push(m.curMap()) + MoveTextLabel(m.curMap(), old, newPos) + } else { + m.startTextEditText(m.movingLabel) + } + } else if m.cursor == p && m.curMap().InBounds(p) { + m.startTextEdit(p) + } + m.movingLabel = -1 + m.dragLabelOrigin = Point{X: 0, Y: 0} + m.dragMouseOrigin = Point{X: 0, Y: 0} + } +} + +func (m *AppModel) finishShape(a, b Point, tool Tool) { + palette := m.curPalette() + terrain := m.selected + if terrain < 0 || terrain >= len(palette) { + return + } + var pts []Point + switch tool { + case ToolLine: + pts = BresenhamLine(a, b) + case ToolRect: + pts = DrawRect(a, b, m.fillShapes) + case ToolCircle: + pts = DrawCircle(a, b, m.fillShapes) + } + ApplyPoints(m.curMap(), pts, terrain, palette) + // Apply brush width thickening if > 1 + if m.brushWidth > 1 { + for _, pt := range pts { + Brush(m.curMap(), pt, terrain, m.brushWidth, palette) + } + } +} + +// --- Keyboard handling --- + +func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Clear save message on next key + m.dialogMsg = "" + if m.mode == ModeDialog { + return m.handleDialogKey(msg) + } + if m.colorPicker != nil && m.colorPicker.Active { + return m.handleColorPickerKey(msg) + } + if m.mode == ModeTextEdit { + return m.handleTextEditKey(msg) + } + + key := msg.String() + cfg := m.cfg.Keybindings + + switch key { + case cfg.Quit: + if m.curMap().Parent != nil { + m.drillUp() + } else if m.dirty { + m.mode = ModeDialog + m.dialog = DialogQuitConfirm + } else { + m.quitting = true + return m, tea.Quit + } + return m, nil + case cfg.Save: + m.saveMap() + return m, nil + case cfg.Undo: + if entry := m.undo.Undo(); entry != nil { + *entry.target = *entry.state + m.dirty = m.undo.pos != m.undoPosAtSave + } + return m, nil + case cfg.Redo: + if entry := m.undo.Redo(); entry != nil { + *entry.target = *entry.state + m.dirty = m.undo.pos != m.undoPosAtSave + } + return m, nil + case cfg.UnicodeToggle: + m.unicode = !m.unicode + return m, nil + case cfg.ColorToggle: + m.colorMode = !m.colorMode + return m, nil + case cfg.FillToggle, "f": + m.fillShapes = !m.fillShapes + return m, nil + case cfg.Resize: + m.mode = ModeDialog + m.dialog = DialogResize + m.ti.SetValue(fmt.Sprintf("%dx%d", m.curMap().Width, m.curMap().Height)) + m.ti.Focus() + return m, nil + case "!": + m.tool = ToolBrush + m.drawHeld, m.eraseHeld = false, false + case "@": + m.tool = ToolSelect + m.drawHeld, m.eraseHeld = false, false + case "#": + m.tool = ToolErase + m.drawHeld, m.eraseHeld = false, false + case "$": + m.tool = ToolFill + m.drawHeld, m.eraseHeld = false, false + case "%": + m.tool = ToolLine + m.drawHeld, m.eraseHeld = false, false + case "^": + m.tool = ToolRect + m.drawHeld, m.eraseHeld = false, false + case "&": + m.tool = ToolCircle + m.drawHeld, m.eraseHeld = false, false + case "*": + m.tool = ToolText + m.drawHeld, m.eraseHeld = false, false + case "(": + m.tool = ToolText + m.drawHeld, m.eraseHeld = false, false + + case "ctrl+1": + m.brushWidth = 1 + case "ctrl+2": + m.brushWidth = 3 + case "ctrl+3": + m.brushWidth = 5 + case "[": + if m.brushWidth > 1 { + m.brushWidth -= 2 + } + case "]": + if m.brushWidth < 5 { + m.brushWidth += 2 + } + case "enter": + if m.tool == ToolText { + return m.handleSpace() + } + if m.movingLabel >= 0 { + m.placeMovingLabel() + return m, nil + } + if m.mode == ModeLinePreview { + m.finalizeLinePreview() + } else if m.mode == ModeRectPreview { + m.finalizeRectPreview() + } else if m.mode == ModeCirclePreview { + m.finalizeCirclePreview() + } else if "enter" == cfg.DrillDown { + m.drillDown() + } + return m, nil + case cfg.DrillDown: + m.drillDown() + return m, nil + case cfg.DrillUp: + m.drillUp() + return m, nil + case cfg.DeleteSubmap: + if _, ok := m.curMap().Submaps[m.cursor]; ok { + m.mode = ModeDialog + m.dialog = DialogDeleteSubmapConfirm + } + return m, nil + case " ", "space": + return m.handleSpace() + case "backspace", "x": + return m.handleBackspace() + case "e": + if m.tool == ToolText { + return m.editTextAtCursor() + } + case "c": + if m.tool == ToolText { + idx := FindTextLabelAt(m.curMap(), m.cursor) + if idx >= 0 { + m.openColorPicker(true) + return m, nil + } + } + case "esc": + if m.movingLabel >= 0 { + m.movingLabel = -1 + m.dragLabelOrigin = Point{X: 0, Y: 0} + m.dragMouseOrigin = Point{X: 0, Y: 0} + return m, nil + } + m.cancelPreview() + return m, nil + case "up", "down", "left", "right", "h", "j", "k", "l": + switch key { + case "up", "k": + m.moveCursor(0, -1) + case "down", "j": + m.moveCursor(0, 1) + case "left", "h": + m.moveCursor(-1, 0) + case "right", "l": + m.moveCursor(1, 0) + } + if m.movingLabel < 0 && m.drawHeld { + m.dirty = true + m.undo.Push(m.curMap()) + m.applyBrush(m.cursor) + } else if m.movingLabel < 0 && m.eraseHeld { + m.dirty = true + m.undo.Push(m.curMap()) + m.applyBrush(m.cursor, true) + } + return m, nil + default: + if len(msg.Runes) == 1 { + r := msg.Runes[0] + switch r { + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + hk := (int(r-'0') + 9) % 10 + // Find all symbols with this hotkey (index % 10 == hk) + var indices []int + for i := range m.curPalette() { + if i%10 == hk { + indices = append(indices, i) + } + } + if len(indices) == 0 { + return m, nil + } + // If current selection shares this hotkey, cycle + curOff := m.hotkeySelect[hk] + if m.selected%10 == hk && containsInt(indices, m.selected) { + curOff = (curOff + 1) % len(indices) + } else { + curOff = 0 + } + m.hotkeySelect[hk] = curOff + m.selected = indices[curOff] + // Update page to show the selected symbol + m.palettePage = m.selected / 10 + case '=': + m.palettePageNext() + case '-': + m.palettePagePrev() + case '<', ',': + m.moveSymbolUp() + case '>', '.': + m.moveSymbolDown() + } + } + } + return m, nil +} + +func (m *AppModel) handleSpace() (tea.Model, tea.Cmd) { + m.drawHeld = false + m.eraseHeld = false + + // Finalize any active preview + if m.mode == ModeLinePreview { + m.finalizeLinePreview() + return m, nil + } + if m.mode == ModeRectPreview { + m.finalizeRectPreview() + return m, nil + } + if m.mode == ModeCirclePreview { + m.finalizeCirclePreview() + return m, nil + } + + if m.tool == ToolSelect { + return m, nil + } + + if m.tool == ToolLine && m.mode == ModeNormal { + m.lineStart = m.cursor + m.mode = ModeLinePreview + m.linePreview = nil + return m, nil + } + if m.tool == ToolRect && m.mode == ModeNormal { + m.rectStart = m.cursor + m.mode = ModeRectPreview + m.rectPreview = nil + return m, nil + } + if m.tool == ToolCircle && m.mode == ModeNormal { + m.circleCenter = m.cursor + m.mode = ModeCirclePreview + m.circlePreview = nil + return m, nil + } + if m.tool == ToolText { + if m.movingLabel >= 0 { + if m.movingLabel < len(m.curMap().TextLabels) { + old := m.curMap().TextLabels[m.movingLabel].Start + newPos := m.labelDragPos() + if old != newPos && m.curMap().InBounds(newPos) { + m.dirty = true + m.undo.Push(m.curMap()) + MoveTextLabel(m.curMap(), old, newPos) + } + } + m.movingLabel = -1 + return m, nil + } + idx := FindTextLabelAt(m.curMap(), m.cursor) + if idx >= 0 { + m.movingLabel = idx + tl := m.curMap().TextLabels[idx] + m.dragLabelOrigin = tl.Start + m.dragMouseOrigin = m.cursor + } else { + m.startTextEdit(m.cursor) + } + return m, nil + } + if m.tool == ToolErase { + m.dirty = true + m.undo.Push(m.curMap()) + m.applyBrush(m.cursor, true) + return m, nil + } + if m.tool == ToolFill { + m.dirty = true + m.undo.Push(m.curMap()) + m.fillAt(m.cursor) + return m, nil + } + // Brush: draw once + m.dirty = true + m.undo.Push(m.curMap()) + m.applyBrush(m.cursor) + return m, nil +} + +func (m *AppModel) handleBackspace() (tea.Model, tea.Cmd) { + m.drawHeld = false + m.eraseHeld = false + if m.mode == ModeLinePreview || m.mode == ModeRectPreview || m.mode == ModeCirclePreview { + m.cancelPreview() + return m, nil + } + if m.tool == ToolText { + idx := FindTextLabelAt(m.curMap(), m.cursor) + if idx >= 0 { + m.dirty = true + m.undo.Push(m.curMap()) + RemoveTextLabel(m.curMap(), m.curMap().TextLabels[idx].Start) + } + return m, nil + } + // Delete current cell + m.dirty = true + m.undo.Push(m.curMap()) + m.applyBrush(m.cursor, true) + return m, nil +} + +func (m *AppModel) finalizeLinePreview() { + m.dirty = true + m.undo.Push(m.curMap()) + ApplyPoints(m.curMap(), m.linePreview, m.selected, m.curPalette()) + m.linePreview = nil + m.mode = ModeNormal +} + +func (m *AppModel) finalizeRectPreview() { + m.dirty = true + m.undo.Push(m.curMap()) + ApplyPoints(m.curMap(), m.rectPreview, m.selected, m.curPalette()) + m.rectPreview = nil + m.mode = ModeNormal +} + +func (m *AppModel) finalizeCirclePreview() { + m.dirty = true + m.undo.Push(m.curMap()) + ApplyPoints(m.curMap(), m.circlePreview, m.selected, m.curPalette()) + m.circlePreview = nil + m.mode = ModeNormal +} + +func (m *AppModel) cancelPreview() { + m.linePreview = nil + m.rectPreview = nil + m.circlePreview = nil + m.mode = ModeNormal +} + +func (m *AppModel) moveCursor(dx, dy int) { + m.cursor.X = clamp(m.cursor.X+dx, 0, m.curMap().Width-1) + m.cursor.Y = clamp(m.cursor.Y+dy, 0, m.curMap().Height-1) + if m.mode == ModeLinePreview { + m.linePreview = ThickenPoints(BresenhamLine(m.lineStart, m.cursor), m.brushWidth) + } else if m.mode == ModeRectPreview { + m.rectPreview = ThickenPoints(DrawRect(m.rectStart, m.cursor, m.fillShapes), m.brushWidth) + } else if m.mode == ModeCirclePreview { + m.circlePreview = ThickenPoints(DrawCircle(m.circleCenter, m.cursor, m.fillShapes), m.brushWidth) + } +} + +func (m *AppModel) applyBrush(center Point, erase ...bool) { + if !m.curMap().InBounds(center) { + return + } + terrain := m.selected + if len(erase) > 0 && erase[0] { + terrain = -1 + } + palette := m.curPalette() + color := "" + if terrain >= 0 && terrain < len(palette) { + color = palette[terrain].PickColor() + } + switch m.tool { + case ToolBrush: + Brush(m.curMap(), center, terrain, m.brushWidth, palette) + case ToolErase: + Brush(m.curMap(), center, terrain, m.brushWidth, palette) + default: + if terrain >= 0 && terrain < len(palette) { + m.curMap().SetCell(center, terrain, color) + } else { + m.curMap().SetCell(center, terrain) + } + } +} + +func (m *AppModel) fillAt(p Point) { + if m.selected >= 0 && m.selected < len(m.curPalette()) { + FloodFill(m.curMap(), p, m.selected, m.curPalette()) + } +} + +func (m *AppModel) clampCursor() { + m.cursor.X = clamp(m.cursor.X, 0, m.curMap().Width-1) + m.cursor.Y = clamp(m.cursor.Y, 0, m.curMap().Height-1) +} + +// --- Text tool --- + +func (m *AppModel) startTextEdit(p Point) { + m.mode = ModeTextEdit + m.textInput.SetValue("") + m.textInput.Focus() + m.cursor = p + m.textCursorStart = p + m.movingLabel = -1 +} + +func (m *AppModel) startTextEditText(idx int) { + if idx < 0 || idx >= len(m.curMap().TextLabels) { + return + } + m.mode = ModeTextEdit + m.textInput.SetValue(m.curMap().TextLabels[idx].Text) + m.textInput.Focus() + m.textCursorStart = m.curMap().TextLabels[idx].Start + m.movingLabel = idx + m.textEditing = true +} + +func (m *AppModel) editTextAtCursor() (tea.Model, tea.Cmd) { + idx := FindTextLabelAt(m.curMap(), m.cursor) + if idx < 0 { + return m, nil + } + m.startTextEditText(idx) + return m, nil +} + +func (m *AppModel) placeMovingLabel() { + if m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) { + old := m.curMap().TextLabels[m.movingLabel].Start + newPos := m.labelDragPos() + if old != newPos && m.curMap().InBounds(newPos) { + m.dirty = true + m.undo.Push(m.curMap()) + MoveTextLabel(m.curMap(), old, newPos) + } + } + m.movingLabel = -1 + m.dragLabelOrigin = Point{X: 0, Y: 0} + m.dragMouseOrigin = Point{X: 0, Y: 0} +} + +func (m *AppModel) labelDragPos() Point { + return Point{ + m.dragLabelOrigin.X + (m.cursor.X - m.dragMouseOrigin.X), + m.dragLabelOrigin.Y + (m.cursor.Y - m.dragMouseOrigin.Y), + } +} + +func (m *AppModel) handleTextEditKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + toolKeys := map[string]Tool{ + "!": ToolBrush, "@": ToolSelect, "#": ToolErase, "$": ToolFill, + "%": ToolLine, "^": ToolRect, "&": ToolCircle, "*": ToolText, "(": ToolText, + } + if t, ok := toolKeys[key]; ok { + m.commitTextEdit() + m.tool = t + m.drawHeld, m.eraseHeld = false, false + return m, nil + } + + switch key { + case "esc": + m.mode = ModeNormal + m.movingLabel = -1 + m.textEditing = false + m.textInput.Blur() + return m, nil + case "backspace": + val := m.textInput.Value() + runes := []rune(val) + if len(runes) > 0 { + m.textInput.SetValue(string(runes[:len(runes)-1])) + } + return m, nil + case "enter": + m.commitTextEdit() + return m, nil + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m *AppModel) commitTextEdit() { + text := m.textInput.Value() + if text != "" { + m.dirty = true + m.undo.Push(m.curMap()) + if m.textEditing && m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) { + RemoveTextLabel(m.curMap(), m.curMap().TextLabels[m.movingLabel].Start) + m.textEditing = false + } + PlaceTextLabel(m.curMap(), m.cursor, text, m.textColor) + } + m.mode = ModeNormal + m.movingLabel = -1 + m.textEditing = false + m.textInput.Blur() +} + +// --- Dialogs --- + +func (m *AppModel) handleDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + isFilePicker := m.dialog == DialogFileSave || m.dialog == DialogFileOpen || m.dialog == DialogSaveAs || m.dialog == DialogOpenMap + if isFilePicker && m.filePicker != nil { + switch key { + case "esc": + m.mode = ModeNormal + m.dialog = DialogNone + m.filePicker = nil + return m, nil + case "up", "k": + if m.filePicker.Selected > 0 { + m.filePicker.Selected-- + } + return m, nil + case "down", "j": + if m.filePicker.Selected < len(m.filePicker.Files)-1 { + m.filePicker.Selected++ + } + return m, nil + case "pgup": + m.filePicker.Selected -= 10 + if m.filePicker.Selected < 0 { + m.filePicker.Selected = 0 + } + return m, nil + case "pgdown": + m.filePicker.Selected += 10 + if m.filePicker.Selected >= len(m.filePicker.Files) { + m.filePicker.Selected = len(m.filePicker.Files) - 1 + } + return m, nil + case "left", "h": + parent := filepath.Dir(m.filePicker.CurDir) + m.filePicker.CurDir = parent + m.filePicker.Selected = 0 + m.refreshFilePicker() + return m, nil + case "enter": + tiVal := strings.TrimSpace(m.ti.Value()) + if tiVal != "" { + m.filePicker = nil + return m.doFileAction(tiVal) + } + if m.filePicker.Selected >= 0 && m.filePicker.Selected < len(m.filePicker.Files) { + entry := m.filePicker.Files[m.filePicker.Selected] + if entry.IsDir() { + m.filePicker.CurDir = filepath.Join(m.filePicker.CurDir, entry.Name()) + m.filePicker.Selected = 0 + m.refreshFilePicker() + return m, nil + } + fullPath := filepath.Join(m.filePicker.CurDir, entry.Name()) + m.ti.SetValue(fullPath) + m.filePicker = nil + return m.doFileAction(fullPath) + } + return m, nil + } + var cmd tea.Cmd + m.ti, cmd = m.ti.Update(msg) + return m, cmd + } + + switch key { + case "esc": + if m.dialog == DialogQuitConfirm { + m.quitting = true + return m, tea.Quit + } + m.mode = ModeNormal + m.dialog = DialogNone + return m, nil + case "q": + if m.dialog == DialogQuitConfirm { + m.quitting = true + return m, tea.Quit + } + fallthrough + default: + var cmd tea.Cmd + m.ti, cmd = m.ti.Update(msg) + return m, cmd + case "enter": + switch m.dialog { + case DialogSaveAs: + name := m.ti.Value() + if name != "" { + m.rootMap.Filename = name + if err := SerializeMap(m.rootMap, name); err != nil { + m.dialogMsg = err.Error() + } else { + m.dialogMsg = fmt.Sprintf("Saved %s", name) + m.dirty = false + m.undoPosAtSave = m.undo.pos + } + } + case DialogResize: + var w, h int + val := m.ti.Value() + if n, _ := fmt.Sscanf(val, "%dx%d", &w, &h); n == 2 && w > 0 && h > 0 && w < 1000 && h < 1000 { + m.resizeMap(w, h) + } + case DialogQuitConfirm: + m.quitting = true + return m, tea.Quit + case DialogDeleteSubmapConfirm: + m.dirty = true + m.undo.Push(m.curMap()) + delete(m.curMap().Submaps, m.cursor) + case DialogRenameSymbol: + name := m.ti.Value() + if name != "" && m.selected < len(m.curPalette()) { + m.curPalette()[m.selected].Name = name + } + case DialogRenameMap: + name := m.ti.Value() + if name != "" { + m.curMap().Name = name + } + case DialogOpenMap: + name := m.ti.Value() + if name != "" { + return m, m.loadMapCmd(name) + } + } + m.mode = ModeNormal + m.dialog = DialogNone + return m, nil + } +} + +func (m *AppModel) doFileAction(path string) (tea.Model, tea.Cmd) { + switch m.dialog { + case DialogFileSave, DialogSaveAs: + m.rootMap.Filename = path + if err := SerializeMap(m.rootMap, path); err != nil { + m.dialogMsg = err.Error() + } else { + m.dialogMsg = fmt.Sprintf("Saved %s", path) + m.dirty = false + m.undoPosAtSave = m.undo.pos + } + case DialogFileOpen, DialogOpenMap: + m.mode = ModeNormal + m.dialog = DialogNone + return m, m.loadMapCmd(path) + } + m.mode = ModeNormal + m.dialog = DialogNone + return m, nil +} + +func (m *AppModel) handleFilePickerMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + _, y := msg.X, msg.Y + if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress { + return m, nil + } + entryY := y - m.filePicker.PopupY + if entryY >= 0 && entryY < len(m.filePicker.Files) { + idx := entryY + m.filePicker.ListTop + if idx >= 0 && idx < len(m.filePicker.Files) { + m.filePicker.Selected = idx + entry := m.filePicker.Files[idx] + if entry.IsDir() { + m.filePicker.CurDir = filepath.Join(m.filePicker.CurDir, entry.Name()) + m.filePicker.Selected = 0 + m.refreshFilePicker() + } else { + fullPath := filepath.Join(m.filePicker.CurDir, entry.Name()) + m.ti.SetValue(fullPath) + m.filePicker = nil + return m.doFileAction(fullPath) + } + } + } + return m, nil +} + +func (m *AppModel) saveMap() { + if m.rootMap.Filename == "" { + m.mode = ModeDialog + m.dialog = DialogFileSave + m.openFilePicker() + m.ti.Focus() + return + } + if err := SerializeMap(m.rootMap, m.rootMap.Filename); err != nil { + m.dialogMsg = fmt.Sprintf("Save error: %v", err) + } else { + m.dialogMsg = fmt.Sprintf("Saved %s", m.rootMap.Filename) + m.dirty = false + m.undoPosAtSave = m.undo.pos + } +} + +func (m *AppModel) openFilePicker() { + cur := "." + m.filePicker = &FilePickerState{CurDir: cur, Selected: 0} + m.refreshFilePicker() + m.ti.SetValue("") +} + +func (m *AppModel) refreshFilePicker() { + entries, err := os.ReadDir(m.filePicker.CurDir) + if err != nil { + m.filePicker.Files = nil + return + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].IsDir() != entries[j].IsDir() { + return entries[i].IsDir() + } + return entries[i].Name() < entries[j].Name() + }) + m.filePicker.Files = entries + if m.filePicker.Selected >= len(entries) { + m.filePicker.Selected = len(entries) - 1 + } + if m.filePicker.Selected < 0 { + m.filePicker.Selected = 0 + } +} + +func (m *AppModel) doFilePickerSelect() { + if m.filePicker == nil || len(m.filePicker.Files) == 0 { + return + } + entry := m.filePicker.Files[m.filePicker.Selected] + if entry.IsDir() { + m.filePicker.CurDir = filepath.Join(m.filePicker.CurDir, entry.Name()) + m.filePicker.Selected = 0 + m.refreshFilePicker() + return + } + m.ti.SetValue(filepath.Join(m.filePicker.CurDir, entry.Name())) +} + +// --- Color picker --- + +func (m *AppModel) openColorPicker(forText bool) { + m.colorPicker = &ColorPickerState{ + Active: true, + Cursor: Point{X: -1, Y: -1}, + Selected: nil, + ForText: forText, + } + if !forText && m.selected < len(m.curPalette()) { + for _, c := range m.curPalette()[m.selected].Colors { + m.colorPicker.Selected = append(m.colorPicker.Selected, c.Color) + } + } +} + +func (m *AppModel) handleColorPickerMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + x, y := msg.X, msg.Y + col := (x - m.colorPicker.GridX) / 2 + row := y - m.colorPicker.GridY + + if msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress { + if row >= 0 && row < 16 && col >= 0 && col < 16 { + idx := row*16 + col + cstr := fmt.Sprintf("%d", idx) + found := false + for i, c := range m.colorPicker.Selected { + if c == cstr { + m.colorPicker.Selected = append(m.colorPicker.Selected[:i], m.colorPicker.Selected[i+1:]...) + found = true + break + } + } + if !found { + m.colorPicker.Selected = append(m.colorPicker.Selected, cstr) + } + } + return m, nil + } + if msg.Action == tea.MouseActionRelease { + m.colorPicker.Cursor = Point{X: -1, Y: -1} + return m, nil + } + if row >= 0 && row < 16 && col >= 0 && col < 16 { + m.colorPicker.Cursor = Point{X: col, Y: row} + } + return m, nil +} + +func (m *AppModel) handleColorPickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + if m.colorPicker.Cursor.X < 0 { + m.colorPicker.Cursor = Point{X: 0, Y: 0} + } + switch key { + case "esc": + m.colorPicker = nil + return m, nil + case "enter": + if m.colorPicker.ForText { + idx := FindTextLabelAt(m.curMap(), m.cursor) + if idx >= 0 && len(m.colorPicker.Selected) > 0 { + m.dirty = true + m.undo.Push(m.curMap()) + m.curMap().TextLabels[idx].Color = m.colorPicker.Selected[0] + } + } else { + if m.selected < len(m.curPalette()) { + var colors []TerrainColor + for _, c := range m.colorPicker.Selected { + colors = append(colors, TerrainColor{Color: c, Weight: 100}) + } + if len(colors) > 0 { + m.curPalette()[m.selected].Colors = colors + } + } + } + m.colorPicker = nil + return m, nil + case "space": + idx := m.colorPicker.Cursor.Y*16 + m.colorPicker.Cursor.X + cstr := fmt.Sprintf("%d", idx) + found := false + for i, c := range m.colorPicker.Selected { + if c == cstr { + m.colorPicker.Selected = append(m.colorPicker.Selected[:i], m.colorPicker.Selected[i+1:]...) + found = true + break + } + } + if !found { + m.colorPicker.Selected = append(m.colorPicker.Selected, cstr) + } + case "up", "k": + m.colorPicker.Cursor.Y = (m.colorPicker.Cursor.Y - 1 + 16) % 16 + case "down", "j": + m.colorPicker.Cursor.Y = (m.colorPicker.Cursor.Y + 1) % 16 + case "left", "h": + m.colorPicker.Cursor.X = (m.colorPicker.Cursor.X - 1 + 16) % 16 + case "right", "l": + m.colorPicker.Cursor.X = (m.colorPicker.Cursor.X + 1) % 16 + case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9": + // Direct color numeral input — not used + } + return m, nil +} + +// --- Submaps --- + +func (m *AppModel) drillDown() { + m.cancelPreview() + sub, ok := m.curMap().Submaps[m.cursor] + if !ok { + sub = NewMap(fmt.Sprintf("%s/sub", m.curMap().Name), + m.cfg.DefaultMapWidth, m.cfg.DefaultMapHeight, + m.curMap().Palette) + sub.Parent = m.curMap() + sub.Filename = m.rootMap.Filename + m.curMap().Submaps[m.cursor] = sub + } + m.prevCursor = m.cursor + m.map_ = sub + m.cursor = Point{X: 0, Y: 0} + m.offset = Point{X: 0, Y: 0} +} + +func (m *AppModel) drillUp() { + if m.curMap().Parent == nil { + return + } + if m.isMapBlank(m.curMap()) { + delete(m.curMap().Parent.Submaps, m.prevCursor) + } + parent := m.curMap().Parent + m.map_ = parent + if m.prevCursor.X >= 0 { + m.cursor = m.prevCursor + } else { + m.cursor = Point{X: -1, Y: -1} + } + m.offset = Point{X: 0, Y: 0} + m.prevCursor = Point{X: -1, Y: -1} +} + +func (m *AppModel) isMapBlank(mm *Map) bool { + for y := range mm.Grid { + for x := range mm.Grid[y] { + if mm.Grid[y][x].Terrain >= 0 || mm.Grid[y][x].Text != "" { + return false + } + } + } + return len(mm.Submaps) == 0 +} + +// --- Toolkit --- + +func (m *AppModel) handleToolbarClick(x int) { + widths := []int{16, 10, 21, 5, 5, 5, 6, 6, 6} + pos := 0 + for i, w := range widths { + if x >= pos && x < pos+w { + switch i { + case 0: // Name + m.mode = ModeDialog + m.dialog = DialogRenameMap + m.ti.SetValue(m.curMap().Name) + m.ti.Focus() + case 1: // Size + m.mode = ModeDialog + m.dialog = DialogResize + m.ti.SetValue(fmt.Sprintf("%dx%d", m.curMap().Width, m.curMap().Height)) + m.ti.Focus() + case 2: // File + m.mode = ModeDialog + m.dialog = DialogFileSave + m.openFilePicker() + m.ti.SetValue(m.rootMap.Filename) + m.ti.Focus() + case 3: // Uni + m.unicode = !m.unicode + case 4: // Col + m.colorMode = !m.colorMode + case 5: // Fil + m.fillShapes = !m.fillShapes + case 6: // Save + m.saveMap() + case 7: // Load + m.mode = ModeDialog + m.dialog = DialogFileOpen + m.openFilePicker() + m.ti.Focus() + case 8: // Quit + if m.dirty { + m.mode = ModeDialog + m.dialog = DialogQuitConfirm + } else { + m.quitting = true + } + } + return + } + pos += w + } +} + +func (m *AppModel) handleSidebarClick(x, y int) { + relY := y - 1 + + // Palette symbols: rows 1-10 + if relY >= 1 && relY <= 10 { + idx := m.palettePage*10 + (relY - 1) + if idx < len(m.curPalette()) { + m.selected = idx + } + return + } + + // Page buttons: row 11 + if relY == 11 { + if x < m.width-sidebarW+7 { + m.palettePagePrev() + } else { + m.palettePageNext() + } + return + } + + // Palette management: rows 12-14 + if relY == 12 { + if x < m.width-sidebarW+8 { + m.addSymbol() + } else { + m.removeSymbol() + } + return + } + if relY == 13 { + if x < m.width-sidebarW+8 { + m.moveSymbolUp() + } else { + m.moveSymbolDown() + } + return + } + if relY == 14 { + if x < m.width-sidebarW+10 { + m.mode = ModeDialog + m.dialog = DialogRenameSymbol + m.ti.SetValue(m.curPalette()[clamp(m.selected, 0, len(m.curPalette())-1)].Name) + m.ti.Focus() + } else { + m.openColorPicker(false) + } + return + } + + // Tools: rows 16+, then 3 brush width rows + toolRow := relY - 16 + if toolRow >= 0 && toolRow < 8 { + m.tool = Tool(toolRow) + } + bwRow := relY - 25 + if bwRow >= 0 && bwRow < 3 { + m.brushWidth = []int{1, 3, 5}[bwRow] + } +} + +func (m *AppModel) addSymbol() { + p := m.curPalette() + if len(p) >= 10 { + return + } + m.dirty = true + m.undo.Push(m.curMap()) + m.curMap().Palette = append(p, Terrain{Name: "new", Symbol: "?", ASCII: "?", Colors: []TerrainColor{{Color: "255", Weight: 100}}}) +} + +func (m *AppModel) removeSymbol() { + if len(m.curPalette()) <= 1 { + return + } + m.dirty = true + m.undo.Push(m.curMap()) + idx := clamp(m.selected, 0, len(m.curPalette())-1) + for y := range m.curMap().Grid { + for x := range m.curMap().Grid[y] { + c := &m.curMap().Grid[y][x] + if c.Terrain == idx { + c.Terrain = -1 + } else if c.Terrain > idx { + c.Terrain-- + } + } + } + m.curMap().Palette = append(m.curMap().Palette[:idx], m.curMap().Palette[idx+1:]...) + if m.selected >= len(m.curPalette()) { + m.selected = len(m.curPalette()) - 1 + } +} + +func (m *AppModel) moveSymbolUp() { + if m.selected <= 0 || m.selected >= len(m.curPalette()) { + return + } + m.dirty = true + m.undo.Push(m.curMap()) + i := m.selected + m.curPalette()[i], m.curPalette()[i-1] = m.curPalette()[i-1], m.curPalette()[i] + for y := range m.curMap().Grid { + for x := range m.curMap().Grid[y] { + c := &m.curMap().Grid[y][x] + if c.Terrain == i { + c.Terrain = i - 1 + } else if c.Terrain == i-1 { + c.Terrain = i + } + } + } + m.selected = i - 1 +} + +func (m *AppModel) moveSymbolDown() { + if m.selected < 0 || m.selected >= len(m.curPalette())-1 { + return + } + m.dirty = true + m.undo.Push(m.curMap()) + i := m.selected + m.curPalette()[i], m.curPalette()[i+1] = m.curPalette()[i+1], m.curPalette()[i] + for y := range m.curMap().Grid { + for x := range m.curMap().Grid[y] { + c := &m.curMap().Grid[y][x] + if c.Terrain == i { + c.Terrain = i + 1 + } else if c.Terrain == i+1 { + c.Terrain = i + } + } + } + m.selected = i + 1 +} + +func (m *AppModel) palettePagePrev() { + totalPages := (len(m.curPalette()) + 9) / 10 + if totalPages <= 1 { + return + } + m.palettePage = (m.palettePage - 1 + totalPages) % totalPages +} + +func (m *AppModel) palettePageNext() { + totalPages := (len(m.curPalette()) + 9) / 10 + if totalPages <= 1 { + return + } + m.palettePage = (m.palettePage + 1) % totalPages +} + +func (m *AppModel) resizeMap(w, h int) { + m.dirty = true + m.undo.Push(m.curMap()) + old := m.curMap() + newGrid := make([][]Cell, h) + for y := range newGrid { + newGrid[y] = make([]Cell, w) + for x := range newGrid[y] { + if y < old.Height && x < old.Width { + newGrid[y][x] = old.Grid[y][x] + } else { + newGrid[y][x].Terrain = -1 + } + } + } + old.Grid = newGrid + old.Width = w + old.Height = h + m.cursor.X = clamp(m.cursor.X, 0, w-1) + m.cursor.Y = clamp(m.cursor.Y, 0, h-1) +} + +// --- Rendering --- + +func (m *AppModel) View() string { + if m.quitting { + return "" + } + + if m.mode == ModeDialog { + return m.renderDialogFullscreen() + } + if m.colorPicker != nil && m.colorPicker.Active { + return m.renderColorPickerFullscreen() + } + return m.baseView() +} + +func (m *AppModel) baseView() string { + gridAreaW := m.width - sidebarW + gridH := m.height - 1 + showStatusHelp := m.height > 6 + if showStatusHelp { + gridH -= 2 + } + if gridAreaW < 1 { + gridAreaW = 1 + } + if gridH < 1 { + gridH = 1 + } + m.scrollToCursor(gridAreaW, gridH) + + var sb strings.Builder + sb.WriteString(m.renderToolbar()) + sb.WriteByte('\n') + + gridLines := strings.Split(m.renderGrid(gridAreaW, gridH), "\n") + sidebarLines := strings.Split(m.renderSidebar(gridH), "\n") + n := max(len(gridLines), len(sidebarLines)) + for i := 0; i < n; i++ { + if i < len(gridLines) { + sb.WriteString(padRight(gridLines[i], gridAreaW)) + } else { + sb.WriteString(strings.Repeat(" ", gridAreaW)) + } + if i < len(sidebarLines) { + sb.WriteString(sidebarLines[i]) + } + sb.WriteByte('\n') + } + if showStatusHelp { + sb.WriteString(m.renderStatus()) + sb.WriteByte('\n') + sb.WriteString(m.renderHelp()) + } + return sb.String() +} + +func (m *AppModel) showFeedback(msg string) { + m.dialogMsg = msg +} + +func (m *AppModel) renderDialogBox() string { + switch m.dialog { + case DialogSaveAs: + return m.renderFilePickerPopup("Save As") + case DialogOpenMap: + return m.renderFilePickerPopup("Open Map") + case DialogFileSave: + return m.renderFilePickerPopup("Save As") + case DialogFileOpen: + return m.renderFilePickerPopup("Open Map") + case DialogResize: + return renderPopup("Resize Map", "Size (e.g. 80x25):", m.ti.View()) + case DialogQuitConfirm: + return renderPopup("Quit", "Quit without saving?", "[Enter] Quit [Esc] Cancel") + case DialogDeleteSubmapConfirm: + return renderPopup("Delete Submap", + fmt.Sprintf("Delete submap at %d,%d?", m.cursor.X, m.cursor.Y), + "[Enter] Confirm [Esc] Cancel") + case DialogRenameSymbol: + return renderPopup("Rename Symbol", "New name:", m.ti.View()) + case DialogRenameMap: + return renderPopup("Rename Map", "New name:", m.ti.View()) + } + return "" +} + +func (m *AppModel) renderDialogFullscreen() string { + popup := m.renderDialogBox() + return m.centeredFullscreen(popup) +} + +func (m *AppModel) renderFilePickerPopup(title string) string { + if m.filePicker == nil { + return renderPopup(title, "", "Loading...") + } + + var sb strings.Builder + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")).Render(title)) + sb.WriteString("\n\n") + sb.WriteString("Path: ") + sb.WriteString(m.filePicker.CurDir) + sb.WriteString("\n") + sb.WriteString("File: ") + sb.WriteString(m.ti.View()) + sb.WriteString("\n\n") + + dirStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + fileStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + selStyle := lipgloss.NewStyle().Background(lipgloss.Color("33")).Foreground(lipgloss.Color("0")) + sizeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243")) + + maxShow := 15 + start := m.filePicker.Selected - maxShow/2 + if start < 0 { + start = 0 + } + end := start + maxShow + if end > len(m.filePicker.Files) { + end = len(m.filePicker.Files) + start = end - maxShow + if start < 0 { + start = 0 + } + } + + for i := start; i < end; i++ { + entry := m.filePicker.Files[i] + name := entry.Name() + var line string + + if entry.IsDir() { + line = dirStyle.Render(name + "/") + } else { + info, err := entry.Info() + if err == nil { + line = fmt.Sprintf("%s %s", sizeStyle.Render(formatSize(info.Size())), fileStyle.Render(name)) + } else { + line = fmt.Sprintf("%s %s", sizeStyle.Render(" ???"), fileStyle.Render(name)) + } + } + + if i == m.filePicker.Selected { + line = selStyle.Render(fmt.Sprintf(" >%s", line)) + } else { + line = fmt.Sprintf(" %s", line) + } + sb.WriteString(line) + sb.WriteByte('\n') + } + + if len(m.filePicker.Files) == 0 { + sb.WriteString(" (empty directory)\n") + } + + sb.WriteString("\n[Enter] Select [Esc] Cancel [Left] Up") + + w := 55 + popup := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Background(lipgloss.Color("234")). + Padding(1, 2). + Width(w). + Render(sb.String()) + + popupW := lipgloss.Width(popup) + popupH := lipgloss.Height(popup) + m.filePicker.PopupX = (m.width-popupW)/2 + 1 + 2 + m.filePicker.PopupY = (m.height-popupH)/2 + 7 + m.filePicker.ListTop = start + if m.filePicker.PopupX < 0 { + m.filePicker.PopupX = 0 + } + if m.filePicker.PopupY < 0 { + m.filePicker.PopupY = 0 + } + + return popup +} + +func (m *AppModel) centeredFullscreen(content string) string { + return lipgloss.Place(m.width, m.height, + lipgloss.Center, lipgloss.Center, + content) +} + +func renderPopup(title, label, value string) string { + lines := []string{ + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")).Render(title), + "", + label + " " + value, + } + w := 40 + for i, l := range lines { + lines[i] = lipgloss.NewStyle().Width(w).Render(l) + } + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Background(lipgloss.Color("234")). + Padding(1, 2). + Width(w + 4). + Render(strings.Join(lines, "\n")) +} + +func (m *AppModel) scrollToCursor(gw, gh int) { + if m.cursor.X < m.offset.X { + m.offset.X = m.cursor.X + } + if m.cursor.X >= m.offset.X+gw { + m.offset.X = m.cursor.X - gw + 1 + } + if m.cursor.Y < m.offset.Y { + m.offset.Y = m.cursor.Y + } + if m.cursor.Y >= m.offset.Y+gh { + m.offset.Y = m.cursor.Y - gh + 1 + } + m.offset.X = clamp(m.offset.X, 0, max(0, m.curMap().Width-gw)) + m.offset.Y = clamp(m.offset.Y, 0, max(0, m.curMap().Height-gh)) +} + +func (m *AppModel) renderToolbar() string { + items := []struct { + label string + width int + }{ + {"Name", 16}, + {"Size", 10}, + {"File", 21}, + {"Uni", 5}, + {"Col", 5}, + {"Fil", 5}, + {"Save", 6}, + {"Load", 6}, + {"Quit", 6}, + } + var styles []string + for i, it := range items { + var txt string + on := false + switch i { + case 0: + txt = " " + truncate(m.curMap().Name, it.width-2) + " " + case 1: + txt = fmt.Sprintf(" %dx%d ", m.curMap().Width, m.curMap().Height) + case 2: + txt = " " + truncate(shortFilename(m.rootMap.Filename), it.width-2) + " " + case 3: + txt = "[Uni]" + on = m.unicode + case 4: + txt = "[Col]" + on = m.colorMode + case 5: + txt = "[Fil]" + on = m.fillShapes + case 6: + txt = "[Save]" + if strings.Contains(m.dialogMsg, "Saved") { + on = true + } + case 7: + txt = "[Load]" + case 8: + txt = "[Quit]" + } + s := lipgloss.NewStyle().Width(it.width) + if on { + s = s.Background(lipgloss.Color("33")).Foreground(lipgloss.Color("0")) + } else { + s = s.Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252")) + } + styles = append(styles, s.Render(txt)) + } + return toolbarStyle.Width(m.width).Render(lipgloss.JoinHorizontal(lipgloss.Top, styles...)) +} + +func truncate(s string, w int) string { + r := []rune(s) + if len(r) <= w { + return s + } + return string(r[:max(0, w-1)]) + "\u2026" +} + +func (m *AppModel) renderGrid(gw, gh int) string { + var sb strings.Builder + for row := 0; row < gh; row++ { + my := row + m.offset.Y + for col := 0; col < gw; col++ { + mx := col + m.offset.X + p := Point{mx, my} + if !m.curMap().InBounds(p) { + sb.WriteString(m.cellStr("·", "240", "", false, false, false)) + continue + } + cell := m.curMap().CellAt(p) + isCursor := !m.mouseDown && p == m.cursor + isPreview := m.isPreviewCell(p) + hasSub := false + if _, ok := m.curMap().Submaps[p]; ok { + hasSub = true + } + + var sym string + var fg string + var bg string + + showText := cell.Text != "" + if showText && m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) { + tl := m.curMap().TextLabels[m.movingLabel] + runes := []rune(tl.Text) + for i := range runes { + if tl.Start.X+i == p.X && tl.Start.Y == p.Y { + showText = false + break + } + } + } + if showText { + sym = cell.Text + fg = "15" + } else if cell.Terrain >= 0 && cell.Terrain < len(m.curPalette()) { + sym = m.curPalette()[cell.Terrain].GetSymbol(m.unicode) + } else { + sym = " " + } + + if cell.Text == "" && m.colorMode { + if cell.Terrain >= 0 && cell.Color != "" { + fg = cell.Color + } else if cell.Terrain >= 0 { + fg = "255" + } + } + if hasSub { + bg = m.cfg.SubmapBg + } + if isPreview && sym == " " { + sym = "·" + fg = "250" + } + + // Live text preview with cursor + if m.mode == ModeTextEdit { + text := m.textInput.Value() + cursorCh := "" + if m.textInput.Focused() { + cursorCh = "\u2502" + } + // Render text anchored at textCursorStart + runes := []rune(text + cursorCh) + for i, r := range runes { + if p.X == m.textCursorStart.X+i && p.Y == m.textCursorStart.Y { + sym = string(r) + fg = "15" + break + } + } + // Cursor highlight at position after text + } + // Moving text label preview + if m.mode != ModeTextEdit && m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) { + tl := m.curMap().TextLabels[m.movingLabel] + pos := m.labelDragPos() + runes := []rune(tl.Text) + for i, r := range runes { + if p.X == pos.X+i && p.Y == pos.Y { + sym = string(r) + fg = "15" + break + } + } + } + + sb.WriteString(m.cellStr(sym, fg, bg, isCursor, isPreview, false)) + } + if row < gh-1 { + sb.WriteByte('\n') + } + } + return sb.String() +} + +func (m *AppModel) isPreviewCell(p Point) bool { + for _, pt := range m.linePreview { + if pt == p { + return true + } + } + for _, pt := range m.rectPreview { + if pt == p { + return true + } + } + for _, pt := range m.circlePreview { + if pt == p { + return true + } + } + return false +} + +func (m *AppModel) cellStr(sym, fg, bg string, cursor, preview, reverse bool) string { + if cursor { + return styledCell(sym, "0", "15", true) + } + if preview { + return styledCell(sym, fg, "240", false) + } + if bg != "" || fg != "" { + return styledCell(sym, fg, bg, false) + } + return sym +} + +func styledCell(sym, fg, bg string, reverse bool) string { + var parts []string + if reverse { + parts = append(parts, "\033[7m") + } else { + if bg != "" { + parts = append(parts, "\033[48;5;"+bg+"m") + } + if fg != "" { + parts = append(parts, "\033[38;5;"+fg+"m") + } + } + if len(parts) > 0 { + parts = append(parts, sym, "\033[0m") + return strings.Join(parts, "") + } + return sym +} + +func (m *AppModel) renderSidebar(gh int) string { + palette := m.curPalette() + totalPages := (len(palette) + 9) / 10 + startIdx := m.palettePage * 10 + endIdx := startIdx + 10 + if endIdx > len(palette) { + endIdx = len(palette) + } + + var sb strings.Builder + sb.WriteString(sidebarStyle.Width(sidebarW).Render("══ Symbols ══")) + sb.WriteByte('\n') + for i := startIdx; i < endIdx; i++ { + t := palette[i] + idx := i % 10 + sym := t.GetSymbol(m.unicode) + fg := "252" + if m.colorMode && len(t.Colors) > 0 { + fg = t.Colors[0].Color + } + coloredSym := lipgloss.NewStyle().Foreground(lipgloss.Color(fg)).Render(sym) + label := fmt.Sprintf("%d %s %-9s", (idx+1)%10, coloredSym, t.Name) + if i == m.selected { + label = lipgloss.NewStyle(). + Background(accentBg).Foreground(accentFg). + Width(sidebarW).Render(label) + } else { + label = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). + Width(sidebarW).Render(label) + } + sb.WriteString(label) + sb.WriteByte('\n') + } + for i := endIdx - startIdx; i < 10; i++ { + sb.WriteByte('\n') + } + // Page buttons + pageLabel := fmt.Sprintf(" << page %d/%d >> ", m.palettePage+1, totalPages) + sb.WriteString(sidebarStyle.Width(sidebarW).Render(pageLabel)) + sb.WriteByte('\n') + sb.WriteString(lipgloss.NewStyle().Width(sidebarW).Render(" [+ Add] [- Del]")) + sb.WriteByte('\n') + sb.WriteString(lipgloss.NewStyle().Width(sidebarW).Render(" [▲ Up] [▼ Down]")) + sb.WriteByte('\n') + sb.WriteString(lipgloss.NewStyle().Width(sidebarW).Render(" [Rename] [Color]")) + sb.WriteByte('\n') + sb.WriteString(sidebarStyle.Width(sidebarW).Render("══ Tools ══")) + sb.WriteByte('\n') + tools := []Tool{ToolBrush, ToolSelect, ToolErase, ToolFill, ToolLine, ToolRect, ToolCircle, ToolText} + tlabels := []string{"1 Brush", "2 Select", "3 Erase", "4 Fill", "5 Line", "6 Rect", "7 Circle", "8 Text"} + for i, tn := range tlabels { + label := tn + if tools[i] == m.tool { + label = lipgloss.NewStyle().Background(accentBg).Foreground(accentFg).Width(sidebarW).Render(tn) + } + if label == tn { + label = lipgloss.NewStyle().Width(sidebarW).Render(label) + } + sb.WriteString(label) + sb.WriteByte('\n') + } + sb.WriteString(sidebarStyle.Width(sidebarW).Render("══ Brush W ══")) + sb.WriteByte('\n') + for _, bw := range []int{1, 3, 5} { + label := fmt.Sprintf(" %dx%d", bw, bw) + if bw == m.brushWidth { + label = lipgloss.NewStyle().Background(accentBg).Foreground(accentFg).Width(sidebarW).Render(label) + } else { + label = lipgloss.NewStyle().Width(sidebarW).Render(label) + } + sb.WriteString(label) + sb.WriteByte('\n') + } + _ = gh + return sb.String() +} + +func (m *AppModel) renderStatus() string { + cell := m.curMap().CellAt(m.cursor) + terrainName := "" + if cell.Terrain >= 0 && cell.Terrain < len(m.curPalette()) { + terrainName = m.curPalette()[cell.Terrain].Name + } + subInfo := "" + if _, ok := m.curMap().Submaps[m.cursor]; ok { + subInfo = " [submap]" + } + modeLabel := m.tool.String() + if m.drawHeld { + modeLabel += " [DRAW]" + } else if m.eraseHeld { + modeLabel += " [ERASE]" + } + feedback := "" + if m.dialogMsg != "" { + feedback = " " + m.dialogMsg + } + return statusStyle.Width(m.width).Render( + fmt.Sprintf(" %d,%d %s %s %s%s%s", + m.cursor.X, m.cursor.Y, + modeLabel, terrainName, m.curMap().Name, subInfo, feedback)) +} + +func (m *AppModel) renderHelp() string { + cfg := m.cfg.Keybindings + help := fmt.Sprintf(" %s:Save %s:Quit %s:Undo %s:Redo Space:Draw Bksp:Erase Enter:Sub Esc:Up Arrows:Move 1-8:Tools f:FillShp []:Width", + cfg.Save, cfg.Quit, cfg.Undo, cfg.Redo) + if len(help) > m.width && m.width > 3 { + help = help[:m.width-3] + "..." + } + return statusStyle.Width(m.width).Render(help) +} + +func (m *AppModel) renderColorPickerFullscreen() string { + var inner strings.Builder + inner.WriteString(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")).Render("Color Picker")) + inner.WriteString(" (space/click:toggle enter:apply esc:cancel)\n\n") + for row := 0; row < 16; row++ { + for col := 0; col < 16; col++ { + idx := row*16 + col + cstr := fmt.Sprintf("%d", idx) + sel := false + for _, c := range m.colorPicker.Selected { + if c == cstr { + sel = true + break + } + } + marker := " " + if sel { + marker = "● " + } + if row == m.colorPicker.Cursor.Y && col == m.colorPicker.Cursor.X { + marker = "○ " + } + // Use lipgloss style — compatible with borders + style := lipgloss.NewStyle().Background(lipgloss.Color(cstr)).Foreground(lipgloss.Color("255")) + inner.WriteString(style.Render(marker)) + } + inner.WriteByte('\n') + } + inner.WriteString("\nSelected: ") + for _, c := range m.colorPicker.Selected { + inner.WriteString(c + " ") + } + popup := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Background(lipgloss.Color("234")). + Padding(1, 2). + Render(inner.String()) + + popupW := lipgloss.Width(popup) + popupH := lipgloss.Height(popup) + gx := (m.width-popupW)/2 + 3 + gy := (m.height-popupH)/2 + 4 + if gx < 0 { + gx = 0 + } + if gy < 0 { + gy = 0 + } + m.colorPicker.GridX = gx + m.colorPicker.GridY = gy + + return m.centeredFullscreen(popup) +} + +func formatSize(size int64) string { + const unit = 1024 + if size < unit { + return fmt.Sprintf("%4dB", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%3.0f%c", float64(size)/float64(div), "KMGTPE"[exp]) +} + +func shortFilename(path string) string { + if path == "" { + return "(unsaved)" + } + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' || path[i] == '\\' { + return path[i+1:] + } + } + return path +} + +func containsInt(s []int, v int) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +func padRight(s string, w int) string { + for lipgloss.Width(s) < w { + s += " " + } + return s +} + +func main() { + DemoModel() + DemoTools() + cfg := LoadConfig() + m := NewAppModel(cfg) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..0ed6d15 --- /dev/null +++ b/config.go @@ -0,0 +1,114 @@ +package main + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type ConfigKeybindings struct { + Quit string `yaml:"quit"` + Save string `yaml:"save"` + Undo string `yaml:"undo"` + Redo string `yaml:"redo"` + UnicodeToggle string `yaml:"unicode_toggle"` + ColorToggle string `yaml:"color_toggle"` + FillToggle string `yaml:"fill_toggle"` + Resize string `yaml:"resize"` + DrillDown string `yaml:"drill_down"` + DrillUp string `yaml:"drill_up"` + DeleteSubmap string `yaml:"delete_submap"` +} + +type Config struct { + Symbols []Terrain `yaml:"symbols"` + Keybindings ConfigKeybindings `yaml:"keybindings"` + SubmapBg string `yaml:"submap_bg"` + DefaultMapWidth int `yaml:"default_map_width"` + DefaultMapHeight int `yaml:"default_map_height"` +} + +func defaultConfig() Config { + return Config{ + SubmapBg: "236", + DefaultMapWidth: 80, + DefaultMapHeight: 25, + Keybindings: ConfigKeybindings{ + Quit: "q", + Save: "s", + Undo: "u", + Redo: "r", + UnicodeToggle: "U", + ColorToggle: "C", + FillToggle: "F", + Resize: "R", + DrillDown: "enter", + DrillUp: "esc", + DeleteSubmap: "D", + }, + Symbols: []Terrain{ + {Name: "water", Symbol: "≋", ASCII: "~", Colors: []TerrainColor{{Color: "21", Weight: 50}, {Color: "27", Weight: 50}}}, + {Name: "mountains", Symbol: "▲", ASCII: "^", Colors: []TerrainColor{{Color: "243", Weight: 50}, {Color: "250", Weight: 50}}}, + {Name: "plains", Symbol: "≡", ASCII: "=", Colors: []TerrainColor{{Color: "106", Weight: 60}, {Color: "70", Weight: 40}}}, + {Name: "trees", Symbol: "♣", ASCII: "#", Colors: []TerrainColor{{Color: "28", Weight: 50}, {Color: "34", Weight: 50}}}, + {Name: "settlement", Symbol: "◉", ASCII: "@", Colors: []TerrainColor{{Color: "208", Weight: 100}}}, + {Name: "outpost", Symbol: "◈", ASCII: "&", Colors: []TerrainColor{{Color: "130", Weight: 100}}}, + {Name: "road", Symbol: "·", ASCII: ".", Colors: []TerrainColor{{Color: "244", Weight: 100}}}, + {Name: "desert", Symbol: "░", ASCII: "_", Colors: []TerrainColor{{Color: "178", Weight: 70}, {Color: "180", Weight: 30}}}, + {Name: "snow", Symbol: "❄", ASCII: "*", Colors: []TerrainColor{{Color: "255", Weight: 100}}}, + {Name: "swamp", Symbol: "≈", ASCII: "%", Colors: []TerrainColor{{Color: "64", Weight: 100}}}, + {Name: "cave", Symbol: "◌", ASCII: "n", Colors: []TerrainColor{{Color: "237", Weight: 100}}}, + {Name: "wall", Symbol: "█", ASCII: "|", Colors: []TerrainColor{{Color: "240", Weight: 100}}}, + {Name: "bridge", Symbol: "▬", ASCII: "-", Colors: []TerrainColor{{Color: "94", Weight: 100}}}, + {Name: "lava", Symbol: "▓", ASCII: "L", Colors: []TerrainColor{{Color: "196", Weight: 50}, {Color: "202", Weight: 50}}}, + {Name: "ice", Symbol: "▩", ASCII: "I", Colors: []TerrainColor{{Color: "51", Weight: 100}}}, + {Name: "ruins", Symbol: "▣", ASCII: "r", Colors: []TerrainColor{{Color: "244", Weight: 100}}}, + {Name: "farmland", Symbol: "▤", ASCII: "f", Colors: []TerrainColor{{Color: "142", Weight: 100}}}, + {Name: "tower", Symbol: "◬", ASCII: "T", Colors: []TerrainColor{{Color: "220", Weight: 100}}}, + {Name: "castle", Symbol: "◼", ASCII: "C", Colors: []TerrainColor{{Color: "244", Weight: 100}}}, + {Name: "coast", Symbol: "∼", ASCII: "s", Colors: []TerrainColor{{Color: "33", Weight: 100}}}, + }, + } +} + +// findConfigFile checks next to the binary, then XDG_CONFIG_HOME. +func findConfigFile() string { + // 1. Next to the binary + exe, err := os.Executable() + if err == nil { + dir := filepath.Dir(exe) + p := filepath.Join(dir, "config.yaml") + if _, err := os.Stat(p); err == nil { + return p + } + } + // 2. XDG_CONFIG_HOME/tui-ascii-mapper + cfgDir := os.Getenv("XDG_CONFIG_HOME") + if cfgDir == "" { + home, _ := os.UserHomeDir() + cfgDir = filepath.Join(home, ".config") + } + p := filepath.Join(cfgDir, "tui-ascii-mapper", "config.yaml") + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +// LoadConfig loads config.yaml, recreating with defaults if missing. +func LoadConfig() Config { + path := findConfigFile() + if path == "" { + return defaultConfig() + } + data, err := os.ReadFile(path) + if err != nil { + return defaultConfig() + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return defaultConfig() + } + return cfg +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..a517b30 --- /dev/null +++ b/config.yaml @@ -0,0 +1,334 @@ +# tui-ascii-mapper default config — recreated if missing + +symbols: + - name: water + symbol: "≋" + ascii: "~" + colors: + - color: "21" + weight: 30 + - color: "27" + weight: 30 + - color: "33" + weight: 20 + - color: "39" + weight: 20 + - name: mountains + symbol: "▲" + ascii: "^" + colors: + - color: "243" + weight: 40 + - color: "247" + weight: 30 + - color: "250" + weight: 30 + - name: plains + symbol: "≡" + ascii: "=" + colors: + - color: "106" + weight: 30 + - color: "70" + weight: 25 + - color: "71" + weight: 25 + - color: "107" + weight: 20 + - name: trees + symbol: "♣" + ascii: "#" + colors: + - color: "28" + weight: 30 + - color: "34" + weight: 25 + - color: "22" + weight: 25 + - color: "29" + weight: 20 + - name: settlement + symbol: "◉" + ascii: "@" + colors: + - color: "208" + weight: 40 + - color: "202" + weight: 30 + - color: "210" + weight: 30 + - name: outpost + symbol: "◈" + ascii: "&" + colors: + - color: "130" + weight: 40 + - color: "136" + weight: 30 + - color: "94" + weight: 30 + - name: road + symbol: "·" + ascii: "." + colors: + - color: "244" + weight: 40 + - color: "242" + weight: 30 + - color: "246" + weight: 30 + - name: desert + symbol: "░" + ascii: "_" + colors: + - color: "178" + weight: 30 + - color: "180" + weight: 25 + - color: "222" + weight: 25 + - color: "179" + weight: 20 + - name: snow + symbol: "❄" + ascii: "*" + colors: + - color: "255" + weight: 40 + - color: "254" + weight: 30 + - color: "250" + weight: 30 + - name: swamp + symbol: "≈" + ascii: "%" + colors: + - color: "64" + weight: 30 + - color: "65" + weight: 25 + - color: "58" + weight: 25 + - color: "107" + weight: 20 + - name: cave + symbol: "◌" + ascii: "n" + colors: + - color: "237" + weight: 40 + - color: "235" + weight: 30 + - color: "239" + weight: 30 + - name: wall + symbol: "█" + ascii: "|" + colors: + - color: "240" + weight: 40 + - color: "238" + weight: 30 + - color: "242" + weight: 30 + - name: bridge + symbol: "▬" + ascii: "-" + colors: + - color: "94" + weight: 40 + - color: "130" + weight: 30 + - color: "136" + weight: 30 + - name: lava + symbol: "▓" + ascii: "L" + colors: + - color: "196" + weight: 30 + - color: "202" + weight: 25 + - color: "208" + weight: 25 + - color: "124" + weight: 20 + - name: ice + symbol: "▩" + ascii: "I" + colors: + - color: "51" + weight: 40 + - color: "45" + weight: 30 + - color: "50" + weight: 30 + - name: ruins + symbol: "▣" + ascii: "r" + colors: + - color: "244" + weight: 35 + - color: "240" + weight: 35 + - color: "243" + weight: 30 + - name: farmland + symbol: "▤" + ascii: "f" + colors: + - color: "142" + weight: 35 + - color: "143" + weight: 35 + - color: "106" + weight: 30 + - name: tower + symbol: "◬" + ascii: "T" + colors: + - color: "220" + weight: 40 + - color: "178" + weight: 30 + - color: "222" + weight: 30 + - name: castle + symbol: "◼" + ascii: "C" + colors: + - color: "244" + weight: 35 + - color: "246" + weight: 35 + - color: "250" + weight: 30 + - name: coast + symbol: "∼" + ascii: "s" + colors: + - color: "33" + weight: 35 + - color: "39" + weight: 35 + - color: "27" + weight: 30 + - name: village + symbol: "◉" + ascii: "o" + colors: + - color: "208" + weight: 35 + - color: "172" + weight: 35 + - color: "166" + weight: 30 + - name: graveyard + symbol: "✞" + ascii: "y" + colors: + - color: "237" + weight: 35 + - color: "240" + weight: 35 + - color: "238" + weight: 30 + - name: tavern + symbol: "♨" + ascii: "a" + colors: + - color: "130" + weight: 35 + - color: "94" + weight: 35 + - color: "131" + weight: 30 + - name: dungeon + symbol: "◎" + ascii: "d" + colors: + - color: "239" + weight: 40 + - color: "237" + weight: 30 + - color: "241" + weight: 30 + - name: forest + symbol: "♠" + ascii: "F" + colors: + - color: "28" + weight: 30 + - color: "64" + weight: 25 + - color: "22" + weight: 25 + - color: "35" + weight: 20 + - name: shrine + symbol: "◈" + ascii: "h" + colors: + - color: "220" + weight: 35 + - color: "222" + weight: 35 + - color: "214" + weight: 30 + - name: crypt + symbol: "▦" + ascii: "x" + colors: + - color: "236" + weight: 40 + - color: "238" + weight: 30 + - color: "240" + weight: 30 + - name: rift + symbol: "◈" + ascii: "i" + colors: + - color: "129" + weight: 35 + - color: "93" + weight: 35 + - color: "201" + weight: 30 + - name: oasis + symbol: "◯" + ascii: "b" + colors: + - color: "51" + weight: 35 + - color: "33" + weight: 35 + - color: "39" + weight: 30 + - name: citadel + symbol: "◘" + ascii: "V" + colors: + - color: "250" + weight: 35 + - color: "248" + weight: 35 + - color: "253" + weight: 30 + +keybindings: + quit: "q" + save: "s" + undo: "u" + redo: "r" + unicode_toggle: "U" + color_toggle: "C" + fill_toggle: "F" + resize: "R" + drill_down: "enter" + drill_up: "esc" + delete_submap: "D" + +submap_bg: "236" +default_map_width: 80 +default_map_height: 25 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3549ff4 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module tui-ascii-mapper + +go 1.26.3 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ab78717 --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/io.go b/io.go new file mode 100644 index 0000000..1b2fd5d --- /dev/null +++ b/io.go @@ -0,0 +1,222 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Save format is YAML header with metadata, then the text grid below. +// Format: +// --- +// +// ... +// + +type SaveData struct { + Name string `yaml:"name"` + Width int `yaml:"width"` + Height int `yaml:"height"` + Palette []Terrain `yaml:"palette"` + GridBody string `yaml:"grid_body,omitempty"` + GridColors []string `yaml:"grid_colors,omitempty"` + TextLabels []TextLabel `yaml:"text_labels,omitempty"` + Submaps []SubmapRef `yaml:"submaps,omitempty"` +} + +type SubmapRef struct { + X int `yaml:"x"` + Y int `yaml:"y"` + Data SaveData `yaml:"data"` +} + +// SerializeMap serializes the map to the save format. +func SerializeMap(m *Map, path string) error { + data := buildSaveData(m) + + yamlBytes, err := yaml.Marshal(data) + if err != nil { + return err + } + + var buf bytes.Buffer + buf.WriteString("---\n") + buf.Write(yamlBytes) + buf.WriteString("...\n") + + for _, row := range m.Grid { + for _, cell := range row { + if cell.Terrain < 0 || cell.Terrain >= len(m.Palette) { + buf.WriteByte(' ') + } else { + buf.WriteString(m.Palette[cell.Terrain].ASCII) + } + } + buf.WriteByte('\n') + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(path, buf.Bytes(), 0644) +} + +func buildSaveData(m *Map) SaveData { + sd := SaveData{ + Name: m.Name, + Width: m.Width, + Height: m.Height, + Palette: m.Palette, + } + // Build grid body (only stored in YAML for submaps) + var gb strings.Builder + for _, row := range m.Grid { + for _, cell := range row { + if cell.Terrain < 0 || cell.Terrain >= len(m.Palette) { + gb.WriteByte(' ') + } else { + gb.WriteString(m.Palette[cell.Terrain].ASCII) + } + } + gb.WriteByte('\n') + } + if m.Parent != nil { + sd.GridBody = gb.String() + } + + for y := range m.Grid { + for x := range m.Grid[y] { + c := m.Grid[y][x] + if c.Color != "" { + sd.GridColors = append(sd.GridColors, fmt.Sprintf("%d,%d,%s", x, y, c.Color)) + } + } + } + for _, tl := range m.TextLabels { + sd.TextLabels = append(sd.TextLabels, tl) + } + for pt, sub := range m.Submaps { + sd.Submaps = append(sd.Submaps, SubmapRef{ + X: pt.X, Y: pt.Y, + Data: buildSaveData(sub), + }) + } + return sd +} + +// DeserializeMap reads a saved map file. +func DeserializeMap(path string) (*Map, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + content := string(data) + + if !strings.HasPrefix(content, "---\n") { + return nil, fmt.Errorf("invalid save file: missing YAML header") + } + + endIdx := strings.Index(content, "\n...\n") + if endIdx < 0 { + return nil, fmt.Errorf("invalid save file: missing ... terminator") + } + + yamlPart := content[4:endIdx] + gridPart := content[endIdx+5:] + + var sd SaveData + if err := yaml.Unmarshal([]byte(yamlPart), &sd); err != nil { + return nil, fmt.Errorf("invalid YAML header: %w", err) + } + + m := NewMap(sd.Name, sd.Width, sd.Height, sd.Palette) + m.Filename = path + + // Restore colors + for _, entry := range sd.GridColors { + var x, y int + var c string + if n, _ := fmt.Sscanf(entry, "%d,%d,%s", &x, &y, &c); n == 3 { + if y >= 0 && y < m.Height && x >= 0 && x < m.Width { + m.Grid[y][x].Color = c + } + } + } + + lines := strings.Split(strings.TrimRight(gridPart, "\n"), "\n") + for y, line := range lines { + if y >= m.Height { + break + } + for x, ch := range line { + if x >= m.Width { + break + } + for i, t := range m.Palette { + if string(ch) == t.ASCII { + m.Grid[y][x].Terrain = i + break + } + } + } + } + + for _, tl := range sd.TextLabels { + PlaceTextLabel(m, tl.Start, tl.Text, tl.Color) + } + + for _, sr := range sd.Submaps { + sub := restoreMap(&sr.Data, m) + m.Submaps[Point{sr.X, sr.Y}] = sub + } + + return m, nil +} + +func restoreMap(sd *SaveData, parent *Map) *Map { + m := NewMap(sd.Name, sd.Width, sd.Height, sd.Palette) + m.Parent = parent + // Restore colors + for _, entry := range sd.GridColors { + var x, y int + var c string + if n, _ := fmt.Sscanf(entry, "%d,%d,%s", &x, &y, &c); n == 3 { + if y >= 0 && y < m.Height && x >= 0 && x < m.Width { + m.Grid[y][x].Color = c + } + } + } + // Restore grid from GridBody + if sd.GridBody != "" { + lines := strings.Split(strings.TrimRight(sd.GridBody, "\n"), "\n") + for y, line := range lines { + if y >= m.Height { + break + } + for x, ch := range line { + if x >= m.Width { + break + } + for i, t := range m.Palette { + if string(ch) == t.ASCII { + m.Grid[y][x].Terrain = i + break + } + } + } + } + } + for _, tl := range sd.TextLabels { + PlaceTextLabel(m, tl.Start, tl.Text, tl.Color) + } + for _, sr := range sd.Submaps { + sub := restoreMap(&sr.Data, m) + m.Submaps[Point{sr.X, sr.Y}] = sub + } + return m +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..99a08be --- /dev/null +++ b/model.go @@ -0,0 +1,267 @@ +package main + +import ( + "fmt" + "math/rand" +) + +type Point struct{ X, Y int } + +type TerrainColor struct { + Color string `yaml:"color"` + Weight int `yaml:"weight"` +} + +type Terrain struct { + Name string `yaml:"name"` + Symbol string `yaml:"symbol"` + ASCII string `yaml:"ascii"` + Colors []TerrainColor `yaml:"colors"` +} + +func (t Terrain) GetSymbol(unicode bool) string { + if unicode { + return t.Symbol + } + return t.ASCII +} + +func (t Terrain) PickColor() string { + if len(t.Colors) == 0 { + return "0" + } + if len(t.Colors) == 1 { + return t.Colors[0].Color + } + total := 0 + for _, c := range t.Colors { + total += c.Weight + } + r := rand.Intn(total) + for _, c := range t.Colors { + r -= c.Weight + if r < 0 { + return c.Color + } + } + return t.Colors[0].Color +} + +type Cell struct { + Terrain int `yaml:"t"` + Color string `yaml:"c,omitempty"` // persisted ANSI color — set when drawn, stays fixed + Text string `yaml:"x,omitempty"` +} + +type TextLabel struct { + Text string `yaml:"text"` + Start Point `yaml:"start"` + Color string `yaml:"color,omitempty"` +} + +type Map struct { + Name string `yaml:"name"` + Width int `yaml:"width"` + Height int `yaml:"height"` + Grid [][]Cell `yaml:"-"` + Palette []Terrain `yaml:"palette"` + TextLabels []TextLabel `yaml:"text_labels,omitempty"` + Submaps map[Point]*Map `yaml:"-"` + Parent *Map `yaml:"-"` + Filename string `yaml:"-"` +} + +func NewMap(name string, w, h int, palette []Terrain) *Map { + grid := make([][]Cell, h) + for y := range grid { + grid[y] = make([]Cell, w) + for x := range grid[y] { + grid[y][x].Terrain = -1 + } + } + return &Map{ + Name: name, + Width: w, + Height: h, + Grid: grid, + Palette: palette, + Submaps: make(map[Point]*Map), + } +} + +func (m *Map) Clone() *Map { + grid := make([][]Cell, m.Height) + for y := range grid { + grid[y] = make([]Cell, m.Width) + copy(grid[y], m.Grid[y]) + } + c := &Map{ + Name: m.Name, + Width: m.Width, + Height: m.Height, + Grid: grid, + Palette: m.Palette, + Submaps: make(map[Point]*Map), + Parent: m.Parent, + } + for _, tl := range m.TextLabels { + c.TextLabels = append(c.TextLabels, tl) + } + for pt, sub := range m.Submaps { + c.Submaps[pt] = sub + } + return c +} + +func (m *Map) InBounds(p Point) bool { + return p.X >= 0 && p.X < m.Width && p.Y >= 0 && p.Y < m.Height +} + +func (m *Map) SetCell(p Point, terrain int, color ...string) { + if !m.InBounds(p) { + return + } + text := m.Grid[p.Y][p.X].Text + clr := "" + if len(color) > 0 { + clr = color[0] + } + m.Grid[p.Y][p.X] = Cell{Terrain: terrain, Color: clr, Text: text} +} + +func (m *Map) SetText(p Point, text string) { + if !m.InBounds(p) { + return + } + m.Grid[p.Y][p.X].Text = text +} + +func (m *Map) CellAt(p Point) Cell { + if !m.InBounds(p) { + return Cell{Terrain: -1} + } + return m.Grid[p.Y][p.X] +} + +type UndoStack struct { + states []undoEntry + pos int +} + +type undoEntry struct { + target *Map + state *Map +} + +func (u *UndoStack) Push(target *Map) { + keep := u.pos + 1 + if keep > len(u.states) { + keep = len(u.states) + } + u.states = append(u.states[:keep], undoEntry{target: target, state: target.Clone()}) + u.pos = len(u.states) - 1 + if len(u.states) > 100 { + u.states = u.states[1:] + u.pos-- + } +} + +func (u *UndoStack) Undo() *undoEntry { + if u.pos <= 0 { + return nil + } + u.pos-- + return &u.states[u.pos] +} + +func (u *UndoStack) Redo() *undoEntry { + if u.pos >= len(u.states)-1 { + return nil + } + u.pos++ + return &u.states[u.pos] +} + +type Tool int + +const ( + ToolBrush Tool = iota + ToolSelect + ToolErase + ToolFill + ToolLine + ToolRect + ToolCircle + ToolText +) + +func (t Tool) String() string { + switch t { + case ToolBrush: + return "Brush" + case ToolSelect: + return "Select" + case ToolErase: + return "Erase" + case ToolFill: + return "Fill" + case ToolLine: + return "Line" + case ToolRect: + return "Rect" + case ToolCircle: + return "Circle" + case ToolText: + return "Text" + } + return "" +} + +type Mode int + +const ( + ModeNormal Mode = iota + ModeDialog + ModeLinePreview + ModeRectPreview + ModeCirclePreview + ModeTextEdit +) + +type DialogType int + +const ( + DialogNone DialogType = iota + DialogSaveAs + DialogOpenMap + DialogResize + DialogQuitConfirm + DialogDeleteSubmapConfirm + DialogRenameSymbol + DialogRenameMap + DialogFileSave + DialogFileOpen +) + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func DemoModel() { + m := NewMap("test", 10, 5, nil) + if m.Width != 10 || m.Height != 5 || m.Grid[0][0].Terrain != -1 { + panic("NewMap broken") + } + m2 := m.Clone() + m2.Grid[0][0].Terrain = 0 + if m.Grid[0][0].Terrain != -1 { + panic("Clone shares data") + } + fmt.Println("model: ok") +} diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..e745892 --- /dev/null +++ b/tools.go @@ -0,0 +1,385 @@ +package main + +import "fmt" + +// Brush applies terrain to a block centered at p. Each cell gets independent random color. +func Brush(m *Map, center Point, terrain int, size int, palette []Terrain) { + half := size / 2 + for dy := -half; dy <= half; dy++ { + for dx := -half; dx <= half; dx++ { + color := "" + if terrain >= 0 && terrain < len(palette) { + color = palette[terrain].PickColor() + } + m.SetCell(Point{center.X + dx, center.Y + dy}, terrain, color) + } + } +} + +// ThickenPoints expands a set of points by the given brush size, returning deduplicated points. +func ThickenPoints(pts []Point, size int) []Point { + if size <= 1 { + return pts + } + half := size / 2 + seen := make(map[Point]bool) + var result []Point + for _, p := range pts { + for dy := -half; dy <= half; dy++ { + for dx := -half; dx <= half; dx++ { + np := Point{p.X + dx, p.Y + dy} + if !seen[np] { + seen[np] = true + result = append(result, np) + } + } + } + } + return result +} + +// FloodFill fills a contiguous area from start with terrain. +func FloodFill(m *Map, start Point, terrain int, palette []Terrain) { + if !m.InBounds(start) { + return + } + target := m.CellAt(start).Terrain + if target == terrain { + return + } + color := "" + if terrain >= 0 && terrain < len(palette) { + color = palette[terrain].PickColor() + } + type pt struct{ x, y int } + stack := []pt{{start.X, start.Y}} + visited := make([][]bool, m.Height) + for i := range visited { + visited[i] = make([]bool, m.Width) + } + for len(stack) > 0 { + p := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if !m.InBounds(Point{p.x, p.y}) || visited[p.y][p.x] { + continue + } + if m.Grid[p.y][p.x].Terrain != target { + continue + } + visited[p.y][p.x] = true + m.SetCell(Point{p.x, p.y}, terrain, color) + stack = append(stack, pt{p.x + 1, p.y}, pt{p.x - 1, p.y}, pt{p.x, p.y + 1}, pt{p.x, p.y - 1}) + } +} + +// BresenhamLine returns points along a line from a to b. +func BresenhamLine(a, b Point) []Point { + var pts []Point + x0, y0 := a.X, a.Y + x1, y1 := b.X, b.Y + dx := abs(x1 - x0) + dy := -abs(y1 - y0) + sx, sy := 1, 1 + if x0 > x1 { + sx = -1 + } + if y0 > y1 { + sy = -1 + } + err := dx + dy + for { + pts = append(pts, Point{x0, y0}) + if x0 == x1 && y0 == y1 { + break + } + e2 := 2 * err + if e2 >= dy { + err += dy + x0 += sx + } + if e2 <= dx { + err += dx + y0 += sy + } + } + return pts +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +// DrawRect returns points for the outline (or fill) of a rectangle. +func DrawRect(a, b Point, filled bool) []Point { + x0, x1 := a.X, b.X + y0, y1 := a.Y, b.Y + if x0 > x1 { + x0, x1 = x1, x0 + } + if y0 > y1 { + y0, y1 = y1, y0 + } + var pts []Point + if filled { + for y := y0; y <= y1; y++ { + for x := x0; x <= x1; x++ { + pts = append(pts, Point{x, y}) + } + } + return pts + } + for x := x0; x <= x1; x++ { + pts = append(pts, Point{x, y0}, Point{x, y1}) + } + for y := y0 + 1; y < y1; y++ { + pts = append(pts, Point{x0, y}, Point{x1, y}) + } + return pts +} + +// DrawCircle returns points for the outline (or fill) of a circle. +func DrawCircle(center, edge Point, filled bool) []Point { + r2 := (edge.X-center.X)*(edge.X-center.X) + (edge.Y-center.Y)*(edge.Y-center.Y) + r := r2 + if r < 0 { + return nil + } + // integer sqrt approximation, good enough for grid + radius := intSqrt(r) + var pts []Point + for dy := -radius; dy <= radius; dy++ { + for dx := -radius; dx <= radius; dx++ { + dist2 := dx*dx + dy*dy + if filled { + if dist2 <= r { + pts = append(pts, Point{center.X + dx, center.Y + dy}) + } + } else { + // outline: approximate ring + if dist2 <= r && dist2 > (radius-1)*(radius-1) { + pts = append(pts, Point{center.X + dx, center.Y + dy}) + } + } + } + } + return pts +} + +// DrawOval returns points for the outline (or fill) of an ellipse with two foci. +func DrawOval(f1, f2 Point, filled bool) []Point { + // semi-major axis: enough to pass through f2 from f1, plus a bit + dx := f2.X - f1.X + dy := f2.Y - f1.Y + // Use distance between foci as 2c, major axis 2a = 2c * 1.5 (so oval extends) + dist := intSqrt(dx*dx + dy*dy) + if dist == 0 { + return nil + } + a := dist * 3 / 2 // major semi-axis (oval extends beyond both foci) + if a < 1 { + a = 1 + } + a2 := a * a + c2 := dist * dist / 4 // c = half distance between foci + b2 := a2 - c2 // b² = a² - c² + if b2 < 0 { + b2 = 0 + } + + // Center of ellipse + cx := (f1.X + f2.X) / 2 + cy := (f1.Y + f2.Y) / 2 + + // Bounding box + minX := cx - a - 1 + maxX := cx + a + 1 + minY := cy - a - 1 + maxY := cy + a + 1 + + var pts []Point + for py := minY; py <= maxY; py++ { + for px := minX; px <= maxX; px++ { + // Distances to foci + d1 := distSq(px, py, f1.X, f1.Y) + d2 := distSq(px, py, f2.X, f2.Y) + sum := intSqrt(d1) + intSqrt(d2) + + if filled { + if sum <= 2*a { + pts = append(pts, Point{px, py}) + } + } else { + // Outline: near the ellipse boundary + if sum >= 2*a-1 && sum <= 2*a+1 { + pts = append(pts, Point{px, py}) + } + } + } + } + return pts +} + +func distSq(x1, y1, x2, y2 int) int { + dx := x1 - x2 + dy := y1 - y2 + return dx*dx + dy*dy +} + +func intSqrt(n int) int { + if n <= 0 { + return 0 + } + lo, hi := 0, n + for lo < hi { + mid := (lo + hi + 1) / 2 + if mid*mid <= n { + lo = mid + } else { + hi = mid - 1 + } + } + return lo +} + +// ApplyPoints writes terrain to all given points. +func ApplyPoints(m *Map, pts []Point, terrain int, palette []Terrain) { + color := "" + if terrain >= 0 && terrain < len(palette) { + color = palette[terrain].PickColor() + } + for _, p := range pts { + m.SetCell(p, terrain, color) + } +} + +// PlaceTextLabel adds a text label at start, clearing any prior text in those cells. +func PlaceTextLabel(m *Map, start Point, text string, color string) { + // Remove any existing label starting at the same point + RemoveTextLabel(m, start) + tl := TextLabel{Text: text, Start: start, Color: color} + m.TextLabels = append(m.TextLabels, tl) + runes := []rune(text) + for i, r := range runes { + p := Point{start.X + i, start.Y} + if m.InBounds(p) { + m.Grid[p.Y][p.X].Text = string(r) + } + } +} + +// RemoveTextLabel removes the text label starting at start. +func RemoveTextLabel(m *Map, start Point) { + for i, tl := range m.TextLabels { + if tl.Start == start { + m.TextLabels = append(m.TextLabels[:i], m.TextLabels[i+1:]...) + break + } + } + // Also clear from grid cells + for y := range m.Grid { + for x := range m.Grid[y] { + if m.Grid[y][x].Text == "" { + continue + } + // Check if this cell belongs to a label + found := false + for _, tl := range m.TextLabels { + runes := []rune(tl.Text) + for i := range runes { + if tl.Start.X+i == x && tl.Start.Y == y { + found = true + break + } + } + if found { + break + } + } + if !found { + m.Grid[y][x].Text = "" + } + } + } +} + +// FindTextLabelAt returns the label index that covers point p, or -1. +func FindTextLabelAt(m *Map, p Point) int { + for i, tl := range m.TextLabels { + runes := []rune(tl.Text) + for j := range runes { + if tl.Start.X+j == p.X && tl.Start.Y == p.Y { + return i + } + } + } + return -1 +} + +// MoveTextLabel moves label at oldStart to newStart. +func MoveTextLabel(m *Map, oldStart, newStart Point) { + for i, tl := range m.TextLabels { + if tl.Start == oldStart { + // Clear old cells + for _, p := range LabelPositions(tl) { + if m.InBounds(p) { + m.Grid[p.Y][p.X].Text = "" + } + } + m.TextLabels[i].Start = newStart + // Set new cells + runes := []rune(tl.Text) + for j, r := range runes { + p := Point{newStart.X + j, newStart.Y} + if m.InBounds(p) { + m.Grid[p.Y][p.X].Text = string(r) + } + } + return + } + } +} + +func LabelPositions(tl TextLabel) []Point { + var pts []Point + runes := []rune(tl.Text) + for i := range runes { + pts = append(pts, Point{tl.Start.X + i, tl.Start.Y}) + } + return pts +} + +func DemoTools() { + m := NewMap("test", 10, 10, nil) + Brush(m, Point{5, 5}, 0, 3, nil) + if m.Grid[5][5].Terrain != 0 { + panic("Brush failed") + } + pts := BresenhamLine(Point{0, 0}, Point{3, 0}) + if len(pts) != 4 || pts[0] != (Point{0, 0}) || pts[3] != (Point{3, 0}) { + panic(fmt.Sprintf("Line failed: %v", pts)) + } + // intSqrt sanity + if intSqrt(25) != 5 || intSqrt(26) != 5 || intSqrt(0) != 0 { + panic("intSqrt failed") + } + // Color picking + t := Terrain{Colors: []TerrainColor{{Color: "22", Weight: 100}}} + if t.PickColor() != "22" { + panic("PickColor failed") + } + // Text labels — Grid[y][x] + PlaceTextLabel(m, Point{2, 2}, "ABC", "") + if m.Grid[2][2].Text != "A" || m.Grid[2][3].Text != "B" { + panic("TextLabel: " + m.Grid[2][2].Text + "," + m.Grid[2][3].Text) + } + RemoveTextLabel(m, Point{2, 2}) + if m.Grid[2][2].Text != "" { + panic("TextLabel remove failed") + } + fmt.Println("tools: ok") +} + + diff --git a/tui-asci-mapper.webp b/tui-asci-mapper.webp new file mode 100644 index 0000000..aabca74 Binary files /dev/null and b/tui-asci-mapper.webp differ diff --git a/tui-ascii-mapper b/tui-ascii-mapper new file mode 100755 index 0000000..d50b521 Binary files /dev/null and b/tui-ascii-mapper differ -- cgit v1.2.3