From ba34490aaed5f7c242c2b0453130fefc07d0d5d0 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 29 Jun 2026 04:03:38 -0400 Subject: restructured project --- Makefile | 4 +- app.go | 2249 --------------------------------------- config.go | 137 --- firstmap | 2417 ------------------------------------------ internal/config/config.go | 135 +++ internal/mapio/mapio.go | 212 ++++ internal/model/enums.go | 81 ++ internal/model/map.go | 99 ++ internal/model/model_test.go | 99 ++ internal/model/terrain.go | 43 + internal/model/undo.go | 42 + internal/tools/tools.go | 327 ++++++ internal/tools/tools_test.go | 36 + internal/tui/app.go | 155 +++ internal/tui/colorpicker.go | 180 ++++ internal/tui/dialogs.go | 526 +++++++++ internal/tui/handlers.go | 501 +++++++++ internal/tui/render.go | 465 ++++++++ internal/tui/update.go | 296 ++++++ internal/tui/view.go | 142 +++ io.go | 222 ---- model.go | 267 ----- tools.go | 385 ------- 23 files changed, 3341 insertions(+), 5679 deletions(-) delete mode 100644 app.go delete mode 100644 config.go delete mode 100644 firstmap create mode 100644 internal/config/config.go create mode 100644 internal/mapio/mapio.go create mode 100644 internal/model/enums.go create mode 100644 internal/model/map.go create mode 100644 internal/model/model_test.go create mode 100644 internal/model/terrain.go create mode 100644 internal/model/undo.go create mode 100644 internal/tools/tools.go create mode 100644 internal/tools/tools_test.go create mode 100644 internal/tui/app.go create mode 100644 internal/tui/colorpicker.go create mode 100644 internal/tui/dialogs.go create mode 100644 internal/tui/handlers.go create mode 100644 internal/tui/render.go create mode 100644 internal/tui/update.go create mode 100644 internal/tui/view.go delete mode 100644 io.go delete mode 100644 model.go delete mode 100644 tools.go 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/internal/mapio/mapio.go b/internal/mapio/mapio.go new file mode 100644 index 0000000..58a8848 --- /dev/null +++ b/internal/mapio/mapio.go @@ -0,0 +1,212 @@ +package mapio + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "tui-ascii-mapper/internal/model" + "tui-ascii-mapper/internal/tools" + + "gopkg.in/yaml.v3" +) + +type SaveData struct { + 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"` +} + +func SerializeMap(m *model.Map, path string) error { + data := buildSaveData(m) + + yamlBytes, err := yaml.Marshal(data) + if err != nil { + return err + } + + var buf bytes.Buffer + buf.WriteString("---\n") + buf.Write(yamlBytes) + buf.WriteString("...\n") + + for _, row := range m.Grid { + for _, cell := range row { + if cell.Terrain < 0 || cell.Terrain >= len(m.Palette) { + buf.WriteByte(' ') + } else { + buf.WriteString(m.Palette[cell.Terrain].ASCII) + } + } + buf.WriteByte('\n') + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(path, buf.Bytes(), 0644) +} + +func buildSaveData(m *model.Map) SaveData { + sd := SaveData{ + Name: m.Name, + Width: m.Width, + Height: m.Height, + Palette: m.Palette, + } + var gb strings.Builder + for _, row := range m.Grid { + for _, cell := range row { + if cell.Terrain < 0 || cell.Terrain >= len(m.Palette) { + gb.WriteByte(' ') + } else { + gb.WriteString(m.Palette[cell.Terrain].ASCII) + } + } + gb.WriteByte('\n') + } + if m.Parent != nil { + sd.GridBody = gb.String() + } + + for y := range m.Grid { + for x := range m.Grid[y] { + c := m.Grid[y][x] + if c.Color != "" { + sd.GridColors = append(sd.GridColors, fmt.Sprintf("%d,%d,%s", x, y, c.Color)) + } + } + } + for _, tl := range m.TextLabels { + sd.TextLabels = append(sd.TextLabels, tl) + } + for pt, sub := range m.Submaps { + sd.Submaps = append(sd.Submaps, SubmapRef{ + X: pt.X, Y: pt.Y, + Data: buildSaveData(sub), + }) + } + return sd +} + +func DeserializeMap(path string) (*model.Map, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + content := string(data) + + if !strings.HasPrefix(content, "---\n") { + return nil, fmt.Errorf("invalid save file: missing YAML header") + } + + endIdx := strings.Index(content, "\n...\n") + if endIdx < 0 { + return nil, fmt.Errorf("invalid save file: missing ... terminator") + } + + yamlPart := content[4:endIdx] + gridPart := content[endIdx+5:] + + var sd SaveData + if err := yaml.Unmarshal([]byte(yamlPart), &sd); err != nil { + return nil, fmt.Errorf("invalid YAML header: %w", err) + } + + m := model.NewMap(sd.Name, sd.Width, sd.Height, sd.Palette) + m.Filename = path + + for _, entry := range sd.GridColors { + var x, y int + var c string + if n, _ := fmt.Sscanf(entry, "%d,%d,%s", &x, &y, &c); n == 3 { + if y >= 0 && y < m.Height && x >= 0 && x < m.Width { + m.Grid[y][x].Color = c + } + } + } + + lines := strings.Split(strings.TrimRight(gridPart, "\n"), "\n") + for y, line := range lines { + if y >= m.Height { + break + } + for x, ch := range line { + if x >= m.Width { + break + } + for i, t := range m.Palette { + if string(ch) == t.ASCII { + m.Grid[y][x].Terrain = i + break + } + } + } + } + + for _, tl := range sd.TextLabels { + tools.PlaceTextLabel(m, tl.Start, tl.Text, tl.Color) + } + + for _, sr := range sd.Submaps { + sub := restoreMap(&sr.Data, m) + m.Submaps[model.Point{X: sr.X, Y: sr.Y}] = sub + } + + return m, nil +} + +func restoreMap(sd *SaveData, parent *model.Map) *model.Map { + m := model.NewMap(sd.Name, sd.Width, sd.Height, sd.Palette) + m.Parent = parent + for _, entry := range sd.GridColors { + var x, y int + var c string + if n, _ := fmt.Sscanf(entry, "%d,%d,%s", &x, &y, &c); n == 3 { + if y >= 0 && y < m.Height && x >= 0 && x < m.Width { + m.Grid[y][x].Color = c + } + } + } + if sd.GridBody != "" { + lines := strings.Split(strings.TrimRight(sd.GridBody, "\n"), "\n") + for y, line := range lines { + if y >= m.Height { + break + } + for x, ch := range line { + if x >= m.Width { + break + } + for i, t := range m.Palette { + if string(ch) == t.ASCII { + m.Grid[y][x].Terrain = i + break + } + } + } + } + } + for _, tl := range sd.TextLabels { + tools.PlaceTextLabel(m, tl.Start, tl.Text, tl.Color) + } + for _, sr := range sd.Submaps { + sub := restoreMap(&sr.Data, m) + 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/internal/tools/tools.go b/internal/tools/tools.go new file mode 100644 index 0000000..77b9f27 --- /dev/null +++ b/internal/tools/tools.go @@ -0,0 +1,327 @@ +package tools + +import "tui-ascii-mapper/internal/model" + +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++ { + color := "" + if terrain >= 0 && terrain < len(palette) { + color = palette[terrain].PickColor() + } + m.SetCell(model.Point{X: center.X + dx, Y: center.Y + dy}, terrain, color) + } + } +} + +func ThickenPoints(pts []model.Point, size int) []model.Point { + if size <= 1 { + return pts + } + half := size / 2 + 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 := model.Point{X: p.X + dx, Y: p.Y + dy} + if !seen[np] { + seen[np] = true + result = append(result, np) + } + } + } + } + return result +} + +func FloodFill(m *model.Map, start model.Point, terrain int, palette []model.Terrain) { + if !m.InBounds(start) { + return + } + target := m.CellAt(start).Terrain + if target == terrain { + return + } + color := "" + if terrain >= 0 && terrain < len(palette) { + color = palette[terrain].PickColor() + } + type pt struct{ x, y int } + stack := []pt{{start.X, start.Y}} + visited := make([][]bool, m.Height) + for i := range visited { + visited[i] = make([]bool, m.Width) + } + for len(stack) > 0 { + p := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if !m.InBounds(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(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}) + } +} + +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 := iabs(x1 - x0) + dy := -iabs(y1 - y0) + sx, sy := 1, 1 + if x0 > x1 { + sx = -1 + } + if y0 > y1 { + sy = -1 + } + err := dx + dy + for { + pts = append(pts, model.Point{X: x0, Y: y0}) + if x0 == x1 && y0 == y1 { + break + } + e2 := 2 * err + if e2 >= dy { + err += dy + x0 += sx + } + if e2 <= dx { + err += dx + y0 += sy + } + } + return pts +} + +func iabs(x int) int { + if x < 0 { + return -x + } + return x +} + +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 { + x0, x1 = x1, x0 + } + if y0 > y1 { + y0, y1 = y1, y0 + } + var pts []model.Point + if filled { + for y := y0; y <= y1; y++ { + for x := x0; x <= x1; x++ { + pts = append(pts, model.Point{X: x, Y: y}) + } + } + return pts + } + for x := x0; x <= x1; x++ { + 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, model.Point{X: x0, Y: y}, model.Point{X: x1, Y: y}) + } + return pts +} + +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 + } + 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, model.Point{X: center.X + dx, Y: center.Y + dy}) + } + } else { + if dist2 <= r && dist2 > (radius-1)*(radius-1) { + pts = append(pts, model.Point{X: center.X + dx, Y: center.Y + dy}) + } + } + } + } + return pts +} + +func DrawOval(f1, f2 model.Point, filled bool) []model.Point { + dx := f2.X - f1.X + dy := f2.Y - f1.Y + dist := IntSqrt(dx*dx + dy*dy) + if dist == 0 { + return nil + } + a := dist * 3 / 2 + if a < 1 { + a = 1 + } + a2 := a * a + c2 := dist * dist / 4 + b2 := a2 - c2 + if b2 < 0 { + b2 = 0 + } + + cx := (f1.X + f2.X) / 2 + cy := (f1.Y + f2.Y) / 2 + + minX := cx - a - 1 + maxX := cx + a + 1 + minY := cy - a - 1 + maxY := cy + a + 1 + + var pts []model.Point + for py := minY; py <= maxY; py++ { + for px := minX; px <= maxX; px++ { + d1 := distSq(px, py, f1.X, f1.Y) + d2 := distSq(px, py, f2.X, f2.Y) + sum := IntSqrt(d1) + IntSqrt(d2) + + if filled { + if sum <= 2*a { + pts = append(pts, model.Point{X: px, Y: py}) + } + } else { + if sum >= 2*a-1 && sum <= 2*a+1 { + pts = append(pts, model.Point{X: px, Y: py}) + } + } + } + } + return pts +} + +func distSq(x1, y1, x2, y2 int) int { + dx := x1 - x2 + dy := y1 - y2 + return dx*dx + dy*dy +} + +func IntSqrt(n int) int { + if n <= 0 { + return 0 + } + lo, hi := 0, n + for lo < hi { + mid := (lo + hi + 1) / 2 + if mid*mid <= n { + lo = mid + } else { + hi = mid - 1 + } + } + return lo +} + +func ApplyPoints(m *model.Map, pts []model.Point, terrain int, palette []model.Terrain) { + color := "" + if terrain >= 0 && terrain < len(palette) { + color = palette[terrain].PickColor() + } + for _, p := range pts { + m.SetCell(p, terrain, color) + } +} + +func PlaceTextLabel(m *model.Map, start model.Point, text string, color string) { + RemoveTextLabel(m, start) + tl := model.TextLabel{Text: text, Start: start, Color: color} + m.TextLabels = append(m.TextLabels, tl) + runes := []rune(text) + for i, r := range runes { + p := model.Point{X: start.X + i, Y: start.Y} + if m.InBounds(p) { + m.Grid[p.Y][p.X].Text = string(r) + } + } +} + +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 + } + } + for y := range m.Grid { + for x := range m.Grid[y] { + if m.Grid[y][x].Text == "" { + continue + } + found := false + for _, tl := range m.TextLabels { + runes := []rune(tl.Text) + for i := range runes { + if tl.Start.X+i == x && tl.Start.Y == y { + found = true + break + } + } + if found { + break + } + } + if !found { + m.Grid[y][x].Text = "" + } + } + } +} + +func FindTextLabelAt(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 MoveTextLabel(m *model.Map, oldStart, newStart model.Point) { + for i, tl := range m.TextLabels { + if tl.Start == oldStart { + for _, p := range LabelPositions(tl) { + if m.InBounds(p) { + m.Grid[p.Y][p.X].Text = "" + } + } + m.TextLabels[i].Start = newStart + runes := []rune(tl.Text) + for j, r := range runes { + p := model.Point{X: newStart.X + j, Y: newStart.Y} + if m.InBounds(p) { + m.Grid[p.Y][p.X].Text = string(r) + } + } + return + } + } +} + +func LabelPositions(tl model.TextLabel) []model.Point { + var pts []model.Point + runes := []rune(tl.Text) + for i := range runes { + pts = append(pts, model.Point{X: tl.Start.X + i, Y: tl.Start.Y}) + } + return pts +} 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/io.go b/io.go deleted file mode 100644 index 1b2fd5d..0000000 --- a/io.go +++ /dev/null @@ -1,222 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - "strings" - - "gopkg.in/yaml.v3" -) - -// Save format is YAML header with metadata, then the text grid below. -// Format: -// --- -// -// ... -// - -type SaveData struct { - Name string `yaml:"name"` - Width int `yaml:"width"` - Height int `yaml:"height"` - Palette []Terrain `yaml:"palette"` - GridBody string `yaml:"grid_body,omitempty"` - GridColors []string `yaml:"grid_colors,omitempty"` - TextLabels []TextLabel `yaml:"text_labels,omitempty"` - Submaps []SubmapRef `yaml:"submaps,omitempty"` -} - -type SubmapRef struct { - X int `yaml:"x"` - Y int `yaml:"y"` - Data SaveData `yaml:"data"` -} - -// SerializeMap serializes the map to the save format. -func SerializeMap(m *Map, path string) error { - data := buildSaveData(m) - - yamlBytes, err := yaml.Marshal(data) - if err != nil { - return err - } - - var buf bytes.Buffer - buf.WriteString("---\n") - buf.Write(yamlBytes) - buf.WriteString("...\n") - - for _, row := range m.Grid { - for _, cell := range row { - if cell.Terrain < 0 || cell.Terrain >= len(m.Palette) { - buf.WriteByte(' ') - } else { - buf.WriteString(m.Palette[cell.Terrain].ASCII) - } - } - buf.WriteByte('\n') - } - - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - return os.WriteFile(path, buf.Bytes(), 0644) -} - -func buildSaveData(m *Map) SaveData { - sd := SaveData{ - Name: m.Name, - Width: m.Width, - Height: m.Height, - Palette: m.Palette, - } - // Build grid body (only stored in YAML for submaps) - var gb strings.Builder - for _, row := range m.Grid { - for _, cell := range row { - if cell.Terrain < 0 || cell.Terrain >= len(m.Palette) { - gb.WriteByte(' ') - } else { - gb.WriteString(m.Palette[cell.Terrain].ASCII) - } - } - gb.WriteByte('\n') - } - if m.Parent != nil { - sd.GridBody = gb.String() - } - - for y := range m.Grid { - for x := range m.Grid[y] { - c := m.Grid[y][x] - if c.Color != "" { - sd.GridColors = append(sd.GridColors, fmt.Sprintf("%d,%d,%s", x, y, c.Color)) - } - } - } - for _, tl := range m.TextLabels { - sd.TextLabels = append(sd.TextLabels, tl) - } - for pt, sub := range m.Submaps { - sd.Submaps = append(sd.Submaps, SubmapRef{ - X: pt.X, Y: pt.Y, - Data: buildSaveData(sub), - }) - } - return sd -} - -// DeserializeMap reads a saved map file. -func DeserializeMap(path string) (*Map, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - content := string(data) - - if !strings.HasPrefix(content, "---\n") { - return nil, fmt.Errorf("invalid save file: missing YAML header") - } - - endIdx := strings.Index(content, "\n...\n") - if endIdx < 0 { - return nil, fmt.Errorf("invalid save file: missing ... terminator") - } - - yamlPart := content[4:endIdx] - gridPart := content[endIdx+5:] - - var sd SaveData - if err := yaml.Unmarshal([]byte(yamlPart), &sd); err != nil { - return nil, fmt.Errorf("invalid YAML header: %w", err) - } - - m := NewMap(sd.Name, sd.Width, sd.Height, sd.Palette) - m.Filename = path - - // Restore colors - for _, entry := range sd.GridColors { - var x, y int - var c string - if n, _ := fmt.Sscanf(entry, "%d,%d,%s", &x, &y, &c); n == 3 { - if y >= 0 && y < m.Height && x >= 0 && x < m.Width { - m.Grid[y][x].Color = c - } - } - } - - lines := strings.Split(strings.TrimRight(gridPart, "\n"), "\n") - for y, line := range lines { - if y >= m.Height { - break - } - for x, ch := range line { - if x >= m.Width { - break - } - for i, t := range m.Palette { - if string(ch) == t.ASCII { - m.Grid[y][x].Terrain = i - break - } - } - } - } - - for _, tl := range sd.TextLabels { - PlaceTextLabel(m, tl.Start, tl.Text, tl.Color) - } - - for _, sr := range sd.Submaps { - sub := restoreMap(&sr.Data, m) - m.Submaps[Point{sr.X, sr.Y}] = sub - } - - return m, nil -} - -func restoreMap(sd *SaveData, parent *Map) *Map { - m := NewMap(sd.Name, sd.Width, sd.Height, sd.Palette) - m.Parent = parent - // Restore colors - for _, entry := range sd.GridColors { - var x, y int - var c string - if n, _ := fmt.Sscanf(entry, "%d,%d,%s", &x, &y, &c); n == 3 { - if y >= 0 && y < m.Height && x >= 0 && x < m.Width { - m.Grid[y][x].Color = c - } - } - } - // Restore grid from GridBody - if sd.GridBody != "" { - lines := strings.Split(strings.TrimRight(sd.GridBody, "\n"), "\n") - for y, line := range lines { - if y >= m.Height { - break - } - for x, ch := range line { - if x >= m.Width { - break - } - for i, t := range m.Palette { - if string(ch) == t.ASCII { - m.Grid[y][x].Terrain = i - break - } - } - } - } - } - for _, tl := range sd.TextLabels { - PlaceTextLabel(m, tl.Start, tl.Text, tl.Color) - } - for _, sr := range sd.Submaps { - sub := restoreMap(&sr.Data, m) - m.Submaps[Point{sr.X, sr.Y}] = sub - } - return m -} diff --git a/model.go b/model.go 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") -} diff --git a/tools.go b/tools.go deleted file mode 100644 index e745892..0000000 --- a/tools.go +++ /dev/null @@ -1,385 +0,0 @@ -package main - -import "fmt" - -// Brush applies terrain to a block centered at p. Each cell gets independent random color. -func Brush(m *Map, center Point, terrain int, size int, palette []Terrain) { - half := size / 2 - for dy := -half; dy <= half; dy++ { - for dx := -half; dx <= half; dx++ { - color := "" - if terrain >= 0 && terrain < len(palette) { - color = palette[terrain].PickColor() - } - m.SetCell(Point{center.X + dx, center.Y + dy}, terrain, color) - } - } -} - -// ThickenPoints expands a set of points by the given brush size, returning deduplicated points. -func ThickenPoints(pts []Point, size int) []Point { - if size <= 1 { - return pts - } - half := size / 2 - seen := make(map[Point]bool) - var result []Point - for _, p := range pts { - for dy := -half; dy <= half; dy++ { - for dx := -half; dx <= half; dx++ { - np := Point{p.X + dx, p.Y + dy} - if !seen[np] { - seen[np] = true - result = append(result, np) - } - } - } - } - return result -} - -// FloodFill fills a contiguous area from start with terrain. -func FloodFill(m *Map, start Point, terrain int, palette []Terrain) { - if !m.InBounds(start) { - return - } - target := m.CellAt(start).Terrain - if target == terrain { - return - } - color := "" - if terrain >= 0 && terrain < len(palette) { - color = palette[terrain].PickColor() - } - type pt struct{ x, y int } - stack := []pt{{start.X, start.Y}} - visited := make([][]bool, m.Height) - for i := range visited { - visited[i] = make([]bool, m.Width) - } - for len(stack) > 0 { - p := stack[len(stack)-1] - stack = stack[:len(stack)-1] - if !m.InBounds(Point{p.x, p.y}) || visited[p.y][p.x] { - continue - } - if m.Grid[p.y][p.x].Terrain != target { - continue - } - visited[p.y][p.x] = true - m.SetCell(Point{p.x, p.y}, terrain, color) - stack = append(stack, pt{p.x + 1, p.y}, pt{p.x - 1, p.y}, pt{p.x, p.y + 1}, pt{p.x, p.y - 1}) - } -} - -// BresenhamLine returns points along a line from a to b. -func BresenhamLine(a, b Point) []Point { - var pts []Point - x0, y0 := a.X, a.Y - x1, y1 := b.X, b.Y - dx := abs(x1 - x0) - dy := -abs(y1 - y0) - sx, sy := 1, 1 - if x0 > x1 { - sx = -1 - } - if y0 > y1 { - sy = -1 - } - err := dx + dy - for { - pts = append(pts, Point{x0, y0}) - if x0 == x1 && y0 == y1 { - break - } - e2 := 2 * err - if e2 >= dy { - err += dy - x0 += sx - } - if e2 <= dx { - err += dx - y0 += sy - } - } - return pts -} - -func abs(x int) int { - if x < 0 { - return -x - } - return x -} - -// DrawRect returns points for the outline (or fill) of a rectangle. -func DrawRect(a, b Point, filled bool) []Point { - x0, x1 := a.X, b.X - y0, y1 := a.Y, b.Y - if x0 > x1 { - x0, x1 = x1, x0 - } - if y0 > y1 { - y0, y1 = y1, y0 - } - var pts []Point - if filled { - for y := y0; y <= y1; y++ { - for x := x0; x <= x1; x++ { - pts = append(pts, Point{x, y}) - } - } - return pts - } - for x := x0; x <= x1; x++ { - pts = append(pts, Point{x, y0}, Point{x, y1}) - } - for y := y0 + 1; y < y1; y++ { - pts = append(pts, Point{x0, y}, Point{x1, y}) - } - return pts -} - -// DrawCircle returns points for the outline (or fill) of a circle. -func DrawCircle(center, edge Point, filled bool) []Point { - r2 := (edge.X-center.X)*(edge.X-center.X) + (edge.Y-center.Y)*(edge.Y-center.Y) - r := r2 - if r < 0 { - return nil - } - // integer sqrt approximation, good enough for grid - radius := intSqrt(r) - var pts []Point - for dy := -radius; dy <= radius; dy++ { - for dx := -radius; dx <= radius; dx++ { - dist2 := dx*dx + dy*dy - if filled { - if dist2 <= r { - pts = append(pts, Point{center.X + dx, center.Y + dy}) - } - } else { - // outline: approximate ring - if dist2 <= r && dist2 > (radius-1)*(radius-1) { - pts = append(pts, Point{center.X + dx, center.Y + dy}) - } - } - } - } - return pts -} - -// DrawOval returns points for the outline (or fill) of an ellipse with two foci. -func DrawOval(f1, f2 Point, filled bool) []Point { - // semi-major axis: enough to pass through f2 from f1, plus a bit - dx := f2.X - f1.X - dy := f2.Y - f1.Y - // Use distance between foci as 2c, major axis 2a = 2c * 1.5 (so oval extends) - dist := intSqrt(dx*dx + dy*dy) - if dist == 0 { - return nil - } - a := dist * 3 / 2 // major semi-axis (oval extends beyond both foci) - if a < 1 { - a = 1 - } - a2 := a * a - c2 := dist * dist / 4 // c = half distance between foci - b2 := a2 - c2 // b² = a² - c² - if b2 < 0 { - b2 = 0 - } - - // Center of ellipse - cx := (f1.X + f2.X) / 2 - cy := (f1.Y + f2.Y) / 2 - - // Bounding box - minX := cx - a - 1 - maxX := cx + a + 1 - minY := cy - a - 1 - maxY := cy + a + 1 - - var pts []Point - for py := minY; py <= maxY; py++ { - for px := minX; px <= maxX; px++ { - // Distances to foci - d1 := distSq(px, py, f1.X, f1.Y) - d2 := distSq(px, py, f2.X, f2.Y) - sum := intSqrt(d1) + intSqrt(d2) - - if filled { - if sum <= 2*a { - pts = append(pts, Point{px, py}) - } - } else { - // Outline: near the ellipse boundary - if sum >= 2*a-1 && sum <= 2*a+1 { - pts = append(pts, Point{px, py}) - } - } - } - } - return pts -} - -func distSq(x1, y1, x2, y2 int) int { - dx := x1 - x2 - dy := y1 - y2 - return dx*dx + dy*dy -} - -func intSqrt(n int) int { - if n <= 0 { - return 0 - } - lo, hi := 0, n - for lo < hi { - mid := (lo + hi + 1) / 2 - if mid*mid <= n { - lo = mid - } else { - hi = mid - 1 - } - } - return lo -} - -// ApplyPoints writes terrain to all given points. -func ApplyPoints(m *Map, pts []Point, terrain int, palette []Terrain) { - color := "" - if terrain >= 0 && terrain < len(palette) { - color = palette[terrain].PickColor() - } - for _, p := range pts { - m.SetCell(p, terrain, color) - } -} - -// PlaceTextLabel adds a text label at start, clearing any prior text in those cells. -func PlaceTextLabel(m *Map, start Point, text string, color string) { - // Remove any existing label starting at the same point - RemoveTextLabel(m, start) - tl := TextLabel{Text: text, Start: start, Color: color} - m.TextLabels = append(m.TextLabels, tl) - runes := []rune(text) - for i, r := range runes { - p := Point{start.X + i, start.Y} - if m.InBounds(p) { - m.Grid[p.Y][p.X].Text = string(r) - } - } -} - -// RemoveTextLabel removes the text label starting at start. -func RemoveTextLabel(m *Map, start Point) { - for i, tl := range m.TextLabels { - if tl.Start == start { - m.TextLabels = append(m.TextLabels[:i], m.TextLabels[i+1:]...) - break - } - } - // Also clear from grid cells - for y := range m.Grid { - for x := range m.Grid[y] { - if m.Grid[y][x].Text == "" { - continue - } - // Check if this cell belongs to a label - found := false - for _, tl := range m.TextLabels { - runes := []rune(tl.Text) - for i := range runes { - if tl.Start.X+i == x && tl.Start.Y == y { - found = true - break - } - } - if found { - break - } - } - if !found { - m.Grid[y][x].Text = "" - } - } - } -} - -// FindTextLabelAt returns the label index that covers point p, or -1. -func FindTextLabelAt(m *Map, p Point) int { - for i, tl := range m.TextLabels { - runes := []rune(tl.Text) - for j := range runes { - if tl.Start.X+j == p.X && tl.Start.Y == p.Y { - return i - } - } - } - return -1 -} - -// MoveTextLabel moves label at oldStart to newStart. -func MoveTextLabel(m *Map, oldStart, newStart Point) { - for i, tl := range m.TextLabels { - if tl.Start == oldStart { - // Clear old cells - for _, p := range LabelPositions(tl) { - if m.InBounds(p) { - m.Grid[p.Y][p.X].Text = "" - } - } - m.TextLabels[i].Start = newStart - // Set new cells - runes := []rune(tl.Text) - for j, r := range runes { - p := Point{newStart.X + j, newStart.Y} - if m.InBounds(p) { - m.Grid[p.Y][p.X].Text = string(r) - } - } - return - } - } -} - -func LabelPositions(tl TextLabel) []Point { - var pts []Point - runes := []rune(tl.Text) - for i := range runes { - pts = append(pts, Point{tl.Start.X + i, tl.Start.Y}) - } - return pts -} - -func DemoTools() { - m := NewMap("test", 10, 10, nil) - Brush(m, Point{5, 5}, 0, 3, nil) - if m.Grid[5][5].Terrain != 0 { - panic("Brush failed") - } - pts := BresenhamLine(Point{0, 0}, Point{3, 0}) - if len(pts) != 4 || pts[0] != (Point{0, 0}) || pts[3] != (Point{3, 0}) { - panic(fmt.Sprintf("Line failed: %v", pts)) - } - // intSqrt sanity - if intSqrt(25) != 5 || intSqrt(26) != 5 || intSqrt(0) != 0 { - panic("intSqrt failed") - } - // Color picking - t := Terrain{Colors: []TerrainColor{{Color: "22", Weight: 100}}} - if t.PickColor() != "22" { - panic("PickColor failed") - } - // Text labels — Grid[y][x] - PlaceTextLabel(m, Point{2, 2}, "ABC", "") - if m.Grid[2][2].Text != "A" || m.Grid[2][3].Text != "B" { - panic("TextLabel: " + m.Grid[2][2].Text + "," + m.Grid[2][3].Text) - } - RemoveTextLabel(m, Point{2, 2}) - if m.Grid[2][2].Text != "" { - panic("TextLabel remove failed") - } - fmt.Println("tools: ok") -} - - -- cgit v1.2.3