aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-23 04:38:06 -0400
committerhistoria <[not public]>2026-06-23 04:38:06 -0400
commitb8e473b069d9c81e2f6a471fa7636c27cee63fae (patch)
tree1cc5271e670fd922cb6a64e5fd8d7d9478ae2aec
downloadtui-ascii-mapper-b8e473b069d9c81e2f6a471fa7636c27cee63fae.tar.gz
initial commit
-rw-r--r--Makefile14
-rw-r--r--README.md59
-rw-r--r--app.go2248
-rw-r--r--config.go114
-rw-r--r--config.yaml334
-rw-r--r--go.mod34
-rw-r--r--go.sum56
-rw-r--r--io.go222
-rw-r--r--model.go267
-rw-r--r--tools.go385
-rw-r--r--tui-asci-mapper.webpbin0 -> 156004 bytes
-rwxr-xr-xtui-ascii-mapperbin0 -> 5926300 bytes
12 files changed, 3733 insertions, 0 deletions
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:
+// ---
+// <yaml metadata>
+// ...
+// <grid lines>
+
+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
--- /dev/null
+++ b/tui-asci-mapper.webp
Binary files differ
diff --git a/tui-ascii-mapper b/tui-ascii-mapper
new file mode 100755
index 0000000..d50b521
--- /dev/null
+++ b/tui-ascii-mapper
Binary files differ