aboutsummaryrefslogtreecommitdiff
path: root/app.go
diff options
context:
space:
mode:
Diffstat (limited to 'app.go')
-rw-r--r--app.go2249
1 files changed, 0 insertions, 2249 deletions
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)
- }
-}