aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile4
-rw-r--r--app.go2249
-rw-r--r--config.go137
-rw-r--r--firstmap2417
-rw-r--r--internal/config/config.go135
-rw-r--r--internal/mapio/mapio.go (renamed from io.go)60
-rw-r--r--internal/model/enums.go81
-rw-r--r--internal/model/map.go99
-rw-r--r--internal/model/model_test.go99
-rw-r--r--internal/model/terrain.go43
-rw-r--r--internal/model/undo.go42
-rw-r--r--internal/tools/tools.go (renamed from tools.go)154
-rw-r--r--internal/tools/tools_test.go36
-rw-r--r--internal/tui/app.go155
-rw-r--r--internal/tui/colorpicker.go180
-rw-r--r--internal/tui/dialogs.go526
-rw-r--r--internal/tui/handlers.go501
-rw-r--r--internal/tui/render.go465
-rw-r--r--internal/tui/update.go296
-rw-r--r--internal/tui/view.go142
-rw-r--r--model.go267
21 files changed, 2875 insertions, 5213 deletions
diff --git a/Makefile b/Makefile
index 079a468..ec51765 100644
--- a/Makefile
+++ b/Makefile
@@ -1,14 +1,14 @@
.PHONY: build run test clean
build:
- go build -o tui-ascii-mapper .
+ go build -o tui-ascii-mapper ./cmd/tui-ascii-mapper
run: build
./tui-ascii-mapper
test:
go vet ./...
- go run . 2>&1 | head -5
+ go test ./internal/...
clean:
rm -f tui-ascii-mapper
diff --git a/app.go b/app.go
deleted file mode 100644
index 6dcbe5c..0000000
--- a/app.go
+++ /dev/null
@@ -1,2249 +0,0 @@
-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)
- m.movingLabel = -1
- } else {
- m.startTextEditText(m.movingLabel)
- }
- } else if m.cursor == p && m.curMap().InBounds(p) {
- m.startTextEdit(p)
- }
- 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()
-
- 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())
- start := m.cursor
- if m.textEditing && m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) {
- RemoveTextLabel(m.curMap(), m.curMap().TextLabels[m.movingLabel].Start)
- start = m.textCursorStart
- m.textEditing = false
- }
- PlaceTextLabel(m.curMap(), start, 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":
- if m.filePicker.Selected > 0 {
- m.filePicker.Selected--
- }
- return m, nil
- case "down":
- 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":
- 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 {
- newTool := Tool(toolRow)
- if m.tool != newTool && m.mode == ModeTextEdit {
- m.mode = ModeNormal
- m.movingLabel = -1
- m.textEditing = false
- m.textInput.Blur()
- }
- m.tool = newTool
- }
- 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) >= 100 {
- 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 && m.tool == ToolText {
- 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 Width ══"))
- 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')
- }
- lines := strings.Split(sb.String(), "\n")
- if len(lines) > gh {
- lines = lines[:gh]
- }
- return strings.Join(lines, "\n")
-}
-
-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
deleted file mode 100644
index 8e34cf3..0000000
--- a/config.go
+++ /dev/null
@@ -1,137 +0,0 @@
-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: 30}, {Color: "27", Weight: 30}, {Color: "33", Weight: 20}, {Color: "39", Weight: 20}}},
- {Name: "mountains", Symbol: "▲", ASCII: "^", Colors: []TerrainColor{{Color: "243", Weight: 40}, {Color: "247", Weight: 30}, {Color: "250", Weight: 30}}},
- {Name: "crater", Symbol: "▼", ASCII: "v", Colors: []TerrainColor{{Color: "243", Weight: 40}, {Color: "247", Weight: 30}, {Color: "250", Weight: 30}}},
- {Name: "plains", Symbol: "≡", ASCII: "=", Colors: []TerrainColor{{Color: "106", Weight: 30}, {Color: "70", Weight: 25}, {Color: "64", Weight: 25}, {Color: "71", Weight: 20}}},
- {Name: "trees", Symbol: "♣", ASCII: "#", Colors: []TerrainColor{{Color: "28", Weight: 30}, {Color: "34", Weight: 25}, {Color: "22", Weight: 25}, {Color: "29", Weight: 20}}},
- {Name: "settlement", Symbol: "⌂", ASCII: "@", Colors: []TerrainColor{{Color: "130", Weight: 40}, {Color: "136", Weight: 30}, {Color: "94", Weight: 30}}},
- {Name: "outpost", Symbol: "◈", ASCII: "&", Colors: []TerrainColor{{Color: "172", Weight: 40}, {Color: "166", Weight: 30}, {Color: "130", Weight: 30}}},
- {Name: "road", Symbol: "·", ASCII: ".", Colors: []TerrainColor{{Color: "244", Weight: 40}, {Color: "242", Weight: 30}, {Color: "246", Weight: 30}}},
- {Name: "desert", Symbol: "░", ASCII: "_", Colors: []TerrainColor{{Color: "178", Weight: 30}, {Color: "180", Weight: 25}, {Color: "222", Weight: 25}, {Color: "179", Weight: 20}}},
- {Name: "snow", Symbol: "❄", ASCII: "*", Colors: []TerrainColor{{Color: "255", Weight: 40}, {Color: "254", Weight: 30}, {Color: "250", Weight: 30}}},
- {Name: "swamp", Symbol: "≈", ASCII: "%", Colors: []TerrainColor{{Color: "64", Weight: 30}, {Color: "65", Weight: 25}, {Color: "58", Weight: 25}, {Color: "107", Weight: 20}}},
- {Name: "cave", Symbol: "◌", ASCII: "n", Colors: []TerrainColor{{Color: "237", Weight: 40}, {Color: "235", Weight: 30}, {Color: "239", Weight: 30}}},
- {Name: "wall", Symbol: "█", ASCII: "|", Colors: []TerrainColor{{Color: "240", Weight: 40}, {Color: "238", Weight: 30}, {Color: "242", Weight: 30}}},
- {Name: "bridge", Symbol: "▬", ASCII: "-", Colors: []TerrainColor{{Color: "94", Weight: 40}, {Color: "130", Weight: 30}, {Color: "136", Weight: 30}}},
- {Name: "lava", Symbol: "▓", ASCII: "L", Colors: []TerrainColor{{Color: "196", Weight: 30}, {Color: "202", Weight: 25}, {Color: "208", Weight: 25}, {Color: "124", Weight: 20}}},
- {Name: "ice", Symbol: "▩", ASCII: "I", Colors: []TerrainColor{{Color: "51", Weight: 40}, {Color: "45", Weight: 30}, {Color: "50", Weight: 30}}},
- {Name: "ruins", Symbol: "▣", ASCII: "r", Colors: []TerrainColor{{Color: "244", Weight: 35}, {Color: "240", Weight: 35}, {Color: "243", Weight: 30}}},
- {Name: "farmland", Symbol: "▤", ASCII: "f", Colors: []TerrainColor{{Color: "142", Weight: 35}, {Color: "143", Weight: 35}, {Color: "106", Weight: 30}}},
- {Name: "tower", Symbol: "◬", ASCII: "T", Colors: []TerrainColor{{Color: "220", Weight: 40}, {Color: "214", Weight: 30}, {Color: "222", Weight: 30}}},
- {Name: "castle", Symbol: "♜", ASCII: "C", Colors: []TerrainColor{{Color: "248", Weight: 35}, {Color: "244", Weight: 35}, {Color: "136", Weight: 30}}},
- {Name: "coast", Symbol: "∼", ASCII: "s", Colors: []TerrainColor{{Color: "33", Weight: 35}, {Color: "39", Weight: 35}, {Color: "27", Weight: 30}}},
- {Name: "village", Symbol: "◉", ASCII: "o", Colors: []TerrainColor{{Color: "208", Weight: 35}, {Color: "172", Weight: 35}, {Color: "166", Weight: 30}}},
- {Name: "graveyard", Symbol: "☠", ASCII: "y", Colors: []TerrainColor{{Color: "238", Weight: 40}, {Color: "240", Weight: 30}, {Color: "242", Weight: 30}}},
- {Name: "tavern", Symbol: "♨", ASCII: "a", Colors: []TerrainColor{{Color: "130", Weight: 35}, {Color: "94", Weight: 35}, {Color: "131", Weight: 30}}},
- {Name: "dungeon", Symbol: "◎", ASCII: "d", Colors: []TerrainColor{{Color: "239", Weight: 40}, {Color: "237", Weight: 30}, {Color: "241", Weight: 30}}},
- {Name: "forest", Symbol: "♠", ASCII: "F", Colors: []TerrainColor{{Color: "28", Weight: 30}, {Color: "64", Weight: 25}, {Color: "22", Weight: 25}, {Color: "35", Weight: 20}}},
- {Name: "shrine", Symbol: "✞", ASCII: "h", Colors: []TerrainColor{{Color: "220", Weight: 35}, {Color: "214", Weight: 35}, {Color: "178", Weight: 30}}},
- {Name: "crypt", Symbol: "▦", ASCII: "x", Colors: []TerrainColor{{Color: "236", Weight: 40}, {Color: "238", Weight: 30}, {Color: "240", Weight: 30}}},
- {Name: "rift", Symbol: "⚡", ASCII: "i", Colors: []TerrainColor{{Color: "129", Weight: 35}, {Color: "93", Weight: 35}, {Color: "201", Weight: 30}}},
- {Name: "oasis", Symbol: "◯", ASCII: "b", Colors: []TerrainColor{{Color: "51", Weight: 35}, {Color: "33", Weight: 35}, {Color: "39", Weight: 30}}},
- {Name: "citadel", Symbol: "◘", ASCII: "V", Colors: []TerrainColor{{Color: "250", Weight: 35}, {Color: "248", Weight: 35}, {Color: "253", Weight: 30}}},
- {Name: "flowers", Symbol: "⚘", ASCII: "f", Colors: []TerrainColor{{Color: "169", Weight: 35}, {Color: "133", Weight: 35}, {Color: "170", Weight: 30}}},
- {Name: "wall/road", Symbol: "═", ASCII: "=", Colors: []TerrainColor{{Color: "243", Weight: 100}}},
- {Name: "wall/road", Symbol: "║", ASCII: "|", Colors: []TerrainColor{{Color: "243", Weight: 100}}},
- {Name: "wall/road", Symbol: "╔", ASCII: "+", Colors: []TerrainColor{{Color: "243", Weight: 100}}},
- {Name: "wall/road", Symbol: "╗", ASCII: "+", Colors: []TerrainColor{{Color: "243", Weight: 100}}},
- {Name: "wall/road", Symbol: "╚", ASCII: "+", Colors: []TerrainColor{{Color: "243", Weight: 100}}},
- {Name: "wall/road", Symbol: "╝", ASCII: "+", Colors: []TerrainColor{{Color: "243", Weight: 100}}},
- {Name: "wall/road", Symbol: "╠", ASCII: "+", Colors: []TerrainColor{{Color: "243", Weight: 100}}},
- {Name: "wall/road", Symbol: "╣", ASCII: "+", Colors: []TerrainColor{{Color: "243", Weight: 100}}},
- {Name: "wall/road", Symbol: "╦", ASCII: "+", Colors: []TerrainColor{{Color: "243", Weight: 100}}},
- {Name: "wall/road", Symbol: "╩", ASCII: "+", Colors: []TerrainColor{{Color: "243", Weight: 100}}},
- {Name: "wall/road", Symbol: "╬", ASCII: "+", Colors: []TerrainColor{{Color: "243", 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/firstmap b/firstmap
deleted file mode 100644
index 9b3896c..0000000
--- a/firstmap
+++ /dev/null
@@ -1,2417 +0,0 @@
----
-name: untitled
-width: 80
-height: 25
-palette:
- - 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: crater
- symbol: ▼
- ascii: v
- 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: "64"
- weight: 25
- - color: "71"
- 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: "130"
- weight: 40
- - color: "136"
- weight: 30
- - color: "94"
- weight: 30
- - name: outpost
- symbol: ◈
- ascii: '&'
- colors:
- - color: "172"
- weight: 40
- - color: "166"
- weight: 30
- - color: "130"
- 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: 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: "214"
- weight: 30
- - color: "222"
- weight: 30
- - name: castle
- symbol: ♜
- ascii: C
- colors:
- - color: "248"
- weight: 35
- - color: "244"
- weight: 35
- - color: "136"
- 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: "238"
- weight: 40
- - color: "240"
- weight: 30
- - color: "242"
- 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: "214"
- weight: 35
- - color: "178"
- 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
- - name: flowers
- symbol: ⚘
- ascii: f
- colors:
- - color: "169"
- weight: 35
- - color: "133"
- weight: 35
- - color: "170"
- weight: 30
- - name: wall/road
- symbol: ═
- ascii: =
- colors:
- - color: "243"
- weight: 100
- - name: wall/road
- symbol: ║
- ascii: '|'
- colors:
- - color: "243"
- weight: 100
- - name: wall/road
- symbol: ╔
- ascii: +
- colors:
- - color: "243"
- weight: 100
- - name: wall/road
- symbol: ╗
- ascii: +
- colors:
- - color: "243"
- weight: 100
- - name: wall/road
- symbol: ╚
- ascii: +
- colors:
- - color: "243"
- weight: 100
- - name: wall/road
- symbol: ╝
- ascii: +
- colors:
- - color: "243"
- weight: 100
- - name: wall/road
- symbol: ╠
- ascii: +
- colors:
- - color: "243"
- weight: 100
- - name: wall/road
- symbol: ╣
- ascii: +
- colors:
- - color: "243"
- weight: 100
- - name: wall/road
- symbol: ╦
- ascii: +
- colors:
- - color: "243"
- weight: 100
- - name: wall/road
- symbol: ╩
- ascii: +
- colors:
- - color: "243"
- weight: 100
- - name: wall/road
- symbol: ╬
- ascii: +
- colors:
- - color: "243"
- weight: 100
-grid_colors:
- - 0,0,71
- - 1,0,70
- - 2,0,106
- - 3,0,71
- - 4,0,64
- - 5,0,106
- - 6,0,64
- - 7,0,106
- - 8,0,64
- - 9,0,70
- - 10,0,64
- - 11,0,64
- - 12,0,64
- - 13,0,71
- - 14,0,106
- - 15,0,106
- - 16,0,106
- - 17,0,106
- - 18,0,64
- - 19,0,64
- - 20,0,71
- - 21,0,70
- - 22,0,70
- - 23,0,246
- - 24,0,70
- - 25,0,64
- - 26,0,106
- - 27,0,106
- - 28,0,64
- - 29,0,64
- - 30,0,64
- - 31,0,71
- - 32,0,70
- - 33,0,106
- - 34,0,106
- - 35,0,106
- - 36,0,70
- - 37,0,64
- - 38,0,71
- - 39,0,64
- - 40,0,70
- - 41,0,64
- - 42,0,70
- - 43,0,70
- - 44,0,71
- - 45,0,70
- - 46,0,71
- - 47,0,64
- - 48,0,70
- - 49,0,106
- - 50,0,169
- - 51,0,133
- - 52,0,133
- - 53,0,170
- - 54,0,133
- - 55,0,133
- - 56,0,133
- - 57,0,169
- - 58,0,133
- - 59,0,169
- - 60,0,170
- - 61,0,170
- - 62,0,170
- - 63,0,133
- - 64,0,133
- - 65,0,169
- - 66,0,170
- - 67,0,133
- - 68,0,106
- - 69,0,244
- - 70,0,106
- - 71,0,169
- - 72,0,133
- - 73,0,133
- - 74,0,133
- - 75,0,169
- - 76,0,170
- - 77,0,133
- - 78,0,133
- - 79,0,133
- - 0,1,70
- - 1,1,64
- - 2,1,64
- - 3,1,106
- - 4,1,64
- - 5,1,64
- - 6,1,71
- - 7,1,71
- - 8,1,64
- - 9,1,70
- - 10,1,71
- - 11,1,71
- - 12,1,70
- - 13,1,106
- - 14,1,71
- - 15,1,70
- - 16,1,64
- - 17,1,64
- - 18,1,70
- - 19,1,64
- - 20,1,70
- - 21,1,71
- - 22,1,71
- - 23,1,246
- - 24,1,70
- - 25,1,106
- - 26,1,71
- - 27,1,106
- - 28,1,106
- - 29,1,71
- - 30,1,70
- - 31,1,64
- - 32,1,106
- - 33,1,70
- - 34,1,64
- - 35,1,64
- - 36,1,71
- - 37,1,70
- - 38,1,71
- - 39,1,70
- - 40,1,70
- - 41,1,106
- - 42,1,106
- - 43,1,106
- - 44,1,64
- - 45,1,106
- - 46,1,64
- - 47,1,106
- - 48,1,64
- - 49,1,64
- - 50,1,133
- - 51,1,133
- - 52,1,169
- - 53,1,170
- - 54,1,169
- - 55,1,169
- - 56,1,169
- - 57,1,170
- - 58,1,133
- - 59,1,170
- - 60,1,170
- - 61,1,170
- - 62,1,133
- - 63,1,170
- - 64,1,169
- - 65,1,133
- - 66,1,133
- - 67,1,169
- - 68,1,106
- - 69,1,244
- - 70,1,70
- - 71,1,133
- - 72,1,169
- - 73,1,170
- - 74,1,133
- - 75,1,169
- - 76,1,170
- - 77,1,169
- - 78,1,170
- - 79,1,169
- - 0,2,71
- - 1,2,70
- - 2,2,71
- - 3,2,106
- - 4,2,64
- - 5,2,70
- - 6,2,70
- - 7,2,64
- - 8,2,106
- - 9,2,106
- - 10,2,71
- - 11,2,71
- - 12,2,64
- - 13,2,106
- - 14,2,64
- - 15,2,71
- - 16,2,71
- - 17,2,106
- - 18,2,106
- - 19,2,106
- - 20,2,106
- - 21,2,64
- - 22,2,64
- - 23,2,246
- - 24,2,64
- - 25,2,70
- - 26,2,70
- - 27,2,71
- - 28,2,250
- - 29,2,247
- - 30,2,247
- - 31,2,243
- - 32,2,243
- - 33,2,243
- - 34,2,247
- - 35,2,106
- - 36,2,106
- - 37,2,106
- - 38,2,64
- - 39,2,71
- - 40,2,64
- - 41,2,106
- - 42,2,64
- - 43,2,71
- - 44,2,106
- - 45,2,71
- - 46,2,106
- - 47,2,106
- - 48,2,70
- - 49,2,71
- - 50,2,170
- - 51,2,169
- - 52,2,133
- - 53,2,170
- - 54,2,133
- - 55,2,133
- - 56,2,133
- - 57,2,169
- - 58,2,170
- - 59,2,169
- - 60,2,169
- - 61,2,133
- - 62,2,170
- - 63,2,170
- - 64,2,169
- - 65,2,170
- - 66,2,169
- - 67,2,170
- - 68,2,64
- - 69,2,244
- - 70,2,106
- - 71,2,169
- - 72,2,169
- - 73,2,170
- - 74,2,133
- - 75,2,133
- - 76,2,133
- - 77,2,133
- - 78,2,170
- - 79,2,169
- - 0,3,106
- - 1,3,71
- - 2,3,64
- - 3,3,106
- - 4,3,106
- - 5,3,106
- - 6,3,64
- - 7,3,71
- - 8,3,71
- - 9,3,70
- - 10,3,64
- - 11,3,64
- - 12,3,106
- - 13,3,64
- - 14,3,106
- - 15,3,70
- - 16,3,64
- - 17,3,106
- - 18,3,106
- - 19,3,106
- - 20,3,70
- - 21,3,64
- - 22,3,71
- - 23,3,246
- - 24,3,71
- - 25,3,106
- - 26,3,70
- - 27,3,247
- - 28,3,247
- - 29,3,243
- - 30,3,247
- - 31,3,243
- - 32,3,243
- - 33,3,243
- - 34,3,250
- - 35,3,250
- - 36,3,70
- - 37,3,71
- - 38,3,64
- - 39,3,70
- - 40,3,71
- - 41,3,70
- - 42,3,64
- - 43,3,70
- - 44,3,106
- - 45,3,71
- - 46,3,106
- - 47,3,106
- - 48,3,70
- - 49,3,71
- - 50,3,106
- - 51,3,64
- - 52,3,71
- - 53,3,71
- - 54,3,106
- - 55,3,64
- - 56,3,71
- - 57,3,106
- - 58,3,64
- - 59,3,71
- - 60,3,106
- - 61,3,106
- - 62,3,64
- - 63,3,71
- - 64,3,106
- - 65,3,71
- - 66,3,71
- - 67,3,64
- - 68,3,70
- - 69,3,244
- - 70,3,106
- - 71,3,64
- - 72,3,70
- - 73,3,106
- - 74,3,106
- - 75,3,71
- - 76,3,70
- - 77,3,106
- - 78,3,71
- - 79,3,70
- - 0,4,106
- - 1,4,71
- - 2,4,106
- - 3,4,70
- - 4,4,64
- - 5,4,71
- - 6,4,64
- - 7,4,71
- - 8,4,70
- - 9,4,70
- - 10,4,106
- - 11,4,71
- - 12,4,106
- - 13,4,106
- - 14,4,106
- - 15,4,70
- - 16,4,106
- - 17,4,70
- - 18,4,106
- - 19,4,71
- - 20,4,106
- - 21,4,106
- - 22,4,64
- - 23,4,246
- - 24,4,106
- - 25,4,64
- - 26,4,243
- - 27,4,243
- - 28,4,247
- - 29,4,243
- - 30,4,243
- - 31,4,250
- - 32,4,250
- - 33,4,250
- - 34,4,243
- - 35,4,250
- - 36,4,250
- - 37,4,71
- - 38,4,106
- - 39,4,71
- - 40,4,106
- - 41,4,64
- - 42,4,71
- - 43,4,64
- - 44,4,106
- - 45,4,64
- - 46,4,71
- - 47,4,71
- - 48,4,71
- - 49,4,106
- - 50,4,70
- - 51,4,71
- - 52,4,71
- - 53,4,64
- - 54,4,64
- - 55,4,64
- - 56,4,106
- - 57,4,70
- - 58,4,106
- - 59,4,70
- - 60,4,106
- - 61,4,106
- - 62,4,71
- - 63,4,106
- - 64,4,70
- - 65,4,106
- - 66,4,106
- - 67,4,64
- - 68,4,71
- - 69,4,244
- - 70,4,70
- - 71,4,70
- - 72,4,64
- - 73,4,64
- - 74,4,106
- - 75,4,64
- - 76,4,106
- - 77,4,70
- - 78,4,71
- - 79,4,70
- - 0,5,244
- - 1,5,244
- - 2,5,244
- - 3,5,244
- - 4,5,244
- - 5,5,244
- - 6,5,244
- - 7,5,244
- - 8,5,244
- - 9,5,244
- - 10,5,244
- - 11,5,244
- - 12,5,244
- - 13,5,244
- - 14,5,244
- - 15,5,244
- - 16,5,244
- - 17,5,244
- - 18,5,244
- - 19,5,244
- - 20,5,244
- - 21,5,244
- - 22,5,244
- - 23,5,244
- - 24,5,71
- - 25,5,243
- - 26,5,247
- - 27,5,243
- - 28,5,247
- - 29,5,243
- - 30,5,250
- - 31,5,250
- - 32,5,243
- - 33,5,247
- - 34,5,243
- - 35,5,247
- - 36,5,243
- - 37,5,243
- - 38,5,64
- - 39,5,64
- - 40,5,106
- - 41,5,64
- - 42,5,106
- - 43,5,70
- - 44,5,106
- - 45,5,106
- - 46,5,70
- - 47,5,106
- - 48,5,106
- - 49,5,106
- - 50,5,133
- - 51,5,133
- - 52,5,133
- - 53,5,170
- - 54,5,133
- - 55,5,133
- - 56,5,133
- - 57,5,133
- - 58,5,133
- - 59,5,133
- - 60,5,170
- - 61,5,170
- - 62,5,169
- - 63,5,133
- - 64,5,133
- - 65,5,169
- - 66,5,133
- - 67,5,169
- - 68,5,70
- - 69,5,244
- - 70,5,106
- - 71,5,169
- - 72,5,170
- - 73,5,170
- - 74,5,133
- - 75,5,169
- - 76,5,133
- - 77,5,133
- - 78,5,133
- - 79,5,170
- - 0,6,71
- - 1,6,64
- - 2,6,106
- - 3,6,71
- - 4,6,70
- - 5,6,71
- - 6,6,70
- - 7,6,106
- - 8,6,71
- - 9,6,106
- - 10,6,64
- - 11,6,71
- - 12,6,106
- - 13,6,106
- - 14,6,106
- - 15,6,71
- - 16,6,106
- - 17,6,71
- - 18,6,64
- - 19,6,70
- - 20,6,64
- - 21,6,106
- - 22,6,70
- - 23,6,246
- - 24,6,64
- - 25,6,243
- - 26,6,243
- - 27,6,243
- - 28,6,250
- - 29,6,247
- - 30,6,240
- - 31,6,240
- - 32,6,240
- - 33,6,250
- - 34,6,243
- - 35,6,243
- - 36,6,247
- - 37,6,247
- - 38,6,70
- - 39,6,106
- - 40,6,70
- - 41,6,70
- - 42,6,106
- - 43,6,70
- - 44,6,70
- - 45,6,64
- - 46,6,70
- - 47,6,71
- - 48,6,71
- - 49,6,106
- - 50,6,133
- - 51,6,170
- - 52,6,170
- - 53,6,170
- - 54,6,170
- - 55,6,170
- - 56,6,169
- - 57,6,170
- - 58,6,169
- - 59,6,133
- - 60,6,169
- - 61,6,170
- - 62,6,133
- - 63,6,133
- - 64,6,169
- - 65,6,169
- - 66,6,170
- - 67,6,133
- - 68,6,70
- - 69,6,244
- - 70,6,64
- - 71,6,170
- - 72,6,169
- - 73,6,170
- - 74,6,169
- - 75,6,169
- - 76,6,170
- - 77,6,133
- - 78,6,170
- - 79,6,169
- - 0,7,70
- - 1,7,64
- - 2,7,71
- - 3,7,70
- - 4,7,106
- - 5,7,70
- - 6,7,70
- - 7,7,64
- - 8,7,70
- - 9,7,106
- - 10,7,64
- - 11,7,70
- - 12,7,70
- - 13,7,71
- - 14,7,71
- - 15,7,71
- - 16,7,70
- - 17,7,106
- - 18,7,64
- - 19,7,71
- - 20,7,106
- - 21,7,64
- - 22,7,64
- - 23,7,246
- - 24,7,106
- - 25,7,247
- - 26,7,250
- - 27,7,250
- - 28,7,250
- - 29,7,240
- - 30,7,240
- - 31,7,240
- - 32,7,240
- - 33,7,240
- - 34,7,250
- - 35,7,243
- - 36,7,243
- - 37,7,250
- - 38,7,64
- - 39,7,106
- - 40,7,106
- - 41,7,64
- - 42,7,64
- - 43,7,64
- - 44,7,64
- - 45,7,70
- - 46,7,71
- - 47,7,71
- - 48,7,106
- - 49,7,106
- - 50,7,169
- - 51,7,170
- - 52,7,133
- - 53,7,169
- - 54,7,170
- - 55,7,133
- - 56,7,169
- - 57,7,133
- - 58,7,169
- - 59,7,170
- - 60,7,133
- - 61,7,169
- - 62,7,133
- - 63,7,133
- - 64,7,169
- - 65,7,169
- - 66,7,170
- - 67,7,133
- - 68,7,70
- - 69,7,244
- - 70,7,64
- - 71,7,133
- - 72,7,169
- - 73,7,133
- - 74,7,170
- - 75,7,133
- - 76,7,133
- - 77,7,133
- - 78,7,169
- - 79,7,170
- - 0,8,64
- - 1,8,106
- - 2,8,64
- - 3,8,106
- - 4,8,70
- - 5,8,71
- - 6,8,71
- - 7,8,71
- - 8,8,71
- - 9,8,71
- - 10,8,71
- - 11,8,64
- - 12,8,71
- - 13,8,106
- - 14,8,106
- - 15,8,71
- - 16,8,70
- - 17,8,106
- - 18,8,64
- - 19,8,64
- - 20,8,64
- - 21,8,106
- - 22,8,71
- - 23,8,246
- - 24,8,71
- - 25,8,250
- - 26,8,250
- - 27,8,250
- - 28,8,250
- - 29,8,240
- - 30,8,240
- - 31,8,244
- - 32,8,240
- - 33,8,240
- - 34,8,247
- - 35,8,243
- - 36,8,243
- - 37,8,250
- - 38,8,70
- - 39,8,64
- - 40,8,64
- - 41,8,106
- - 42,8,70
- - 43,8,64
- - 44,8,64
- - 45,8,70
- - 46,8,70
- - 47,8,70
- - 48,8,70
- - 49,8,64
- - 50,8,169
- - 51,8,170
- - 52,8,133
- - 53,8,170
- - 54,8,133
- - 55,8,133
- - 56,8,169
- - 57,8,169
- - 58,8,133
- - 59,8,169
- - 60,8,133
- - 61,8,170
- - 62,8,133
- - 63,8,133
- - 64,8,133
- - 65,8,133
- - 66,8,170
- - 67,8,133
- - 68,8,71
- - 69,8,244
- - 70,8,71
- - 71,8,133
- - 72,8,133
- - 73,8,169
- - 74,8,133
- - 75,8,170
- - 76,8,170
- - 77,8,170
- - 78,8,170
- - 79,8,169
- - 0,9,70
- - 1,9,64
- - 2,9,71
- - 3,9,70
- - 4,9,106
- - 5,9,64
- - 6,9,71
- - 7,9,70
- - 8,9,70
- - 9,9,64
- - 10,9,106
- - 11,9,64
- - 12,9,64
- - 13,9,106
- - 14,9,64
- - 15,9,71
- - 16,9,106
- - 17,9,106
- - 18,9,70
- - 19,9,71
- - 20,9,64
- - 21,9,71
- - 22,9,106
- - 23,9,246
- - 24,9,64
- - 25,9,247
- - 26,9,247
- - 27,9,243
- - 28,9,243
- - 29,9,240
- - 30,9,240
- - 31,9,240
- - 32,9,240
- - 33,9,240
- - 34,9,247
- - 35,9,247
- - 36,9,247
- - 37,9,250
- - 38,9,70
- - 39,9,70
- - 40,9,106
- - 41,9,71
- - 42,9,70
- - 43,9,64
- - 44,9,71
- - 45,9,71
- - 46,9,64
- - 47,9,64
- - 48,9,64
- - 49,9,64
- - 50,9,169
- - 51,9,169
- - 52,9,133
- - 53,9,133
- - 54,9,170
- - 55,9,169
- - 56,9,169
- - 57,9,133
- - 58,9,169
- - 59,9,169
- - 60,9,170
- - 61,9,170
- - 62,9,169
- - 63,9,169
- - 64,9,133
- - 65,9,169
- - 66,9,133
- - 67,9,169
- - 68,9,70
- - 69,9,244
- - 70,9,106
- - 71,9,133
- - 72,9,170
- - 73,9,133
- - 74,9,169
- - 75,9,170
- - 76,9,169
- - 77,9,169
- - 78,9,170
- - 79,9,133
- - 0,10,106
- - 1,10,70
- - 2,10,70
- - 3,10,64
- - 4,10,70
- - 5,10,106
- - 6,10,71
- - 7,10,64
- - 8,10,64
- - 9,10,64
- - 10,10,71
- - 11,10,64
- - 12,10,106
- - 13,10,71
- - 14,10,71
- - 15,10,70
- - 16,10,64
- - 17,10,106
- - 18,10,70
- - 19,10,70
- - 20,10,106
- - 21,10,106
- - 22,10,71
- - 23,10,246
- - 24,10,64
- - 25,10,250
- - 26,10,247
- - 27,10,243
- - 28,10,243
- - 29,10,250
- - 30,10,240
- - 31,10,240
- - 32,10,240
- - 33,10,250
- - 34,10,250
- - 35,10,250
- - 36,10,247
- - 37,10,247
- - 38,10,64
- - 39,10,71
- - 40,10,106
- - 41,10,106
- - 42,10,71
- - 43,10,70
- - 44,10,71
- - 45,10,106
- - 46,10,64
- - 47,10,106
- - 48,10,64
- - 49,10,70
- - 50,10,170
- - 51,10,169
- - 52,10,169
- - 53,10,170
- - 54,10,169
- - 55,10,170
- - 56,10,170
- - 57,10,169
- - 58,10,133
- - 59,10,169
- - 60,10,133
- - 61,10,133
- - 62,10,170
- - 63,10,169
- - 64,10,169
- - 65,10,133
- - 66,10,170
- - 67,10,170
- - 68,10,106
- - 69,10,244
- - 70,10,64
- - 71,10,133
- - 72,10,133
- - 73,10,133
- - 74,10,170
- - 75,10,133
- - 76,10,170
- - 77,10,133
- - 78,10,169
- - 79,10,169
- - 0,11,106
- - 1,11,70
- - 2,11,106
- - 3,11,106
- - 4,11,106
- - 5,11,106
- - 6,11,70
- - 7,11,106
- - 8,11,70
- - 9,11,64
- - 10,11,106
- - 11,11,106
- - 12,11,106
- - 13,11,70
- - 14,11,64
- - 15,11,71
- - 16,11,106
- - 17,11,70
- - 18,11,70
- - 19,11,106
- - 20,11,70
- - 21,11,106
- - 22,11,64
- - 23,11,246
- - 24,11,70
- - 25,11,243
- - 26,11,247
- - 27,11,250
- - 28,11,243
- - 29,11,247
- - 30,11,250
- - 31,11,246
- - 32,11,247
- - 33,11,250
- - 34,11,243
- - 35,11,243
- - 36,11,247
- - 37,11,250
- - 38,11,64
- - 39,11,70
- - 40,11,106
- - 41,11,106
- - 42,11,64
- - 43,11,71
- - 44,11,71
- - 45,11,70
- - 46,11,106
- - 47,11,106
- - 48,11,71
- - 49,11,71
- - 50,11,169
- - 51,11,169
- - 52,11,170
- - 53,11,170
- - 54,11,170
- - 55,11,133
- - 56,11,133
- - 57,11,133
- - 58,11,169
- - 59,11,133
- - 60,11,169
- - 61,11,169
- - 62,11,170
- - 63,11,133
- - 64,11,133
- - 65,11,133
- - 66,11,169
- - 67,11,133
- - 68,11,64
- - 69,11,244
- - 70,11,106
- - 71,11,133
- - 72,11,170
- - 73,11,133
- - 74,11,170
- - 75,11,169
- - 76,11,170
- - 77,11,170
- - 78,11,133
- - 79,11,169
- - 0,12,71
- - 1,12,106
- - 2,12,106
- - 3,12,106
- - 4,12,64
- - 5,12,64
- - 6,12,70
- - 7,12,64
- - 8,12,71
- - 9,12,71
- - 10,12,70
- - 11,12,70
- - 12,12,106
- - 13,12,64
- - 14,12,64
- - 15,12,106
- - 16,12,64
- - 17,12,70
- - 18,12,71
- - 19,12,64
- - 20,12,106
- - 21,12,64
- - 22,12,71
- - 23,12,246
- - 24,12,246
- - 25,12,106
- - 26,12,247
- - 27,12,250
- - 28,12,243
- - 29,12,247
- - 30,12,247
- - 31,12,246
- - 32,12,247
- - 33,12,247
- - 34,12,243
- - 35,12,250
- - 36,12,247
- - 37,12,106
- - 38,12,106
- - 39,12,70
- - 40,12,64
- - 41,12,64
- - 42,12,71
- - 43,12,106
- - 44,12,70
- - 45,12,70
- - 46,12,106
- - 47,12,71
- - 48,12,106
- - 49,12,71
- - 50,12,169
- - 51,12,170
- - 52,12,133
- - 53,12,170
- - 54,12,169
- - 55,12,170
- - 56,12,170
- - 57,12,133
- - 58,12,133
- - 59,12,170
- - 60,12,133
- - 61,12,169
- - 62,12,170
- - 63,12,170
- - 64,12,169
- - 65,12,169
- - 66,12,133
- - 67,12,133
- - 68,12,106
- - 69,12,244
- - 70,12,70
- - 71,12,169
- - 72,12,170
- - 73,12,169
- - 74,12,169
- - 75,12,170
- - 76,12,133
- - 77,12,170
- - 78,12,169
- - 79,12,169
- - 0,13,70
- - 1,13,71
- - 2,13,106
- - 3,13,70
- - 4,13,70
- - 5,13,106
- - 6,13,70
- - 7,13,64
- - 8,13,71
- - 9,13,71
- - 10,13,64
- - 11,13,64
- - 12,13,71
- - 13,13,70
- - 14,13,106
- - 15,13,106
- - 16,13,64
- - 17,13,71
- - 18,13,71
- - 19,13,106
- - 20,13,71
- - 21,13,64
- - 22,13,106
- - 23,13,64
- - 24,13,64
- - 25,13,246
- - 26,13,246
- - 27,13,243
- - 28,13,243
- - 29,13,243
- - 30,13,243
- - 31,13,246
- - 32,13,243
- - 33,13,243
- - 34,13,247
- - 35,13,243
- - 36,13,64
- - 37,13,64
- - 38,13,106
- - 39,13,70
- - 40,13,70
- - 41,13,71
- - 42,13,64
- - 43,13,106
- - 44,13,106
- - 45,13,64
- - 46,13,106
- - 47,13,64
- - 48,13,106
- - 49,13,70
- - 50,13,170
- - 51,13,133
- - 52,13,169
- - 53,13,170
- - 54,13,170
- - 55,13,170
- - 56,13,170
- - 57,13,170
- - 58,13,170
- - 59,13,169
- - 60,13,169
- - 61,13,133
- - 62,13,133
- - 63,13,169
- - 64,13,169
- - 65,13,169
- - 66,13,133
- - 67,13,170
- - 68,13,106
- - 69,13,244
- - 70,13,70
- - 71,13,170
- - 72,13,170
- - 73,13,169
- - 74,13,169
- - 75,13,133
- - 76,13,133
- - 77,13,169
- - 78,13,170
- - 79,13,133
- - 0,14,71
- - 1,14,106
- - 2,14,71
- - 3,14,106
- - 4,14,106
- - 5,14,106
- - 6,14,70
- - 7,14,64
- - 8,14,71
- - 9,14,106
- - 10,14,71
- - 11,14,70
- - 12,14,64
- - 13,14,70
- - 14,14,64
- - 15,14,106
- - 16,14,70
- - 17,14,71
- - 18,14,64
- - 19,14,70
- - 20,14,70
- - 21,14,71
- - 22,14,106
- - 23,14,71
- - 24,14,70
- - 25,14,70
- - 26,14,70
- - 27,14,246
- - 28,14,246
- - 29,14,247
- - 30,14,250
- - 31,14,246
- - 32,14,247
- - 33,14,247
- - 34,14,250
- - 35,14,106
- - 36,14,71
- - 37,14,64
- - 38,14,64
- - 39,14,64
- - 40,14,106
- - 41,14,64
- - 42,14,70
- - 43,14,106
- - 44,14,64
- - 45,14,106
- - 46,14,71
- - 47,14,64
- - 48,14,70
- - 49,14,70
- - 50,14,133
- - 51,14,169
- - 52,14,170
- - 53,14,133
- - 54,14,133
- - 55,14,170
- - 56,14,133
- - 57,14,169
- - 58,14,170
- - 59,14,169
- - 60,14,169
- - 61,14,170
- - 62,14,133
- - 63,14,169
- - 64,14,169
- - 65,14,133
- - 66,14,133
- - 67,14,170
- - 68,14,71
- - 69,14,244
- - 70,14,106
- - 71,14,170
- - 72,14,133
- - 73,14,170
- - 74,14,169
- - 75,14,133
- - 76,14,169
- - 77,14,133
- - 78,14,170
- - 79,14,133
- - 0,15,64
- - 1,15,70
- - 2,15,106
- - 3,15,64
- - 4,15,71
- - 5,15,64
- - 6,15,71
- - 7,15,71
- - 8,15,70
- - 9,15,70
- - 10,15,70
- - 11,15,106
- - 12,15,70
- - 13,15,70
- - 14,15,70
- - 15,15,106
- - 16,15,71
- - 17,15,71
- - 18,15,71
- - 19,15,64
- - 20,15,106
- - 21,15,70
- - 22,15,64
- - 23,15,64
- - 24,15,71
- - 25,15,70
- - 26,15,106
- - 27,15,64
- - 28,15,71
- - 29,15,246
- - 30,15,246
- - 31,15,246
- - 32,15,106
- - 33,15,64
- - 34,15,71
- - 35,15,71
- - 36,15,106
- - 37,15,71
- - 38,15,71
- - 39,15,106
- - 40,15,64
- - 41,15,64
- - 42,15,106
- - 43,15,71
- - 44,15,106
- - 45,15,106
- - 46,15,106
- - 47,15,64
- - 48,15,71
- - 49,15,70
- - 50,15,70
- - 51,15,246
- - 52,15,246
- - 53,15,246
- - 54,15,246
- - 55,15,246
- - 56,15,246
- - 57,15,246
- - 58,15,246
- - 59,15,246
- - 60,15,246
- - 61,15,246
- - 62,15,246
- - 63,15,246
- - 64,15,246
- - 65,15,246
- - 66,15,246
- - 67,15,246
- - 68,15,246
- - 69,15,244
- - 70,15,71
- - 71,15,64
- - 72,15,71
- - 73,15,106
- - 74,15,71
- - 75,15,64
- - 76,15,106
- - 77,15,71
- - 78,15,106
- - 79,15,64
- - 0,16,33
- - 1,16,33
- - 2,16,39
- - 3,16,27
- - 4,16,21
- - 5,16,106
- - 6,16,64
- - 7,16,70
- - 8,16,71
- - 9,16,106
- - 10,16,64
- - 11,16,71
- - 12,16,70
- - 13,16,106
- - 14,16,106
- - 15,16,70
- - 16,16,106
- - 17,16,106
- - 18,16,64
- - 19,16,71
- - 20,16,70
- - 21,16,106
- - 22,16,71
- - 23,16,64
- - 24,16,70
- - 25,16,106
- - 26,16,70
- - 27,16,106
- - 28,16,106
- - 29,16,106
- - 30,16,106
- - 31,16,246
- - 32,16,246
- - 33,16,246
- - 34,16,246
- - 35,16,246
- - 36,16,246
- - 37,16,246
- - 38,16,246
- - 39,16,246
- - 40,16,246
- - 41,16,246
- - 42,16,246
- - 43,16,246
- - 44,16,246
- - 45,16,246
- - 46,16,246
- - 47,16,246
- - 48,16,246
- - 49,16,246
- - 50,16,246
- - 51,16,244
- - 52,16,106
- - 53,16,64
- - 54,16,70
- - 55,16,70
- - 56,16,106
- - 57,16,64
- - 58,16,71
- - 59,16,70
- - 60,16,70
- - 61,16,70
- - 62,16,64
- - 63,16,64
- - 64,16,106
- - 65,16,106
- - 66,16,70
- - 67,16,64
- - 68,16,106
- - 69,16,71
- - 70,16,64
- - 71,16,64
- - 72,16,64
- - 73,16,33
- - 74,16,27
- - 75,16,27
- - 76,16,33
- - 77,16,27
- - 78,16,21
- - 79,16,21
- - 0,17,39
- - 1,17,39
- - 2,17,27
- - 3,17,27
- - 4,17,21
- - 5,17,27
- - 6,17,33
- - 7,17,27
- - 8,17,39
- - 9,17,21
- - 10,17,27
- - 11,17,21
- - 12,17,64
- - 13,17,106
- - 14,17,106
- - 15,17,64
- - 16,17,71
- - 17,17,64
- - 18,17,106
- - 19,17,71
- - 20,17,106
- - 21,17,64
- - 22,17,106
- - 23,17,70
- - 24,17,71
- - 25,17,71
- - 26,17,106
- - 27,17,106
- - 28,17,106
- - 29,17,106
- - 30,17,106
- - 31,17,246
- - 32,17,70
- - 33,17,71
- - 34,17,106
- - 35,17,106
- - 36,17,64
- - 37,17,64
- - 38,17,106
- - 39,17,64
- - 40,17,71
- - 41,17,106
- - 42,17,70
- - 43,17,64
- - 44,17,71
- - 45,17,106
- - 46,17,64
- - 47,17,64
- - 48,17,64
- - 49,17,70
- - 50,17,71
- - 51,17,106
- - 52,17,70
- - 53,17,64
- - 54,17,106
- - 55,17,106
- - 56,17,64
- - 57,17,70
- - 58,17,70
- - 59,17,106
- - 60,17,106
- - 61,17,106
- - 62,17,71
- - 63,17,39
- - 64,17,21
- - 65,17,27
- - 66,17,27
- - 67,17,27
- - 68,17,21
- - 69,17,21
- - 70,17,27
- - 71,17,39
- - 72,17,33
- - 73,17,21
- - 74,17,27
- - 75,17,39
- - 76,17,39
- - 77,17,39
- - 78,17,33
- - 79,17,21
- - 0,18,33
- - 1,18,33
- - 2,18,33
- - 3,18,27
- - 4,18,21
- - 5,18,21
- - 6,18,33
- - 7,18,39
- - 8,18,27
- - 9,18,27
- - 10,18,39
- - 11,18,39
- - 12,18,21
- - 13,18,27
- - 14,18,27
- - 15,18,27
- - 16,18,21
- - 17,18,33
- - 18,18,33
- - 19,18,33
- - 20,18,39
- - 21,18,27
- - 22,18,33
- - 23,18,39
- - 24,18,21
- - 25,18,106
- - 26,18,71
- - 27,18,64
- - 28,18,106
- - 29,18,71
- - 30,18,64
- - 31,18,246
- - 32,18,64
- - 33,18,64
- - 34,18,106
- - 35,18,70
- - 36,18,64
- - 37,18,70
- - 38,18,64
- - 39,18,64
- - 40,18,71
- - 41,18,106
- - 42,18,71
- - 43,18,64
- - 44,18,71
- - 45,18,71
- - 46,18,64
- - 47,18,64
- - 48,18,21
- - 49,18,27
- - 50,18,21
- - 51,18,39
- - 52,18,21
- - 53,18,21
- - 54,18,39
- - 55,18,27
- - 56,18,39
- - 57,18,27
- - 58,18,33
- - 59,18,27
- - 60,18,33
- - 61,18,27
- - 62,18,21
- - 63,18,39
- - 64,18,27
- - 65,18,21
- - 66,18,33
- - 67,18,21
- - 68,18,27
- - 69,18,27
- - 70,18,21
- - 71,18,33
- - 72,18,27
- - 73,18,27
- - 74,18,21
- - 75,18,27
- - 76,18,27
- - 77,18,27
- - 78,18,27
- - 79,18,27
- - 0,19,33
- - 1,19,21
- - 2,19,33
- - 3,19,39
- - 4,19,27
- - 5,19,21
- - 6,19,39
- - 7,19,21
- - 8,19,21
- - 9,19,33
- - 10,19,21
- - 11,19,27
- - 12,19,21
- - 13,19,21
- - 14,19,21
- - 15,19,21
- - 16,19,33
- - 17,19,33
- - 18,19,39
- - 19,19,39
- - 20,19,27
- - 21,19,33
- - 22,19,39
- - 23,19,39
- - 24,19,27
- - 25,19,27
- - 26,19,21
- - 27,19,27
- - 28,19,21
- - 29,19,21
- - 30,19,27
- - 31,19,246
- - 32,19,21
- - 33,19,27
- - 34,19,39
- - 35,19,27
- - 36,19,27
- - 37,19,27
- - 38,19,27
- - 39,19,27
- - 40,19,21
- - 41,19,27
- - 42,19,21
- - 43,19,39
- - 44,19,27
- - 45,19,39
- - 46,19,27
- - 47,19,21
- - 48,19,39
- - 49,19,39
- - 50,19,39
- - 51,19,21
- - 52,19,39
- - 53,19,21
- - 54,19,21
- - 55,19,21
- - 56,19,33
- - 57,19,33
- - 58,19,33
- - 59,19,33
- - 60,19,21
- - 61,19,21
- - 62,19,21
- - 63,19,27
- - 64,19,33
- - 65,19,39
- - 66,19,27
- - 67,19,21
- - 68,19,21
- - 69,19,27
- - 70,19,39
- - 71,19,39
- - 72,19,21
- - 73,19,21
- - 74,19,27
- - 75,19,21
- - 76,19,33
- - 77,19,27
- - 78,19,21
- - 79,19,21
- - 0,20,39
- - 1,20,21
- - 2,20,27
- - 3,20,27
- - 4,20,33
- - 5,20,27
- - 6,20,27
- - 7,20,39
- - 8,20,33
- - 9,20,33
- - 10,20,21
- - 11,20,27
- - 12,20,39
- - 13,20,39
- - 14,20,39
- - 15,20,27
- - 16,20,21
- - 17,20,21
- - 18,20,21
- - 19,20,21
- - 20,20,27
- - 21,20,39
- - 22,20,21
- - 23,20,39
- - 24,20,39
- - 25,20,27
- - 26,20,33
- - 27,20,33
- - 28,20,39
- - 29,20,21
- - 30,20,21
- - 31,20,246
- - 32,20,39
- - 33,20,21
- - 34,20,33
- - 35,20,33
- - 36,20,21
- - 37,20,33
- - 38,20,39
- - 39,20,39
- - 40,20,27
- - 41,20,33
- - 42,20,33
- - 43,20,21
- - 44,20,21
- - 45,20,21
- - 46,20,27
- - 47,20,27
- - 48,20,39
- - 49,20,27
- - 50,20,33
- - 51,20,21
- - 52,20,33
- - 53,20,21
- - 54,20,27
- - 55,20,21
- - 56,20,27
- - 57,20,21
- - 58,20,27
- - 59,20,33
- - 60,20,39
- - 61,20,21
- - 62,20,27
- - 63,20,27
- - 64,20,33
- - 65,20,21
- - 66,20,33
- - 67,20,39
- - 68,20,27
- - 69,20,33
- - 70,20,27
- - 71,20,39
- - 72,20,21
- - 73,20,33
- - 74,20,39
- - 75,20,33
- - 76,20,27
- - 77,20,21
- - 78,20,39
- - 79,20,27
- - 0,21,33
- - 1,21,33
- - 2,21,27
- - 3,21,27
- - 4,21,33
- - 5,21,27
- - 6,21,21
- - 7,21,39
- - 8,21,21
- - 9,21,21
- - 10,21,21
- - 11,21,21
- - 12,21,33
- - 13,21,39
- - 14,21,33
- - 15,21,27
- - 16,21,27
- - 17,21,27
- - 18,21,21
- - 19,21,21
- - 20,21,33
- - 21,21,21
- - 22,21,27
- - 23,21,27
- - 24,21,27
- - 25,21,27
- - 26,21,21
- - 27,21,21
- - 28,21,27
- - 29,21,27
- - 30,21,179
- - 31,21,246
- - 32,21,178
- - 33,21,21
- - 34,21,21
- - 35,21,21
- - 36,21,27
- - 37,21,27
- - 38,21,33
- - 39,21,27
- - 40,21,39
- - 41,21,33
- - 42,21,27
- - 43,21,27
- - 44,21,27
- - 45,21,33
- - 46,21,39
- - 47,21,21
- - 48,21,33
- - 49,21,21
- - 50,21,21
- - 51,21,39
- - 52,21,39
- - 53,21,33
- - 54,21,27
- - 55,21,27
- - 56,21,39
- - 57,21,39
- - 58,21,21
- - 59,21,27
- - 60,21,21
- - 61,21,33
- - 62,21,21
- - 63,21,27
- - 64,21,39
- - 65,21,39
- - 66,21,39
- - 67,21,33
- - 68,21,27
- - 69,21,27
- - 70,21,33
- - 71,21,39
- - 72,21,21
- - 73,21,27
- - 74,21,27
- - 75,21,21
- - 76,21,39
- - 77,21,33
- - 78,21,33
- - 79,21,27
- - 0,22,39
- - 1,22,21
- - 2,22,21
- - 3,22,39
- - 4,22,27
- - 5,22,39
- - 6,22,27
- - 7,22,21
- - 8,22,39
- - 9,22,21
- - 10,22,39
- - 11,22,21
- - 12,22,21
- - 13,22,21
- - 14,22,21
- - 15,22,21
- - 16,22,27
- - 17,22,21
- - 18,22,33
- - 19,22,27
- - 20,22,39
- - 21,22,27
- - 22,22,27
- - 23,22,33
- - 24,22,27
- - 25,22,21
- - 26,22,21
- - 27,22,21
- - 28,22,27
- - 29,22,21
- - 30,22,180
- - 31,22,39
- - 32,22,178
- - 33,22,21
- - 34,22,33
- - 35,22,21
- - 36,22,21
- - 37,22,21
- - 38,22,21
- - 39,22,39
- - 40,22,27
- - 41,22,33
- - 42,22,27
- - 43,22,33
- - 44,22,21
- - 45,22,21
- - 46,22,21
- - 47,22,39
- - 48,22,39
- - 49,22,27
- - 50,22,21
- - 51,22,33
- - 52,22,39
- - 53,22,27
- - 54,22,21
- - 55,22,33
- - 56,22,39
- - 57,22,27
- - 58,22,27
- - 59,22,21
- - 60,22,21
- - 61,22,21
- - 62,22,39
- - 63,22,33
- - 64,22,33
- - 65,22,21
- - 66,22,39
- - 67,22,33
- - 68,22,27
- - 69,22,33
- - 70,22,39
- - 71,22,39
- - 72,22,27
- - 73,22,21
- - 74,22,27
- - 75,22,33
- - 76,22,21
- - 77,22,39
- - 78,22,39
- - 79,22,27
- - 0,23,39
- - 1,23,21
- - 2,23,27
- - 3,23,39
- - 4,23,21
- - 5,23,39
- - 6,23,39
- - 7,23,21
- - 8,23,27
- - 9,23,27
- - 10,23,27
- - 11,23,39
- - 12,23,21
- - 13,23,27
- - 14,23,21
- - 15,23,27
- - 16,23,21
- - 17,23,39
- - 18,23,27
- - 19,23,21
- - 20,23,21
- - 21,23,27
- - 22,23,33
- - 23,23,33
- - 24,23,33
- - 25,23,39
- - 26,23,39
- - 27,23,39
- - 28,23,21
- - 29,23,21
- - 30,23,178
- - 31,23,222
- - 32,23,178
- - 33,23,33
- - 34,23,27
- - 35,23,21
- - 36,23,33
- - 37,23,21
- - 38,23,33
- - 39,23,39
- - 40,23,21
- - 41,23,33
- - 42,23,33
- - 43,23,27
- - 44,23,27
- - 45,23,21
- - 46,23,27
- - 47,23,39
- - 48,23,39
- - 49,23,33
- - 50,23,33
- - 51,23,33
- - 52,23,21
- - 53,23,39
- - 54,23,21
- - 55,23,39
- - 56,23,33
- - 57,23,21
- - 58,23,21
- - 59,23,39
- - 60,23,39
- - 61,23,21
- - 62,23,27
- - 63,23,39
- - 64,23,27
- - 65,23,27
- - 66,23,21
- - 67,23,27
- - 68,23,21
- - 69,23,27
- - 70,23,27
- - 71,23,39
- - 72,23,33
- - 73,23,33
- - 74,23,21
- - 75,23,39
- - 76,23,27
- - 77,23,39
- - 78,23,27
- - 79,23,27
- - 0,24,27
- - 1,24,39
- - 2,24,33
- - 3,24,21
- - 4,24,27
- - 5,24,21
- - 6,24,39
- - 7,24,39
- - 8,24,27
- - 9,24,27
- - 10,24,21
- - 11,24,27
- - 12,24,21
- - 13,24,21
- - 14,24,39
- - 15,24,27
- - 16,24,27
- - 17,24,21
- - 18,24,27
- - 19,24,27
- - 20,24,27
- - 21,24,33
- - 22,24,21
- - 23,24,21
- - 24,24,33
- - 25,24,27
- - 26,24,21
- - 27,24,33
- - 28,24,27
- - 29,24,27
- - 30,24,39
- - 31,24,27
- - 32,24,21
- - 33,24,27
- - 34,24,27
- - 35,24,39
- - 36,24,21
- - 37,24,33
- - 38,24,33
- - 39,24,27
- - 40,24,33
- - 41,24,39
- - 42,24,21
- - 43,24,27
- - 44,24,27
- - 45,24,21
- - 46,24,33
- - 47,24,39
- - 48,24,33
- - 49,24,39
- - 50,24,27
- - 51,24,27
- - 52,24,21
- - 53,24,21
- - 54,24,27
- - 55,24,21
- - 56,24,39
- - 57,24,27
- - 58,24,21
- - 59,24,21
- - 60,24,39
- - 61,24,21
- - 62,24,33
- - 63,24,33
- - 64,24,33
- - 65,24,21
- - 66,24,21
- - 67,24,21
- - 68,24,33
- - 69,24,27
- - 70,24,21
- - 71,24,33
- - 72,24,39
- - 73,24,39
- - 74,24,33
- - 75,24,39
- - 76,24,33
- - 77,24,27
- - 78,24,27
- - 79,24,39
-text_labels:
- - text: Heinrich
- start:
- x: 28
- "y": 6
-...
-=======================.==========================ffffffffffffffffff=.=fffffffff
-=======================.==========================ffffffffffffffffff=.=fffffffff
-=======================.====vvvvvvv===============ffffffffffffffffff=.=fffffffff
-=======================.===vvvvvvvvv=================================.==========
-=======================.==vvvvvvvvvvv================================.==========
-........................=vvvvvvvvvvvvv============ffffffffffffffffff=.=fffffffff
-=======================.=vvvvv vvvvv============ffffffffffffffffff=.=fffffffff
-=======================.=vvvv vvvv============ffffffffffffffffff=.=fffffffff
-=======================.=vvvv C vvvv============ffffffffffffffffff=.=fffffffff
-=======================.=vvvv vvvv============ffffffffffffffffff=.=fffffffff
-=======================.=vvvvv vvvvv============ffffffffffffffffff=.=fffffffff
-=======================.=vvvvvv.vvvvvv============ffffffffffffffffff=.=fffffffff
-=======================..=vvvvv.vvvvv=============ffffffffffffffffff=.=fffffffff
-=========================..vvvv.vvvv==============ffffffffffffffffff=.=fffffffff
-===========================..vv.vvv===============ffffffffffffffffff=.=fffffffff
-=============================...===================...................==========
-~~~~~==========================.....................=====================~~~~~~~
-~~~~~~~~~~~~===================.===============================~~~~~~~~~~~~~~~~~
-~~~~~~~~~~~~~~~~~~~~~~~~~======.================~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~_._~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~_b_~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~___~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..cafa82f
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,135 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+
+ "tui-ascii-mapper/internal/model"
+
+ "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 []model.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: []model.Terrain{
+ {Name: "water", Symbol: "≋", ASCII: "~", Colors: []model.TerrainColor{{Color: "21", Weight: 30}, {Color: "27", Weight: 30}, {Color: "33", Weight: 20}, {Color: "39", Weight: 20}}},
+ {Name: "mountains", Symbol: "▲", ASCII: "^", Colors: []model.TerrainColor{{Color: "243", Weight: 40}, {Color: "247", Weight: 30}, {Color: "250", Weight: 30}}},
+ {Name: "crater", Symbol: "▼", ASCII: "v", Colors: []model.TerrainColor{{Color: "243", Weight: 40}, {Color: "247", Weight: 30}, {Color: "250", Weight: 30}}},
+ {Name: "plains", Symbol: "≡", ASCII: "=", Colors: []model.TerrainColor{{Color: "106", Weight: 30}, {Color: "70", Weight: 25}, {Color: "64", Weight: 25}, {Color: "71", Weight: 20}}},
+ {Name: "trees", Symbol: "♣", ASCII: "#", Colors: []model.TerrainColor{{Color: "28", Weight: 30}, {Color: "34", Weight: 25}, {Color: "22", Weight: 25}, {Color: "29", Weight: 20}}},
+ {Name: "settlement", Symbol: "⌂", ASCII: "@", Colors: []model.TerrainColor{{Color: "130", Weight: 40}, {Color: "136", Weight: 30}, {Color: "94", Weight: 30}}},
+ {Name: "outpost", Symbol: "◈", ASCII: "&", Colors: []model.TerrainColor{{Color: "172", Weight: 40}, {Color: "166", Weight: 30}, {Color: "130", Weight: 30}}},
+ {Name: "road", Symbol: "·", ASCII: ".", Colors: []model.TerrainColor{{Color: "244", Weight: 40}, {Color: "242", Weight: 30}, {Color: "246", Weight: 30}}},
+ {Name: "desert", Symbol: "░", ASCII: "_", Colors: []model.TerrainColor{{Color: "178", Weight: 30}, {Color: "180", Weight: 25}, {Color: "222", Weight: 25}, {Color: "179", Weight: 20}}},
+ {Name: "snow", Symbol: "❄", ASCII: "*", Colors: []model.TerrainColor{{Color: "255", Weight: 40}, {Color: "254", Weight: 30}, {Color: "250", Weight: 30}}},
+ {Name: "swamp", Symbol: "≈", ASCII: "%", Colors: []model.TerrainColor{{Color: "64", Weight: 30}, {Color: "65", Weight: 25}, {Color: "58", Weight: 25}, {Color: "107", Weight: 20}}},
+ {Name: "cave", Symbol: "◌", ASCII: "n", Colors: []model.TerrainColor{{Color: "237", Weight: 40}, {Color: "235", Weight: 30}, {Color: "239", Weight: 30}}},
+ {Name: "wall", Symbol: "█", ASCII: "|", Colors: []model.TerrainColor{{Color: "240", Weight: 40}, {Color: "238", Weight: 30}, {Color: "242", Weight: 30}}},
+ {Name: "bridge", Symbol: "▬", ASCII: "-", Colors: []model.TerrainColor{{Color: "94", Weight: 40}, {Color: "130", Weight: 30}, {Color: "136", Weight: 30}}},
+ {Name: "lava", Symbol: "▓", ASCII: "L", Colors: []model.TerrainColor{{Color: "196", Weight: 30}, {Color: "202", Weight: 25}, {Color: "208", Weight: 25}, {Color: "124", Weight: 20}}},
+ {Name: "ice", Symbol: "▩", ASCII: "I", Colors: []model.TerrainColor{{Color: "51", Weight: 40}, {Color: "45", Weight: 30}, {Color: "50", Weight: 30}}},
+ {Name: "ruins", Symbol: "▣", ASCII: "r", Colors: []model.TerrainColor{{Color: "244", Weight: 35}, {Color: "240", Weight: 35}, {Color: "243", Weight: 30}}},
+ {Name: "farmland", Symbol: "▤", ASCII: "f", Colors: []model.TerrainColor{{Color: "142", Weight: 35}, {Color: "143", Weight: 35}, {Color: "106", Weight: 30}}},
+ {Name: "tower", Symbol: "◬", ASCII: "T", Colors: []model.TerrainColor{{Color: "220", Weight: 40}, {Color: "214", Weight: 30}, {Color: "222", Weight: 30}}},
+ {Name: "castle", Symbol: "♜", ASCII: "C", Colors: []model.TerrainColor{{Color: "248", Weight: 35}, {Color: "244", Weight: 35}, {Color: "136", Weight: 30}}},
+ {Name: "coast", Symbol: "∼", ASCII: "s", Colors: []model.TerrainColor{{Color: "33", Weight: 35}, {Color: "39", Weight: 35}, {Color: "27", Weight: 30}}},
+ {Name: "village", Symbol: "◉", ASCII: "o", Colors: []model.TerrainColor{{Color: "208", Weight: 35}, {Color: "172", Weight: 35}, {Color: "166", Weight: 30}}},
+ {Name: "graveyard", Symbol: "☠", ASCII: "y", Colors: []model.TerrainColor{{Color: "238", Weight: 40}, {Color: "240", Weight: 30}, {Color: "242", Weight: 30}}},
+ {Name: "tavern", Symbol: "♨", ASCII: "a", Colors: []model.TerrainColor{{Color: "130", Weight: 35}, {Color: "94", Weight: 35}, {Color: "131", Weight: 30}}},
+ {Name: "dungeon", Symbol: "◎", ASCII: "d", Colors: []model.TerrainColor{{Color: "239", Weight: 40}, {Color: "237", Weight: 30}, {Color: "241", Weight: 30}}},
+ {Name: "forest", Symbol: "♠", ASCII: "F", Colors: []model.TerrainColor{{Color: "28", Weight: 30}, {Color: "64", Weight: 25}, {Color: "22", Weight: 25}, {Color: "35", Weight: 20}}},
+ {Name: "shrine", Symbol: "✞", ASCII: "h", Colors: []model.TerrainColor{{Color: "220", Weight: 35}, {Color: "214", Weight: 35}, {Color: "178", Weight: 30}}},
+ {Name: "crypt", Symbol: "▦", ASCII: "x", Colors: []model.TerrainColor{{Color: "236", Weight: 40}, {Color: "238", Weight: 30}, {Color: "240", Weight: 30}}},
+ {Name: "rift", Symbol: "⚡", ASCII: "i", Colors: []model.TerrainColor{{Color: "129", Weight: 35}, {Color: "93", Weight: 35}, {Color: "201", Weight: 30}}},
+ {Name: "oasis", Symbol: "◯", ASCII: "b", Colors: []model.TerrainColor{{Color: "51", Weight: 35}, {Color: "33", Weight: 35}, {Color: "39", Weight: 30}}},
+ {Name: "citadel", Symbol: "◘", ASCII: "V", Colors: []model.TerrainColor{{Color: "250", Weight: 35}, {Color: "248", Weight: 35}, {Color: "253", Weight: 30}}},
+ {Name: "flowers", Symbol: "⚘", ASCII: "f", Colors: []model.TerrainColor{{Color: "169", Weight: 35}, {Color: "133", Weight: 35}, {Color: "170", Weight: 30}}},
+ {Name: "wall/road", Symbol: "═", ASCII: "=", Colors: []model.TerrainColor{{Color: "243", Weight: 100}}},
+ {Name: "wall/road", Symbol: "║", ASCII: "|", Colors: []model.TerrainColor{{Color: "243", Weight: 100}}},
+ {Name: "wall/road", Symbol: "╔", ASCII: "+", Colors: []model.TerrainColor{{Color: "243", Weight: 100}}},
+ {Name: "wall/road", Symbol: "╗", ASCII: "+", Colors: []model.TerrainColor{{Color: "243", Weight: 100}}},
+ {Name: "wall/road", Symbol: "╚", ASCII: "+", Colors: []model.TerrainColor{{Color: "243", Weight: 100}}},
+ {Name: "wall/road", Symbol: "╝", ASCII: "+", Colors: []model.TerrainColor{{Color: "243", Weight: 100}}},
+ {Name: "wall/road", Symbol: "╠", ASCII: "+", Colors: []model.TerrainColor{{Color: "243", Weight: 100}}},
+ {Name: "wall/road", Symbol: "╣", ASCII: "+", Colors: []model.TerrainColor{{Color: "243", Weight: 100}}},
+ {Name: "wall/road", Symbol: "╦", ASCII: "+", Colors: []model.TerrainColor{{Color: "243", Weight: 100}}},
+ {Name: "wall/road", Symbol: "╩", ASCII: "+", Colors: []model.TerrainColor{{Color: "243", Weight: 100}}},
+ {Name: "wall/road", Symbol: "╬", ASCII: "+", Colors: []model.TerrainColor{{Color: "243", Weight: 100}}},
+ },
+ }
+}
+
+func findConfigFile() string {
+ 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
+ }
+ }
+ 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 ""
+}
+
+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/io.go b/internal/mapio/mapio.go
index 1b2fd5d..58a8848 100644
--- a/io.go
+++ b/internal/mapio/mapio.go
@@ -1,4 +1,4 @@
-package main
+package mapio
import (
"bytes"
@@ -7,35 +7,30 @@ import (
"path/filepath"
"strings"
+ "tui-ascii-mapper/internal/model"
+ "tui-ascii-mapper/internal/tools"
+
"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"`
+ Name string `yaml:"name"`
+ Width int `yaml:"width"`
+ Height int `yaml:"height"`
+ Palette []model.Terrain `yaml:"palette"`
+ GridBody string `yaml:"grid_body,omitempty"`
+ GridColors []string `yaml:"grid_colors,omitempty"`
+ TextLabels []model.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"`
+ 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 {
+func SerializeMap(m *model.Map, path string) error {
data := buildSaveData(m)
yamlBytes, err := yaml.Marshal(data)
@@ -66,14 +61,13 @@ func SerializeMap(m *Map, path string) error {
return os.WriteFile(path, buf.Bytes(), 0644)
}
-func buildSaveData(m *Map) SaveData {
+func buildSaveData(m *model.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 {
@@ -109,8 +103,7 @@ func buildSaveData(m *Map) SaveData {
return sd
}
-// DeserializeMap reads a saved map file.
-func DeserializeMap(path string) (*Map, error) {
+func DeserializeMap(path string) (*model.Map, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
@@ -134,10 +127,9 @@ func DeserializeMap(path string) (*Map, error) {
return nil, fmt.Errorf("invalid YAML header: %w", err)
}
- m := NewMap(sd.Name, sd.Width, sd.Height, sd.Palette)
+ m := model.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
@@ -167,21 +159,20 @@ func DeserializeMap(path string) (*Map, error) {
}
for _, tl := range sd.TextLabels {
- PlaceTextLabel(m, tl.Start, tl.Text, tl.Color)
+ tools.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
+ m.Submaps[model.Point{X: sr.X, Y: sr.Y}] = sub
}
return m, nil
}
-func restoreMap(sd *SaveData, parent *Map) *Map {
- m := NewMap(sd.Name, sd.Width, sd.Height, sd.Palette)
+func restoreMap(sd *SaveData, parent *model.Map) *model.Map {
+ m := model.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
@@ -191,7 +182,6 @@ func restoreMap(sd *SaveData, parent *Map) *Map {
}
}
}
- // Restore grid from GridBody
if sd.GridBody != "" {
lines := strings.Split(strings.TrimRight(sd.GridBody, "\n"), "\n")
for y, line := range lines {
@@ -212,11 +202,11 @@ func restoreMap(sd *SaveData, parent *Map) *Map {
}
}
for _, tl := range sd.TextLabels {
- PlaceTextLabel(m, tl.Start, tl.Text, tl.Color)
+ tools.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
+ m.Submaps[model.Point{X: sr.X, Y: sr.Y}] = sub
}
return m
}
diff --git a/internal/model/enums.go b/internal/model/enums.go
new file mode 100644
index 0000000..a29cf84
--- /dev/null
+++ b/internal/model/enums.go
@@ -0,0 +1,81 @@
+package model
+
+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 "Rectangle"
+ 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 ContainsInt(s []int, v int) bool {
+ for _, x := range s {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/model/map.go b/internal/model/map.go
new file mode 100644
index 0000000..de19660
--- /dev/null
+++ b/internal/model/map.go
@@ -0,0 +1,99 @@
+package model
+
+type Point struct{ X, Y int }
+
+type Cell struct {
+ Terrain int `yaml:"t"`
+ Color string `yaml:"c,omitempty"`
+ 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]
+}
diff --git a/internal/model/model_test.go b/internal/model/model_test.go
new file mode 100644
index 0000000..8e4ee63
--- /dev/null
+++ b/internal/model/model_test.go
@@ -0,0 +1,99 @@
+package model
+
+import "testing"
+
+func TestNewMap(t *testing.T) {
+ m := NewMap("test", 10, 5, nil)
+ if m.Width != 10 || m.Height != 5 {
+ t.Fatalf("NewMap size: got %dx%d", m.Width, m.Height)
+ }
+ if m.Grid[0][0].Terrain != -1 {
+ t.Fatal("NewMap: grid not initialized to -1")
+ }
+ if len(m.Grid) != 5 || len(m.Grid[0]) != 10 {
+ t.Fatal("NewMap: grid dimensions wrong")
+ }
+}
+
+func TestClone(t *testing.T) {
+ m := NewMap("test", 10, 5, nil)
+ m2 := m.Clone()
+ m2.Grid[0][0].Terrain = 0
+ if m.Grid[0][0].Terrain != -1 {
+ t.Fatal("Clone shares data")
+ }
+}
+
+func TestInBounds(t *testing.T) {
+ m := NewMap("test", 10, 5, nil)
+ if !m.InBounds(Point{X: 0, Y: 0}) {
+ t.Fatal("InBounds(0,0) should be true")
+ }
+ if m.InBounds(Point{X: -1, Y: 0}) {
+ t.Fatal("InBounds(-1,0) should be false")
+ }
+ if m.InBounds(Point{X: 0, Y: 5}) {
+ t.Fatal("InBounds(0,5) should be false")
+ }
+ if m.InBounds(Point{X: 10, Y: 0}) {
+ t.Fatal("InBounds(10,0) should be false")
+ }
+}
+
+func TestSetGetCell(t *testing.T) {
+ m := NewMap("test", 10, 5, nil)
+ m.SetCell(Point{X: 1, Y: 2}, 5, "red")
+ c := m.CellAt(Point{X: 1, Y: 2})
+ if c.Terrain != 5 || c.Color != "red" {
+ t.Fatalf("SetCell/GetCell: got %d,%s", c.Terrain, c.Color)
+ }
+}
+
+func TestUndoStack(t *testing.T) {
+ u := &UndoStack{}
+ m := NewMap("test", 3, 3, nil)
+ u.Push(m)
+ m.SetCell(Point{X: 1, Y: 1}, 2, "green")
+ u.Push(m)
+ m.SetCell(Point{X: 2, Y: 2}, 3, "blue")
+
+ if entry := u.Undo(); entry != nil {
+ *entry.Target = *entry.State
+ }
+ if m.CellAt(Point{X: 2, Y: 2}).Terrain != -1 {
+ t.Fatal("Undo failed")
+ }
+}
+
+func TestClamp(t *testing.T) {
+ if Clamp(5, 0, 10) != 5 {
+ t.Fatal("Clamp middle")
+ }
+ if Clamp(-1, 0, 10) != 0 {
+ t.Fatal("Clamp low")
+ }
+ if Clamp(11, 0, 10) != 10 {
+ t.Fatal("Clamp high")
+ }
+}
+
+func TestPickColor(t *testing.T) {
+ tc := Terrain{Colors: []TerrainColor{{Color: "22", Weight: 100}}}
+ if tc.PickColor() != "22" {
+ t.Fatal("PickColor single failed")
+ }
+ tc2 := Terrain{}
+ if tc2.PickColor() != "0" {
+ t.Fatal("PickColor empty failed")
+ }
+}
+
+func TestTerrainSymbol(t *testing.T) {
+ tr := Terrain{Symbol: "X", ASCII: "x"}
+ if tr.GetSymbol(true) != "X" {
+ t.Fatal("Unicode symbol")
+ }
+ if tr.GetSymbol(false) != "x" {
+ t.Fatal("ASCII symbol")
+ }
+}
diff --git a/internal/model/terrain.go b/internal/model/terrain.go
new file mode 100644
index 0000000..7772620
--- /dev/null
+++ b/internal/model/terrain.go
@@ -0,0 +1,43 @@
+package model
+
+import "math/rand"
+
+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
+}
diff --git a/internal/model/undo.go b/internal/model/undo.go
new file mode 100644
index 0000000..5b0297b
--- /dev/null
+++ b/internal/model/undo.go
@@ -0,0 +1,42 @@
+package model
+
+type UndoStack struct {
+ states []Entry
+ pos int
+}
+
+type Entry 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], Entry{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() *Entry {
+ if u.pos <= 0 {
+ return nil
+ }
+ u.pos--
+ return &u.states[u.pos]
+}
+
+func (u *UndoStack) Redo() *Entry {
+ if u.pos >= len(u.states)-1 {
+ return nil
+ }
+ u.pos++
+ return &u.states[u.pos]
+}
+
+func (u *UndoStack) Pos() int { return u.pos }
diff --git a/tools.go b/internal/tools/tools.go
index e745892..77b9f27 100644
--- a/tools.go
+++ b/internal/tools/tools.go
@@ -1,9 +1,8 @@
-package main
+package tools
-import "fmt"
+import "tui-ascii-mapper/internal/model"
-// 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) {
+func Brush(m *model.Map, center model.Point, terrain int, size int, palette []model.Terrain) {
half := size / 2
for dy := -half; dy <= half; dy++ {
for dx := -half; dx <= half; dx++ {
@@ -11,23 +10,22 @@ func Brush(m *Map, center Point, terrain int, size int, palette []Terrain) {
if terrain >= 0 && terrain < len(palette) {
color = palette[terrain].PickColor()
}
- m.SetCell(Point{center.X + dx, center.Y + dy}, terrain, color)
+ m.SetCell(model.Point{X: center.X + dx, Y: 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 {
+func ThickenPoints(pts []model.Point, size int) []model.Point {
if size <= 1 {
return pts
}
half := size / 2
- seen := make(map[Point]bool)
- var result []Point
+ seen := make(map[model.Point]bool)
+ var result []model.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}
+ np := model.Point{X: p.X + dx, Y: p.Y + dy}
if !seen[np] {
seen[np] = true
result = append(result, np)
@@ -38,8 +36,7 @@ func ThickenPoints(pts []Point, size int) []Point {
return result
}
-// FloodFill fills a contiguous area from start with terrain.
-func FloodFill(m *Map, start Point, terrain int, palette []Terrain) {
+func FloodFill(m *model.Map, start model.Point, terrain int, palette []model.Terrain) {
if !m.InBounds(start) {
return
}
@@ -60,25 +57,24 @@ func FloodFill(m *Map, start Point, terrain int, palette []Terrain) {
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] {
+ if !m.InBounds(model.Point{X: p.x, Y: 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)
+ m.SetCell(model.Point{X: p.x, Y: 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
+func BresenhamLine(a, b model.Point) []model.Point {
+ var pts []model.Point
x0, y0 := a.X, a.Y
x1, y1 := b.X, b.Y
- dx := abs(x1 - x0)
- dy := -abs(y1 - y0)
+ dx := iabs(x1 - x0)
+ dy := -iabs(y1 - y0)
sx, sy := 1, 1
if x0 > x1 {
sx = -1
@@ -88,7 +84,7 @@ func BresenhamLine(a, b Point) []Point {
}
err := dx + dy
for {
- pts = append(pts, Point{x0, y0})
+ pts = append(pts, model.Point{X: x0, Y: y0})
if x0 == x1 && y0 == y1 {
break
}
@@ -105,15 +101,14 @@ func BresenhamLine(a, b Point) []Point {
return pts
}
-func abs(x int) int {
+func iabs(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 {
+func DrawRect(a, b model.Point, filled bool) []model.Point {
x0, x1 := a.X, b.X
y0, y1 := a.Y, b.Y
if x0 > x1 {
@@ -122,45 +117,42 @@ func DrawRect(a, b Point, filled bool) []Point {
if y0 > y1 {
y0, y1 = y1, y0
}
- var pts []Point
+ var pts []model.Point
if filled {
for y := y0; y <= y1; y++ {
for x := x0; x <= x1; x++ {
- pts = append(pts, Point{x, y})
+ pts = append(pts, model.Point{X: x, Y: y})
}
}
return pts
}
for x := x0; x <= x1; x++ {
- pts = append(pts, Point{x, y0}, Point{x, y1})
+ pts = append(pts, model.Point{X: x, Y: y0}, model.Point{X: x, Y: y1})
}
for y := y0 + 1; y < y1; y++ {
- pts = append(pts, Point{x0, y}, Point{x1, y})
+ pts = append(pts, model.Point{X: x0, Y: y}, model.Point{X: x1, Y: y})
}
return pts
}
-// DrawCircle returns points for the outline (or fill) of a circle.
-func DrawCircle(center, edge Point, filled bool) []Point {
+func DrawCircle(center, edge model.Point, filled bool) []model.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
+ radius := IntSqrt(r)
+ var pts []model.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})
+ pts = append(pts, model.Point{X: center.X + dx, Y: 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})
+ pts = append(pts, model.Point{X: center.X + dx, Y: center.Y + dy})
}
}
}
@@ -168,53 +160,46 @@ func DrawCircle(center, edge Point, filled bool) []Point {
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
+func DrawOval(f1, f2 model.Point, filled bool) []model.Point {
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)
+ dist := IntSqrt(dx*dx + dy*dy)
if dist == 0 {
return nil
}
- a := dist * 3 / 2 // major semi-axis (oval extends beyond both foci)
+ a := dist * 3 / 2
if a < 1 {
a = 1
}
a2 := a * a
- c2 := dist * dist / 4 // c = half distance between foci
- b2 := a2 - c2 // b² = a² - c²
+ c2 := dist * dist / 4
+ b2 := a2 - c2
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
+ var pts []model.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)
+ sum := IntSqrt(d1) + IntSqrt(d2)
if filled {
if sum <= 2*a {
- pts = append(pts, Point{px, py})
+ pts = append(pts, model.Point{X: px, Y: py})
}
} else {
- // Outline: near the ellipse boundary
if sum >= 2*a-1 && sum <= 2*a+1 {
- pts = append(pts, Point{px, py})
+ pts = append(pts, model.Point{X: px, Y: py})
}
}
}
@@ -228,7 +213,7 @@ func distSq(x1, y1, x2, y2 int) int {
return dx*dx + dy*dy
}
-func intSqrt(n int) int {
+func IntSqrt(n int) int {
if n <= 0 {
return 0
}
@@ -244,8 +229,7 @@ func intSqrt(n int) int {
return lo
}
-// ApplyPoints writes terrain to all given points.
-func ApplyPoints(m *Map, pts []Point, terrain int, palette []Terrain) {
+func ApplyPoints(m *model.Map, pts []model.Point, terrain int, palette []model.Terrain) {
color := ""
if terrain >= 0 && terrain < len(palette) {
color = palette[terrain].PickColor()
@@ -255,36 +239,31 @@ func ApplyPoints(m *Map, pts []Point, terrain int, palette []Terrain) {
}
}
-// 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
+func PlaceTextLabel(m *model.Map, start model.Point, text string, color string) {
RemoveTextLabel(m, start)
- tl := TextLabel{Text: text, Start: start, Color: color}
+ tl := model.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}
+ p := model.Point{X: start.X + i, Y: 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) {
+func RemoveTextLabel(m *model.Map, start model.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)
@@ -305,8 +284,7 @@ func RemoveTextLabel(m *Map, start Point) {
}
}
-// FindTextLabelAt returns the label index that covers point p, or -1.
-func FindTextLabelAt(m *Map, p Point) int {
+func FindTextLabelAt(m *model.Map, p model.Point) int {
for i, tl := range m.TextLabels {
runes := []rune(tl.Text)
for j := range runes {
@@ -318,21 +296,18 @@ func FindTextLabelAt(m *Map, p Point) int {
return -1
}
-// MoveTextLabel moves label at oldStart to newStart.
-func MoveTextLabel(m *Map, oldStart, newStart Point) {
+func MoveTextLabel(m *model.Map, oldStart, newStart model.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}
+ p := model.Point{X: newStart.X + j, Y: newStart.Y}
if m.InBounds(p) {
m.Grid[p.Y][p.X].Text = string(r)
}
@@ -342,44 +317,11 @@ func MoveTextLabel(m *Map, oldStart, newStart Point) {
}
}
-func LabelPositions(tl TextLabel) []Point {
- var pts []Point
+func LabelPositions(tl model.TextLabel) []model.Point {
+ var pts []model.Point
runes := []rune(tl.Text)
for i := range runes {
- pts = append(pts, Point{tl.Start.X + i, tl.Start.Y})
+ pts = append(pts, model.Point{X: tl.Start.X + i, Y: 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/internal/tools/tools_test.go b/internal/tools/tools_test.go
new file mode 100644
index 0000000..22ac3d2
--- /dev/null
+++ b/internal/tools/tools_test.go
@@ -0,0 +1,36 @@
+package tools
+
+import (
+ "fmt"
+ "testing"
+
+ "tui-ascii-mapper/internal/model"
+)
+
+func TestDemotools(t *testing.T) {
+ m := model.NewMap("test", 10, 10, nil)
+ Brush(m, model.Point{X: 5, Y: 5}, 0, 3, nil)
+ if m.Grid[5][5].Terrain != 0 {
+ t.Fatal("Brush failed")
+ }
+ pts := BresenhamLine(model.Point{X: 0, Y: 0}, model.Point{X: 3, Y: 0})
+ if len(pts) != 4 || pts[0] != (model.Point{X: 0, Y: 0}) || pts[3] != (model.Point{X: 3, Y: 0}) {
+ t.Fatalf("Line failed: %v", pts)
+ }
+ if IntSqrt(25) != 5 || IntSqrt(26) != 5 || IntSqrt(0) != 0 {
+ t.Fatal("IntSqrt failed")
+ }
+ t2 := model.Terrain{Colors: []model.TerrainColor{{Color: "22", Weight: 100}}}
+ if t2.PickColor() != "22" {
+ t.Fatal("PickColor failed")
+ }
+ PlaceTextLabel(m, model.Point{X: 2, Y: 2}, "ABC", "")
+ if m.Grid[2][2].Text != "A" || m.Grid[2][3].Text != "B" {
+ t.Fatalf("TextLabel: %s,%s", m.Grid[2][2].Text, m.Grid[2][3].Text)
+ }
+ RemoveTextLabel(m, model.Point{X: 2, Y: 2})
+ if m.Grid[2][2].Text != "" {
+ t.Fatal("TextLabel remove failed")
+ }
+ fmt.Println("tools: ok")
+}
diff --git a/internal/tui/app.go b/internal/tui/app.go
new file mode 100644
index 0000000..066fa28
--- /dev/null
+++ b/internal/tui/app.go
@@ -0,0 +1,155 @@
+package tui
+
+import (
+ "os"
+
+ "tui-ascii-mapper/internal/config"
+ "tui-ascii-mapper/internal/mapio"
+ "tui-ascii-mapper/internal/model"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type FilePickerState struct {
+ Files []os.DirEntry
+ CurDir string
+ Selected int
+ PopupX int
+ PopupY int
+ ListTop int
+}
+
+type ColorPickerState struct {
+ Active bool
+ Cursor model.Point
+ Selected []string
+ ForText bool
+ GridX int
+ GridY int
+}
+
+type AppModel struct {
+ map_ *model.Map
+ rootMap *model.Map
+ cursor model.Point
+ offset model.Point
+ prevCursor model.Point
+
+ tool model.Tool
+ selected int
+ brushWidth int
+ unicode bool
+ colorMode bool
+ fillShapes bool
+
+ mode model.Mode
+ dialog model.DialogType
+
+ ti textinput.Model
+
+ lineStart model.Point
+ linePreview []model.Point
+ rectStart model.Point
+ rectPreview []model.Point
+ circleCenter model.Point
+ circlePreview []model.Point
+
+ textEditing bool
+ textInput textinput.Model
+ textColor string
+ movingLabel int
+ dragLabelOrigin model.Point
+ dragMouseOrigin model.Point
+ textCursorStart model.Point
+
+ mouseDown bool
+ mouseBtn int
+ mouseStart model.Point
+ lastPaint model.Point
+ drawHeld bool
+ eraseHeld bool
+
+ undo *model.UndoStack
+
+ width int
+ height int
+ quitting bool
+ cfg config.Config
+ dialogMsg string
+
+ colorPicker *ColorPickerState
+ dirty bool
+ undoPosAtSave int
+ hotkeySelect [10]int
+ palettePage int
+ filePicker *FilePickerState
+}
+
+func (m *AppModel) curMap() *model.Map { return m.map_ }
+
+func (m *AppModel) curPalette() []model.Terrain {
+ if m.map_ != nil {
+ return m.map_.Palette
+ }
+ return nil
+}
+
+func NewAppModel(cfg config.Config) *AppModel {
+ palette := make([]model.Terrain, len(cfg.Symbols))
+ copy(palette, cfg.Symbols)
+ root := model.NewMap("untitled", cfg.DefaultMapWidth, cfg.DefaultMapHeight, palette)
+
+ undo := &model.UndoStack{}
+ undo.Push(root)
+ undoAtSave := undo.Pos()
+
+ 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: model.Point{X: 0, Y: 0},
+ prevCursor: model.Point{X: -1, Y: -1},
+ tool: model.ToolBrush,
+ brushWidth: 1,
+ selected: 0,
+ unicode: true,
+ colorMode: true,
+ mode: model.ModeNormal,
+ undo: undo,
+ cfg: cfg,
+ ti: ti,
+ textInput: textTI,
+ movingLabel: -1,
+ lastPaint: model.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 := mapio.DeserializeMap(path)
+ return mapLoadedMsg{data: md, err: err}
+ }
+}
+
+type mapLoadedMsg struct {
+ data *model.Map
+ err error
+}
diff --git a/internal/tui/colorpicker.go b/internal/tui/colorpicker.go
new file mode 100644
index 0000000..fba388e
--- /dev/null
+++ b/internal/tui/colorpicker.go
@@ -0,0 +1,180 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "tui-ascii-mapper/internal/model"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+func (m *AppModel) openColorPicker(forText bool) {
+ m.colorPicker = &ColorPickerState{
+ Active: true,
+ Cursor: model.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 = model.Point{X: -1, Y: -1}
+ return m, nil
+ }
+ if row >= 0 && row < 16 && col >= 0 && col < 16 {
+ m.colorPicker.Cursor = model.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 = model.Point{X: 0, Y: 0}
+ }
+ switch key {
+ case "esc":
+ m.colorPicker = nil
+ return m, nil
+ case "enter":
+ if m.colorPicker.ForText {
+ idx := findTextLabelAtDirect(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 []model.TerrainColor
+ for _, c := range m.colorPicker.Selected {
+ colors = append(colors, model.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":
+ }
+ return m, nil
+}
+
+func findTextLabelAtDirect(m *model.Map, p model.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
+}
+
+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 = "\u25cf "
+ }
+ if row == m.colorPicker.Cursor.Y && col == m.colorPicker.Cursor.X {
+ marker = "\u25cb "
+ }
+ 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)
+}
diff --git a/internal/tui/dialogs.go b/internal/tui/dialogs.go
new file mode 100644
index 0000000..014951d
--- /dev/null
+++ b/internal/tui/dialogs.go
@@ -0,0 +1,526 @@
+package tui
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "tui-ascii-mapper/internal/mapio"
+ "tui-ascii-mapper/internal/model"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m *AppModel) handleDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ key := msg.String()
+
+ isFilePicker := m.dialog == model.DialogFileSave || m.dialog == model.DialogFileOpen || m.dialog == model.DialogSaveAs || m.dialog == model.DialogOpenMap
+ if isFilePicker && m.filePicker != nil {
+ switch key {
+ case "esc":
+ m.mode = model.ModeNormal
+ m.dialog = model.DialogNone
+ m.filePicker = nil
+ return m, nil
+ case "up":
+ if m.filePicker.Selected > 0 {
+ m.filePicker.Selected--
+ }
+ return m, nil
+ case "down":
+ 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":
+ 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 == model.DialogQuitConfirm {
+ m.quitting = true
+ return m, tea.Quit
+ }
+ m.mode = model.ModeNormal
+ m.dialog = model.DialogNone
+ return m, nil
+ case "q":
+ if m.dialog == model.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 model.DialogSaveAs:
+ name := m.ti.Value()
+ if name != "" {
+ m.rootMap.Filename = name
+ if err := mapio.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 model.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 model.DialogQuitConfirm:
+ m.quitting = true
+ return m, tea.Quit
+ case model.DialogDeleteSubmapConfirm:
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ delete(m.curMap().Submaps, m.cursor)
+ case model.DialogRenameSymbol:
+ name := m.ti.Value()
+ if name != "" && m.selected < len(m.curPalette()) {
+ m.curPalette()[m.selected].Name = name
+ }
+ case model.DialogRenameMap:
+ name := m.ti.Value()
+ if name != "" {
+ m.curMap().Name = name
+ }
+ case model.DialogOpenMap:
+ name := m.ti.Value()
+ if name != "" {
+ return m, m.loadMapCmd(name)
+ }
+ }
+ m.mode = model.ModeNormal
+ m.dialog = model.DialogNone
+ return m, nil
+ }
+}
+
+func (m *AppModel) doFileAction(path string) (tea.Model, tea.Cmd) {
+ switch m.dialog {
+ case model.DialogFileSave, model.DialogSaveAs:
+ m.rootMap.Filename = path
+ if err := mapio.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 model.DialogFileOpen, model.DialogOpenMap:
+ m.mode = model.ModeNormal
+ m.dialog = model.DialogNone
+ return m, m.loadMapCmd(path)
+ }
+ m.mode = model.ModeNormal
+ m.dialog = model.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 = model.ModeDialog
+ m.dialog = model.DialogFileSave
+ m.openFilePicker()
+ m.ti.Focus()
+ return
+ }
+ if err := mapio.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()))
+}
+
+func (m *AppModel) drillDown() {
+ m.cancelPreview()
+ sub, ok := m.curMap().Submaps[m.cursor]
+ if !ok {
+ sub = model.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 = model.Point{X: 0, Y: 0}
+ m.offset = model.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 = model.Point{X: -1, Y: -1}
+ }
+ m.offset = model.Point{X: 0, Y: 0}
+ m.prevCursor = model.Point{X: -1, Y: -1}
+}
+
+func (m *AppModel) isMapBlank(mm *model.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
+}
+
+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:
+ m.mode = model.ModeDialog
+ m.dialog = model.DialogRenameMap
+ m.ti.SetValue(m.curMap().Name)
+ m.ti.Focus()
+ case 1:
+ m.mode = model.ModeDialog
+ m.dialog = model.DialogResize
+ m.ti.SetValue(fmt.Sprintf("%dx%d", m.curMap().Width, m.curMap().Height))
+ m.ti.Focus()
+ case 2:
+ m.mode = model.ModeDialog
+ m.dialog = model.DialogFileSave
+ m.openFilePicker()
+ m.ti.SetValue(m.rootMap.Filename)
+ m.ti.Focus()
+ case 3:
+ m.unicode = !m.unicode
+ case 4:
+ m.colorMode = !m.colorMode
+ case 5:
+ m.fillShapes = !m.fillShapes
+ case 6:
+ m.saveMap()
+ case 7:
+ m.mode = model.ModeDialog
+ m.dialog = model.DialogFileOpen
+ m.openFilePicker()
+ m.ti.Focus()
+ case 8:
+ if m.dirty {
+ m.mode = model.ModeDialog
+ m.dialog = model.DialogQuitConfirm
+ } else {
+ m.quitting = true
+ }
+ }
+ return
+ }
+ pos += w
+ }
+}
+
+func (m *AppModel) handleSidebarClick(x, y int) {
+ relY := y - 1
+
+ if relY >= 1 && relY <= 10 {
+ idx := m.palettePage*10 + (relY - 1)
+ if idx < len(m.curPalette()) {
+ m.selected = idx
+ }
+ return
+ }
+
+ if relY == 11 {
+ if x < m.width-sidebarW+7 {
+ m.palettePagePrev()
+ } else {
+ m.palettePageNext()
+ }
+ return
+ }
+
+ 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 = model.ModeDialog
+ m.dialog = model.DialogRenameSymbol
+ m.ti.SetValue(m.curPalette()[model.Clamp(m.selected, 0, len(m.curPalette())-1)].Name)
+ m.ti.Focus()
+ } else {
+ m.openColorPicker(false)
+ }
+ return
+ }
+
+ toolRow := relY - 16
+ if toolRow >= 0 && toolRow < 8 {
+ newTool := model.Tool(toolRow)
+ if m.tool != newTool && m.mode == model.ModeTextEdit {
+ m.mode = model.ModeNormal
+ m.movingLabel = -1
+ m.textEditing = false
+ m.textInput.Blur()
+ }
+ m.tool = newTool
+ }
+ 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) >= 100 {
+ return
+ }
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ m.curMap().Palette = append(p, model.Terrain{Name: "new", Symbol: "?", ASCII: "?", Colors: []model.TerrainColor{{Color: "255", Weight: 100}}})
+}
+
+func (m *AppModel) removeSymbol() {
+ if len(m.curPalette()) <= 1 {
+ return
+ }
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ idx := model.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([][]model.Cell, h)
+ for y := range newGrid {
+ newGrid[y] = make([]model.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 = model.Clamp(m.cursor.X, 0, w-1)
+ m.cursor.Y = model.Clamp(m.cursor.Y, 0, h-1)
+}
diff --git a/internal/tui/handlers.go b/internal/tui/handlers.go
new file mode 100644
index 0000000..6f78922
--- /dev/null
+++ b/internal/tui/handlers.go
@@ -0,0 +1,501 @@
+package tui
+
+import (
+ "fmt"
+
+ "tui-ascii-mapper/internal/model"
+ "tui-ascii-mapper/internal/tools"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ m.dialogMsg = ""
+ if m.mode == model.ModeDialog {
+ return m.handleDialogKey(msg)
+ }
+ if m.colorPicker != nil && m.colorPicker.Active {
+ return m.handleColorPickerKey(msg)
+ }
+ if m.mode == model.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 = model.ModeDialog
+ m.dialog = model.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 = model.ModeDialog
+ m.dialog = model.DialogResize
+ m.ti.SetValue(fmt.Sprintf("%dx%d", m.curMap().Width, m.curMap().Height))
+ m.ti.Focus()
+ return m, nil
+ case "!":
+ m.tool = model.ToolBrush
+ m.drawHeld, m.eraseHeld = false, false
+ case "@":
+ m.tool = model.ToolSelect
+ m.drawHeld, m.eraseHeld = false, false
+ case "#":
+ m.tool = model.ToolErase
+ m.drawHeld, m.eraseHeld = false, false
+ case "$":
+ m.tool = model.ToolFill
+ m.drawHeld, m.eraseHeld = false, false
+ case "%":
+ m.tool = model.ToolLine
+ m.drawHeld, m.eraseHeld = false, false
+ case "^":
+ m.tool = model.ToolRect
+ m.drawHeld, m.eraseHeld = false, false
+ case "&":
+ m.tool = model.ToolCircle
+ m.drawHeld, m.eraseHeld = false, false
+ case "*":
+ m.tool = model.ToolText
+ m.drawHeld, m.eraseHeld = false, false
+ case "(":
+ m.tool = model.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 == model.ToolText {
+ return m.handleSpace()
+ }
+ if m.movingLabel >= 0 {
+ m.placeMovingLabel()
+ return m, nil
+ }
+ if m.mode == model.ModeLinePreview {
+ m.finalizeLinePreview()
+ } else if m.mode == model.ModeRectPreview {
+ m.finalizeRectPreview()
+ } else if m.mode == model.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 = model.ModeDialog
+ m.dialog = model.DialogDeleteSubmapConfirm
+ }
+ return m, nil
+ case " ", "space":
+ return m.handleSpace()
+ case "backspace", "x":
+ return m.handleBackspace()
+ case "e":
+ if m.tool == model.ToolText {
+ return m.editTextAtCursor()
+ }
+ case "c":
+ if m.tool == model.ToolText {
+ idx := tools.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 = model.Point{X: 0, Y: 0}
+ m.dragMouseOrigin = model.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
+ var indices []int
+ for i := range m.curPalette() {
+ if i%10 == hk {
+ indices = append(indices, i)
+ }
+ }
+ if len(indices) == 0 {
+ return m, nil
+ }
+ curOff := m.hotkeySelect[hk]
+ if m.selected%10 == hk && model.ContainsInt(indices, m.selected) {
+ curOff = (curOff + 1) % len(indices)
+ } else {
+ curOff = 0
+ }
+ m.hotkeySelect[hk] = curOff
+ m.selected = indices[curOff]
+ 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
+
+ if m.mode == model.ModeLinePreview {
+ m.finalizeLinePreview()
+ return m, nil
+ }
+ if m.mode == model.ModeRectPreview {
+ m.finalizeRectPreview()
+ return m, nil
+ }
+ if m.mode == model.ModeCirclePreview {
+ m.finalizeCirclePreview()
+ return m, nil
+ }
+
+ if m.tool == model.ToolSelect {
+ return m, nil
+ }
+
+ if m.tool == model.ToolLine && m.mode == model.ModeNormal {
+ m.lineStart = m.cursor
+ m.mode = model.ModeLinePreview
+ m.linePreview = nil
+ return m, nil
+ }
+ if m.tool == model.ToolRect && m.mode == model.ModeNormal {
+ m.rectStart = m.cursor
+ m.mode = model.ModeRectPreview
+ m.rectPreview = nil
+ return m, nil
+ }
+ if m.tool == model.ToolCircle && m.mode == model.ModeNormal {
+ m.circleCenter = m.cursor
+ m.mode = model.ModeCirclePreview
+ m.circlePreview = nil
+ return m, nil
+ }
+ if m.tool == model.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())
+ tools.MoveTextLabel(m.curMap(), old, newPos)
+ }
+ }
+ m.movingLabel = -1
+ return m, nil
+ }
+ idx := tools.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 == model.ToolErase {
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ m.applyBrush(m.cursor, true)
+ return m, nil
+ }
+ if m.tool == model.ToolFill {
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ m.fillAt(m.cursor)
+ return m, nil
+ }
+ 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 == model.ModeLinePreview || m.mode == model.ModeRectPreview || m.mode == model.ModeCirclePreview {
+ m.cancelPreview()
+ return m, nil
+ }
+ if m.tool == model.ToolText {
+ idx := tools.FindTextLabelAt(m.curMap(), m.cursor)
+ if idx >= 0 {
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ tools.RemoveTextLabel(m.curMap(), m.curMap().TextLabels[idx].Start)
+ }
+ return m, nil
+ }
+ 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())
+ tools.ApplyPoints(m.curMap(), m.linePreview, m.selected, m.curPalette())
+ m.linePreview = nil
+ m.mode = model.ModeNormal
+}
+
+func (m *AppModel) finalizeRectPreview() {
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ tools.ApplyPoints(m.curMap(), m.rectPreview, m.selected, m.curPalette())
+ m.rectPreview = nil
+ m.mode = model.ModeNormal
+}
+
+func (m *AppModel) finalizeCirclePreview() {
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ tools.ApplyPoints(m.curMap(), m.circlePreview, m.selected, m.curPalette())
+ m.circlePreview = nil
+ m.mode = model.ModeNormal
+}
+
+func (m *AppModel) cancelPreview() {
+ m.linePreview = nil
+ m.rectPreview = nil
+ m.circlePreview = nil
+ m.mode = model.ModeNormal
+}
+
+func (m *AppModel) moveCursor(dx, dy int) {
+ m.cursor.X = model.Clamp(m.cursor.X+dx, 0, m.curMap().Width-1)
+ m.cursor.Y = model.Clamp(m.cursor.Y+dy, 0, m.curMap().Height-1)
+ if m.mode == model.ModeLinePreview {
+ m.linePreview = tools.ThickenPoints(tools.BresenhamLine(m.lineStart, m.cursor), m.brushWidth)
+ } else if m.mode == model.ModeRectPreview {
+ m.rectPreview = tools.ThickenPoints(tools.DrawRect(m.rectStart, m.cursor, m.fillShapes), m.brushWidth)
+ } else if m.mode == model.ModeCirclePreview {
+ m.circlePreview = tools.ThickenPoints(tools.DrawCircle(m.circleCenter, m.cursor, m.fillShapes), m.brushWidth)
+ }
+}
+
+func (m *AppModel) applyBrush(center model.Point, erase ...bool) {
+ if !m.curMap().InBounds(center) {
+ return
+ }
+ terrain := m.selected
+ if len(erase) > 0 && erase[0] {
+ terrain = -1
+ }
+ palette := m.curPalette()
+ switch m.tool {
+ case model.ToolBrush:
+ tools.Brush(m.curMap(), center, terrain, m.brushWidth, palette)
+ case model.ToolErase:
+ tools.Brush(m.curMap(), center, terrain, m.brushWidth, palette)
+ default:
+ color := ""
+ if terrain >= 0 && terrain < len(palette) {
+ color = palette[terrain].PickColor()
+ }
+ m.curMap().SetCell(center, terrain, color)
+ }
+}
+
+func (m *AppModel) fillAt(p model.Point) {
+ if m.selected >= 0 && m.selected < len(m.curPalette()) {
+ tools.FloodFill(m.curMap(), p, m.selected, m.curPalette())
+ }
+}
+
+func (m *AppModel) clampCursor() {
+ m.cursor.X = model.Clamp(m.cursor.X, 0, m.curMap().Width-1)
+ m.cursor.Y = model.Clamp(m.cursor.Y, 0, m.curMap().Height-1)
+}
+
+func (m *AppModel) startTextEdit(p model.Point) {
+ m.mode = model.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 = model.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 := tools.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())
+ tools.MoveTextLabel(m.curMap(), old, newPos)
+ }
+ }
+ m.movingLabel = -1
+ m.dragLabelOrigin = model.Point{X: 0, Y: 0}
+ m.dragMouseOrigin = model.Point{X: 0, Y: 0}
+}
+
+func (m *AppModel) labelDragPos() model.Point {
+ return model.Point{
+ X: m.dragLabelOrigin.X + (m.cursor.X - m.dragMouseOrigin.X),
+ Y: m.dragLabelOrigin.Y + (m.cursor.Y - m.dragMouseOrigin.Y),
+ }
+}
+
+func (m *AppModel) handleTextEditKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ key := msg.String()
+
+ switch key {
+ case "esc":
+ m.mode = model.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())
+ start := m.cursor
+ if m.textEditing && m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) {
+ tools.RemoveTextLabel(m.curMap(), m.curMap().TextLabels[m.movingLabel].Start)
+ start = m.textCursorStart
+ m.textEditing = false
+ }
+ tools.PlaceTextLabel(m.curMap(), start, text, m.textColor)
+ }
+ m.mode = model.ModeNormal
+ m.movingLabel = -1
+ m.textEditing = false
+ m.textInput.Blur()
+}
diff --git a/internal/tui/render.go b/internal/tui/render.go
new file mode 100644
index 0000000..1b34a21
--- /dev/null
+++ b/internal/tui/render.go
@@ -0,0 +1,465 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "tui-ascii-mapper/internal/model"
+
+ "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")
+)
+
+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 := model.Point{X: mx, Y: my}
+ if !m.curMap().InBounds(p) {
+ sb.WriteString(m.cellStr("\u00b7", "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 = "\u00b7"
+ fg = "250"
+ }
+
+ if m.mode == model.ModeTextEdit && m.tool == model.ToolText {
+ text := m.textInput.Value()
+ cursorCh := ""
+ if m.textInput.Focused() {
+ cursorCh = "\u2502"
+ }
+ 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
+ }
+ }
+ }
+ if m.mode != model.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 model.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')
+ }
+ 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_ := []model.Tool{model.ToolBrush, model.ToolSelect, model.ToolErase, model.ToolFill, model.ToolLine, model.ToolRect, model.ToolCircle, model.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 Width ══"))
+ 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')
+ }
+ lines := strings.Split(sb.String(), "\n")
+ if len(lines) > gh {
+ lines = lines[:gh]
+ }
+ return strings.Join(lines, "\n")
+}
+
+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) renderDialogBox() string {
+ switch m.dialog {
+ case model.DialogSaveAs:
+ return m.renderFilePickerPopup("Save As")
+ case model.DialogOpenMap:
+ return m.renderFilePickerPopup("Open Map")
+ case model.DialogFileSave:
+ return m.renderFilePickerPopup("Save As")
+ case model.DialogFileOpen:
+ return m.renderFilePickerPopup("Open Map")
+ case model.DialogResize:
+ return renderPopup("Resize Map", "Size (e.g. 80x25):", m.ti.View())
+ case model.DialogQuitConfirm:
+ return renderPopup("Quit", "Quit without saving?", "[Enter] Quit [Esc] Cancel")
+ case model.DialogDeleteSubmapConfirm:
+ return renderPopup("Delete Submap",
+ fmt.Sprintf("Delete submap at %d,%d?", m.cursor.X, m.cursor.Y),
+ "[Enter] Confirm [Esc] Cancel")
+ case model.DialogRenameSymbol:
+ return renderPopup("Rename Symbol", "New name:", m.ti.View())
+ case model.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
+}
diff --git a/internal/tui/update.go b/internal/tui/update.go
new file mode 100644
index 0000000..87e0917
--- /dev/null
+++ b/internal/tui/update.go
@@ -0,0 +1,296 @@
+package tui
+
+import (
+ "fmt"
+
+ "tui-ascii-mapper/internal/model"
+ "tui-ascii-mapper/internal/tools"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+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 = model.ModeNormal
+ m.dialog = model.DialogNone
+ return m, nil
+ }
+ m.map_ = msg.data
+ m.rootMap = msg.data
+ m.undo = &model.UndoStack{}
+ m.undo.Push(m.map_)
+ m.cursor = model.Point{X: 0, Y: 0}
+ m.offset = model.Point{X: 0, Y: 0}
+ m.dialogMsg = fmt.Sprintf("Loaded %s", msg.data.Filename)
+ m.mode = model.ModeNormal
+ m.dialog = model.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 == model.ModeDialog || m.mode == model.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
+}
+
+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 == model.DialogFileSave || m.dialog == model.DialogFileOpen || m.dialog == model.DialogSaveAs || m.dialog == model.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 = model.Point{X: -1, Y: -1}
+ if inGrid {
+ p := model.Point{X: gx, Y: gy}
+ switch m.tool {
+ case model.ToolLine:
+ m.mouseStart = p
+ m.mode = model.ModeLinePreview
+ m.linePreview = nil
+ case model.ToolRect:
+ m.mouseStart = p
+ m.mode = model.ModeRectPreview
+ m.rectPreview = nil
+ case model.ToolCircle:
+ m.mouseStart = p
+ m.mode = model.ModeCirclePreview
+ m.circlePreview = nil
+ case model.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 = model.Point{X: -1, Y: -1}
+ return m, nil
+ }
+
+ if isRightPress {
+ if inGrid {
+ if m.tool == model.ToolText {
+ idx := tools.FindTextLabelAt(m.curMap(), model.Point{X: gx, Y: gy})
+ if idx >= 0 {
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ tools.RemoveTextLabel(m.curMap(), m.curMap().TextLabels[idx].Start)
+ m.movingLabel = -1
+ }
+ } else {
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ m.applyBrush(model.Point{X: gx, Y: gy}, true)
+ m.lastPaint = model.Point{X: gx, Y: gy}
+ }
+ }
+ return m, nil
+ }
+
+ if m.mouseDown && inGrid && (gx != m.lastPaint.X || gy != m.lastPaint.Y) {
+ if m.movingLabel >= 0 && m.tool == model.ToolText {
+ m.cursor = model.Point{X: gx, Y: gy}
+ m.clampCursor()
+ } else if m.tool == model.ToolLine {
+ m.linePreview = tools.ThickenPoints(tools.BresenhamLine(m.mouseStart, model.Point{X: gx, Y: gy}), m.brushWidth)
+ m.lastPaint = model.Point{X: gx, Y: gy}
+ } else if m.tool == model.ToolRect {
+ m.rectPreview = tools.ThickenPoints(tools.DrawRect(m.mouseStart, model.Point{X: gx, Y: gy}, m.fillShapes), m.brushWidth)
+ m.lastPaint = model.Point{X: gx, Y: gy}
+ } else if m.tool == model.ToolCircle {
+ m.circlePreview = tools.ThickenPoints(tools.DrawCircle(m.mouseStart, model.Point{X: gx, Y: gy}, m.fillShapes), m.brushWidth)
+ m.lastPaint = model.Point{X: gx, Y: gy}
+ } else if m.tool != model.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 := model.Point{X: gx, Y: gy}
+ switch m.tool {
+ case model.ToolBrush:
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ m.applyBrush(p)
+ m.lastPaint = p
+ case model.ToolErase:
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ m.applyBrush(p, true)
+ m.lastPaint = p
+ case model.ToolFill:
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ m.fillAt(p)
+ m.lastPaint = p
+ case model.ToolText:
+ idx := tools.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 := model.Point{X: gx, Y: gy}
+ switch m.tool {
+ case model.ToolLine:
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ m.finishShape(m.mouseStart, p, model.ToolLine)
+ m.linePreview = nil
+ m.mode = model.ModeNormal
+ case model.ToolRect:
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ m.finishShape(m.mouseStart, p, model.ToolRect)
+ m.rectPreview = nil
+ m.mode = model.ModeNormal
+ case model.ToolCircle:
+ m.dirty = true
+ m.undo.Push(m.curMap())
+ m.finishShape(m.mouseStart, p, model.ToolCircle)
+ m.circlePreview = nil
+ m.mode = model.ModeNormal
+ case model.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())
+ tools.MoveTextLabel(m.curMap(), old, newPos)
+ m.movingLabel = -1
+ } else {
+ m.startTextEditText(m.movingLabel)
+ }
+ } else if m.cursor == p && m.curMap().InBounds(p) {
+ m.startTextEdit(p)
+ }
+ m.dragLabelOrigin = model.Point{X: 0, Y: 0}
+ m.dragMouseOrigin = model.Point{X: 0, Y: 0}
+ }
+}
+
+func (m *AppModel) finishShape(a, b model.Point, tool model.Tool) {
+ palette := m.curPalette()
+ terrain := m.selected
+ if terrain < 0 || terrain >= len(palette) {
+ return
+ }
+ var pts []model.Point
+ switch tool {
+ case model.ToolLine:
+ pts = tools.BresenhamLine(a, b)
+ case model.ToolRect:
+ pts = tools.DrawRect(a, b, m.fillShapes)
+ case model.ToolCircle:
+ pts = tools.DrawCircle(a, b, m.fillShapes)
+ }
+ tools.ApplyPoints(m.curMap(), pts, terrain, palette)
+ if m.brushWidth > 1 {
+ for _, pt := range pts {
+ tools.Brush(m.curMap(), pt, terrain, m.brushWidth, palette)
+ }
+ }
+}
diff --git a/internal/tui/view.go b/internal/tui/view.go
new file mode 100644
index 0000000..fd8acdc
--- /dev/null
+++ b/internal/tui/view.go
@@ -0,0 +1,142 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "tui-ascii-mapper/internal/model"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+func (m *AppModel) View() string {
+ if m.quitting {
+ return ""
+ }
+
+ if m.mode == model.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) 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 = model.Clamp(m.offset.X, 0, max(0, m.curMap().Width-gw))
+ m.offset.Y = model.Clamp(m.offset.Y, 0, max(0, m.curMap().Height-gh))
+}
+
+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 padRight(s string, w int) string {
+ for lipgloss.Width(s) < w {
+ s += " "
+ }
+ return s
+}
+
+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
+}
diff --git a/model.go b/model.go
deleted file mode 100644
index 65b1132..0000000
--- a/model.go
+++ /dev/null
@@ -1,267 +0,0 @@
-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 "Rectangle"
- 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")
-}