diff options
| -rw-r--r-- | Makefile | 14 | ||||
| -rw-r--r-- | README.md | 59 | ||||
| -rw-r--r-- | app.go | 2248 | ||||
| -rw-r--r-- | config.go | 114 | ||||
| -rw-r--r-- | config.yaml | 334 | ||||
| -rw-r--r-- | go.mod | 34 | ||||
| -rw-r--r-- | go.sum | 56 | ||||
| -rw-r--r-- | io.go | 222 | ||||
| -rw-r--r-- | model.go | 267 | ||||
| -rw-r--r-- | tools.go | 385 | ||||
| -rw-r--r-- | tui-asci-mapper.webp | bin | 0 -> 156004 bytes | |||
| -rwxr-xr-x | tui-ascii-mapper | bin | 0 -> 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). + + + +## 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. @@ -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 @@ -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 +) @@ -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= @@ -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 Binary files differnew file mode 100644 index 0000000..aabca74 --- /dev/null +++ b/tui-asci-mapper.webp diff --git a/tui-ascii-mapper b/tui-ascii-mapper Binary files differnew file mode 100755 index 0000000..d50b521 --- /dev/null +++ b/tui-ascii-mapper |
