package main import ( "fmt" "os" "path/filepath" "sort" "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const sidebarW = 18 var ( toolbarStyle = lipgloss.NewStyle().Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252")) sidebarStyle = lipgloss.NewStyle().Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252")) statusStyle = lipgloss.NewStyle().Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252")) accentBg = lipgloss.Color("33") accentFg = lipgloss.Color("255") ) type AppModel struct { map_ *Map rootMap *Map cursor Point offset Point prevCursor Point // cursor position before entering submap tool Tool selected int brushWidth int // 1, 3, or 5 unicode bool colorMode bool fillShapes bool mode Mode dialog DialogType ti textinput.Model lineStart Point linePreview []Point rectStart Point rectPreview []Point circleCenter Point circlePreview []Point textEditing bool textInput textinput.Model textColor string movingLabel int dragLabelOrigin Point dragMouseOrigin Point textCursorStart Point // offset from mouse to text label start during drag mouseDown bool mouseBtn int mouseStart Point lastPaint Point drawHeld bool // space held down eraseHeld bool // backspace held down undo *UndoStack width int height int quitting bool cfg Config dialogMsg string colorPicker *ColorPickerState dirty bool undoPosAtSave int hotkeySelect [10]int palettePage int // sidebar palette page (0 = symbols 0-9, 1 = 10-19, etc.) // which offset when multiple symbols share a hotkey filePicker *FilePickerState } type FilePickerState struct { Files []os.DirEntry CurDir string Selected int PopupX int PopupY int ListTop int } type ColorPickerState struct { Active bool Cursor Point Selected []string ForText bool // true = editing text label color GridX int GridY int } func (m *AppModel) curMap() *Map { return m.map_ } func (m *AppModel) curPalette() []Terrain { if m.map_ != nil { return m.map_.Palette } return nil } func NewAppModel(cfg Config) *AppModel { palette := make([]Terrain, len(cfg.Symbols)) copy(palette, cfg.Symbols) root := NewMap("untitled", cfg.DefaultMapWidth, cfg.DefaultMapHeight, palette) undo := &UndoStack{} undo.Push(root) undoAtSave := undo.pos // initial save point matches initial state ti := textinput.New() ti.Placeholder = "" ti.Prompt = "" ti.CharLimit = 64 textTI := textinput.New() textTI.Placeholder = "" textTI.Prompt = "" textTI.CharLimit = 256 return &AppModel{ map_: root, rootMap: root, cursor: Point{X: 0, Y: 0}, prevCursor: Point{X: -1, Y: -1}, tool: ToolBrush, brushWidth: 1, selected: 0, unicode: true, colorMode: true, mode: ModeNormal, undo: undo, cfg: cfg, ti: ti, textInput: textTI, movingLabel: -1, lastPaint: Point{X: -1, Y: -1}, undoPosAtSave: undoAtSave, } } func (m *AppModel) Init() tea.Cmd { return tea.Batch( textinput.Blink, tea.EnableMouseCellMotion, ) } func (m *AppModel) loadMapCmd(path string) tea.Cmd { return func() tea.Msg { md, err := DeserializeMap(path) return mapLoadedMsg{data: md, err: err} } } type mapLoadedMsg struct { data *Map err error } func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil case mapLoadedMsg: if msg.err != nil { m.dialogMsg = fmt.Sprintf("Load error: %v", msg.err) m.mode = ModeNormal m.dialog = DialogNone return m, nil } m.map_ = msg.data m.rootMap = msg.data m.undo = &UndoStack{} m.undo.Push(m.map_) m.cursor = Point{X: 0, Y: 0} m.offset = Point{X: 0, Y: 0} m.dialogMsg = fmt.Sprintf("Loaded %s", msg.data.Filename) m.mode = ModeNormal m.dialog = DialogNone m.dirty = false m.undoPosAtSave = m.undo.pos return m, nil case tea.MouseMsg: return m.handleMouse(msg) case tea.KeyMsg: return m.handleKey(msg) } if m.mode == ModeDialog || m.mode == ModeTextEdit { var cmd tea.Cmd m.ti, cmd = m.ti.Update(msg) if cmd != nil { return m, cmd } m.textInput, cmd = m.textInput.Update(msg) if cmd != nil { return m, cmd } } return m, nil } // --- Mouse handling --- func (m *AppModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { x, y := msg.X, msg.Y if m.colorPicker != nil && m.colorPicker.Active { return m.handleColorPickerMouse(msg) } isFilePicker := m.dialog == DialogFileSave || m.dialog == DialogFileOpen || m.dialog == DialogSaveAs || m.dialog == DialogOpenMap if isFilePicker && m.filePicker != nil && m.filePicker.PopupY > 0 { return m.handleFilePickerMouse(msg) } isPress := msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress isRelease := msg.Action == tea.MouseActionRelease isRightPress := msg.Button == tea.MouseButtonRight && msg.Action == tea.MouseActionPress gridAreaW := m.width - sidebarW gridH := m.height - 1 if m.height > 6 { gridH -= 2 } if gridH < 1 { gridH = 1 } if gridAreaW < 1 { gridAreaW = 1 } if isPress && y == 0 { m.handleToolbarClick(x) if m.quitting { return m, tea.Quit } return m, nil } gridY := y - 1 onSidebar := gridAreaW > 0 && x >= gridAreaW if isPress && onSidebar && gridY >= 0 { m.handleSidebarClick(x, y) return m, nil } gx := x + m.offset.X gy := gridY + m.offset.Y inGrid := gridY >= 0 && gridY < gridH && x < gridAreaW if isPress { m.mouseDown = true m.lastPaint = Point{X: -1, Y: -1} if inGrid { p := Point{gx, gy} switch m.tool { case ToolLine: m.mouseStart = p m.mode = ModeLinePreview m.linePreview = nil case ToolRect: m.mouseStart = p m.mode = ModeRectPreview m.rectPreview = nil case ToolCircle: m.mouseStart = p m.mode = ModeCirclePreview m.circlePreview = nil case ToolSelect: m.cursor = p m.clampCursor() default: return m.mouseDraw(p.X, p.Y) } } return m, nil } if isRelease { if m.mouseDown { m.mouseRelease(gx, gy) } m.mouseDown = false m.lastPaint = Point{X: -1, Y: -1} return m, nil } if isRightPress { if inGrid { if m.tool == ToolText { idx := FindTextLabelAt(m.curMap(), Point{gx, gy}) if idx >= 0 { m.dirty = true m.undo.Push(m.curMap()) RemoveTextLabel(m.curMap(), m.curMap().TextLabels[idx].Start) m.movingLabel = -1 } } else { m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(Point{gx, gy}, true) m.lastPaint = Point{gx, gy} } } return m, nil } if m.mouseDown && inGrid && (gx != m.lastPaint.X || gy != m.lastPaint.Y) { if m.movingLabel >= 0 && m.tool == ToolText { m.cursor = Point{gx, gy} m.clampCursor() } else if m.tool == ToolLine { m.linePreview = ThickenPoints(BresenhamLine(m.mouseStart, Point{gx, gy}), m.brushWidth) m.lastPaint = Point{gx, gy} } else if m.tool == ToolRect { m.rectPreview = ThickenPoints(DrawRect(m.mouseStart, Point{gx, gy}, m.fillShapes), m.brushWidth) m.lastPaint = Point{gx, gy} } else if m.tool == ToolCircle { m.circlePreview = ThickenPoints(DrawCircle(m.mouseStart, Point{gx, gy}, m.fillShapes), m.brushWidth) m.lastPaint = Point{gx, gy} } else if m.tool != ToolText { m.mouseDraw(gx, gy) } } switch msg.Button { case tea.MouseButtonWheelUp: if m.offset.Y > 0 { m.offset.Y-- } case tea.MouseButtonWheelDown: maxY := m.curMap().Height - gridH if maxY < 0 { maxY = 0 } if m.offset.Y < maxY { m.offset.Y++ } } return m, nil } func (m *AppModel) mouseDraw(gx, gy int) (tea.Model, tea.Cmd) { p := Point{gx, gy} switch m.tool { case ToolBrush: m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(p) m.lastPaint = p case ToolErase: m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(p, true) m.lastPaint = p case ToolFill: m.dirty = true m.undo.Push(m.curMap()) m.fillAt(p) m.lastPaint = p case ToolText: idx := FindTextLabelAt(m.curMap(), p) if idx >= 0 { m.movingLabel = idx tl := m.curMap().TextLabels[idx] m.dragLabelOrigin = tl.Start m.dragMouseOrigin = p m.lastPaint = p } } m.cursor = p m.clampCursor() return m, nil } func (m *AppModel) mouseRelease(gx, gy int) { p := Point{gx, gy} switch m.tool { case ToolLine: m.dirty = true m.undo.Push(m.curMap()) m.finishShape(m.mouseStart, p, ToolLine) m.linePreview = nil m.mode = ModeNormal case ToolRect: m.dirty = true m.undo.Push(m.curMap()) m.finishShape(m.mouseStart, p, ToolRect) m.rectPreview = nil m.mode = ModeNormal case ToolCircle: m.dirty = true m.undo.Push(m.curMap()) m.finishShape(m.mouseStart, p, ToolCircle) m.circlePreview = nil m.mode = ModeNormal case ToolText: if m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) { old := m.curMap().TextLabels[m.movingLabel].Start newPos := m.labelDragPos() if old != newPos && m.curMap().InBounds(newPos) { m.dirty = true m.undo.Push(m.curMap()) MoveTextLabel(m.curMap(), old, newPos) } else { m.startTextEditText(m.movingLabel) } } else if m.cursor == p && m.curMap().InBounds(p) { m.startTextEdit(p) } m.movingLabel = -1 m.dragLabelOrigin = Point{X: 0, Y: 0} m.dragMouseOrigin = Point{X: 0, Y: 0} } } func (m *AppModel) finishShape(a, b Point, tool Tool) { palette := m.curPalette() terrain := m.selected if terrain < 0 || terrain >= len(palette) { return } var pts []Point switch tool { case ToolLine: pts = BresenhamLine(a, b) case ToolRect: pts = DrawRect(a, b, m.fillShapes) case ToolCircle: pts = DrawCircle(a, b, m.fillShapes) } ApplyPoints(m.curMap(), pts, terrain, palette) // Apply brush width thickening if > 1 if m.brushWidth > 1 { for _, pt := range pts { Brush(m.curMap(), pt, terrain, m.brushWidth, palette) } } } // --- Keyboard handling --- func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Clear save message on next key m.dialogMsg = "" if m.mode == ModeDialog { return m.handleDialogKey(msg) } if m.colorPicker != nil && m.colorPicker.Active { return m.handleColorPickerKey(msg) } if m.mode == ModeTextEdit { return m.handleTextEditKey(msg) } key := msg.String() cfg := m.cfg.Keybindings switch key { case cfg.Quit: if m.curMap().Parent != nil { m.drillUp() } else if m.dirty { m.mode = ModeDialog m.dialog = DialogQuitConfirm } else { m.quitting = true return m, tea.Quit } return m, nil case cfg.Save: m.saveMap() return m, nil case cfg.Undo: if entry := m.undo.Undo(); entry != nil { *entry.target = *entry.state m.dirty = m.undo.pos != m.undoPosAtSave } return m, nil case cfg.Redo: if entry := m.undo.Redo(); entry != nil { *entry.target = *entry.state m.dirty = m.undo.pos != m.undoPosAtSave } return m, nil case cfg.UnicodeToggle: m.unicode = !m.unicode return m, nil case cfg.ColorToggle: m.colorMode = !m.colorMode return m, nil case cfg.FillToggle, "f": m.fillShapes = !m.fillShapes return m, nil case cfg.Resize: m.mode = ModeDialog m.dialog = DialogResize m.ti.SetValue(fmt.Sprintf("%dx%d", m.curMap().Width, m.curMap().Height)) m.ti.Focus() return m, nil case "!": m.tool = ToolBrush m.drawHeld, m.eraseHeld = false, false case "@": m.tool = ToolSelect m.drawHeld, m.eraseHeld = false, false case "#": m.tool = ToolErase m.drawHeld, m.eraseHeld = false, false case "$": m.tool = ToolFill m.drawHeld, m.eraseHeld = false, false case "%": m.tool = ToolLine m.drawHeld, m.eraseHeld = false, false case "^": m.tool = ToolRect m.drawHeld, m.eraseHeld = false, false case "&": m.tool = ToolCircle m.drawHeld, m.eraseHeld = false, false case "*": m.tool = ToolText m.drawHeld, m.eraseHeld = false, false case "(": m.tool = ToolText m.drawHeld, m.eraseHeld = false, false case "ctrl+1": m.brushWidth = 1 case "ctrl+2": m.brushWidth = 3 case "ctrl+3": m.brushWidth = 5 case "[": if m.brushWidth > 1 { m.brushWidth -= 2 } case "]": if m.brushWidth < 5 { m.brushWidth += 2 } case "enter": if m.tool == ToolText { return m.handleSpace() } if m.movingLabel >= 0 { m.placeMovingLabel() return m, nil } if m.mode == ModeLinePreview { m.finalizeLinePreview() } else if m.mode == ModeRectPreview { m.finalizeRectPreview() } else if m.mode == ModeCirclePreview { m.finalizeCirclePreview() } else if "enter" == cfg.DrillDown { m.drillDown() } return m, nil case cfg.DrillDown: m.drillDown() return m, nil case cfg.DrillUp: m.drillUp() return m, nil case cfg.DeleteSubmap: if _, ok := m.curMap().Submaps[m.cursor]; ok { m.mode = ModeDialog m.dialog = DialogDeleteSubmapConfirm } return m, nil case " ", "space": return m.handleSpace() case "backspace", "x": return m.handleBackspace() case "e": if m.tool == ToolText { return m.editTextAtCursor() } case "c": if m.tool == ToolText { idx := FindTextLabelAt(m.curMap(), m.cursor) if idx >= 0 { m.openColorPicker(true) return m, nil } } case "esc": if m.movingLabel >= 0 { m.movingLabel = -1 m.dragLabelOrigin = Point{X: 0, Y: 0} m.dragMouseOrigin = Point{X: 0, Y: 0} return m, nil } m.cancelPreview() return m, nil case "up", "down", "left", "right", "h", "j", "k", "l": switch key { case "up", "k": m.moveCursor(0, -1) case "down", "j": m.moveCursor(0, 1) case "left", "h": m.moveCursor(-1, 0) case "right", "l": m.moveCursor(1, 0) } if m.movingLabel < 0 && m.drawHeld { m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(m.cursor) } else if m.movingLabel < 0 && m.eraseHeld { m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(m.cursor, true) } return m, nil default: if len(msg.Runes) == 1 { r := msg.Runes[0] switch r { case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': hk := (int(r-'0') + 9) % 10 // Find all symbols with this hotkey (index % 10 == hk) var indices []int for i := range m.curPalette() { if i%10 == hk { indices = append(indices, i) } } if len(indices) == 0 { return m, nil } // If current selection shares this hotkey, cycle curOff := m.hotkeySelect[hk] if m.selected%10 == hk && containsInt(indices, m.selected) { curOff = (curOff + 1) % len(indices) } else { curOff = 0 } m.hotkeySelect[hk] = curOff m.selected = indices[curOff] // Update page to show the selected symbol m.palettePage = m.selected / 10 case '=': m.palettePageNext() case '-': m.palettePagePrev() case '<', ',': m.moveSymbolUp() case '>', '.': m.moveSymbolDown() } } } return m, nil } func (m *AppModel) handleSpace() (tea.Model, tea.Cmd) { m.drawHeld = false m.eraseHeld = false // Finalize any active preview if m.mode == ModeLinePreview { m.finalizeLinePreview() return m, nil } if m.mode == ModeRectPreview { m.finalizeRectPreview() return m, nil } if m.mode == ModeCirclePreview { m.finalizeCirclePreview() return m, nil } if m.tool == ToolSelect { return m, nil } if m.tool == ToolLine && m.mode == ModeNormal { m.lineStart = m.cursor m.mode = ModeLinePreview m.linePreview = nil return m, nil } if m.tool == ToolRect && m.mode == ModeNormal { m.rectStart = m.cursor m.mode = ModeRectPreview m.rectPreview = nil return m, nil } if m.tool == ToolCircle && m.mode == ModeNormal { m.circleCenter = m.cursor m.mode = ModeCirclePreview m.circlePreview = nil return m, nil } if m.tool == ToolText { if m.movingLabel >= 0 { if m.movingLabel < len(m.curMap().TextLabels) { old := m.curMap().TextLabels[m.movingLabel].Start newPos := m.labelDragPos() if old != newPos && m.curMap().InBounds(newPos) { m.dirty = true m.undo.Push(m.curMap()) MoveTextLabel(m.curMap(), old, newPos) } } m.movingLabel = -1 return m, nil } idx := FindTextLabelAt(m.curMap(), m.cursor) if idx >= 0 { m.movingLabel = idx tl := m.curMap().TextLabels[idx] m.dragLabelOrigin = tl.Start m.dragMouseOrigin = m.cursor } else { m.startTextEdit(m.cursor) } return m, nil } if m.tool == ToolErase { m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(m.cursor, true) return m, nil } if m.tool == ToolFill { m.dirty = true m.undo.Push(m.curMap()) m.fillAt(m.cursor) return m, nil } // Brush: draw once m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(m.cursor) return m, nil } func (m *AppModel) handleBackspace() (tea.Model, tea.Cmd) { m.drawHeld = false m.eraseHeld = false if m.mode == ModeLinePreview || m.mode == ModeRectPreview || m.mode == ModeCirclePreview { m.cancelPreview() return m, nil } if m.tool == ToolText { idx := FindTextLabelAt(m.curMap(), m.cursor) if idx >= 0 { m.dirty = true m.undo.Push(m.curMap()) RemoveTextLabel(m.curMap(), m.curMap().TextLabels[idx].Start) } return m, nil } // Delete current cell m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(m.cursor, true) return m, nil } func (m *AppModel) finalizeLinePreview() { m.dirty = true m.undo.Push(m.curMap()) ApplyPoints(m.curMap(), m.linePreview, m.selected, m.curPalette()) m.linePreview = nil m.mode = ModeNormal } func (m *AppModel) finalizeRectPreview() { m.dirty = true m.undo.Push(m.curMap()) ApplyPoints(m.curMap(), m.rectPreview, m.selected, m.curPalette()) m.rectPreview = nil m.mode = ModeNormal } func (m *AppModel) finalizeCirclePreview() { m.dirty = true m.undo.Push(m.curMap()) ApplyPoints(m.curMap(), m.circlePreview, m.selected, m.curPalette()) m.circlePreview = nil m.mode = ModeNormal } func (m *AppModel) cancelPreview() { m.linePreview = nil m.rectPreview = nil m.circlePreview = nil m.mode = ModeNormal } func (m *AppModel) moveCursor(dx, dy int) { m.cursor.X = clamp(m.cursor.X+dx, 0, m.curMap().Width-1) m.cursor.Y = clamp(m.cursor.Y+dy, 0, m.curMap().Height-1) if m.mode == ModeLinePreview { m.linePreview = ThickenPoints(BresenhamLine(m.lineStart, m.cursor), m.brushWidth) } else if m.mode == ModeRectPreview { m.rectPreview = ThickenPoints(DrawRect(m.rectStart, m.cursor, m.fillShapes), m.brushWidth) } else if m.mode == ModeCirclePreview { m.circlePreview = ThickenPoints(DrawCircle(m.circleCenter, m.cursor, m.fillShapes), m.brushWidth) } } func (m *AppModel) applyBrush(center Point, erase ...bool) { if !m.curMap().InBounds(center) { return } terrain := m.selected if len(erase) > 0 && erase[0] { terrain = -1 } palette := m.curPalette() color := "" if terrain >= 0 && terrain < len(palette) { color = palette[terrain].PickColor() } switch m.tool { case ToolBrush: Brush(m.curMap(), center, terrain, m.brushWidth, palette) case ToolErase: Brush(m.curMap(), center, terrain, m.brushWidth, palette) default: if terrain >= 0 && terrain < len(palette) { m.curMap().SetCell(center, terrain, color) } else { m.curMap().SetCell(center, terrain) } } } func (m *AppModel) fillAt(p Point) { if m.selected >= 0 && m.selected < len(m.curPalette()) { FloodFill(m.curMap(), p, m.selected, m.curPalette()) } } func (m *AppModel) clampCursor() { m.cursor.X = clamp(m.cursor.X, 0, m.curMap().Width-1) m.cursor.Y = clamp(m.cursor.Y, 0, m.curMap().Height-1) } // --- Text tool --- func (m *AppModel) startTextEdit(p Point) { m.mode = ModeTextEdit m.textInput.SetValue("") m.textInput.Focus() m.cursor = p m.textCursorStart = p m.movingLabel = -1 } func (m *AppModel) startTextEditText(idx int) { if idx < 0 || idx >= len(m.curMap().TextLabels) { return } m.mode = ModeTextEdit m.textInput.SetValue(m.curMap().TextLabels[idx].Text) m.textInput.Focus() m.textCursorStart = m.curMap().TextLabels[idx].Start m.movingLabel = idx m.textEditing = true } func (m *AppModel) editTextAtCursor() (tea.Model, tea.Cmd) { idx := FindTextLabelAt(m.curMap(), m.cursor) if idx < 0 { return m, nil } m.startTextEditText(idx) return m, nil } func (m *AppModel) placeMovingLabel() { if m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) { old := m.curMap().TextLabels[m.movingLabel].Start newPos := m.labelDragPos() if old != newPos && m.curMap().InBounds(newPos) { m.dirty = true m.undo.Push(m.curMap()) MoveTextLabel(m.curMap(), old, newPos) } } m.movingLabel = -1 m.dragLabelOrigin = Point{X: 0, Y: 0} m.dragMouseOrigin = Point{X: 0, Y: 0} } func (m *AppModel) labelDragPos() Point { return Point{ m.dragLabelOrigin.X + (m.cursor.X - m.dragMouseOrigin.X), m.dragLabelOrigin.Y + (m.cursor.Y - m.dragMouseOrigin.Y), } } func (m *AppModel) handleTextEditKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() toolKeys := map[string]Tool{ "!": ToolBrush, "@": ToolSelect, "#": ToolErase, "$": ToolFill, "%": ToolLine, "^": ToolRect, "&": ToolCircle, "*": ToolText, "(": ToolText, } if t, ok := toolKeys[key]; ok { m.commitTextEdit() m.tool = t m.drawHeld, m.eraseHeld = false, false return m, nil } switch key { case "esc": m.mode = ModeNormal m.movingLabel = -1 m.textEditing = false m.textInput.Blur() return m, nil case "backspace": val := m.textInput.Value() runes := []rune(val) if len(runes) > 0 { m.textInput.SetValue(string(runes[:len(runes)-1])) } return m, nil case "enter": m.commitTextEdit() return m, nil } var cmd tea.Cmd m.textInput, cmd = m.textInput.Update(msg) return m, cmd } func (m *AppModel) commitTextEdit() { text := m.textInput.Value() if text != "" { m.dirty = true m.undo.Push(m.curMap()) if m.textEditing && m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) { RemoveTextLabel(m.curMap(), m.curMap().TextLabels[m.movingLabel].Start) m.textEditing = false } PlaceTextLabel(m.curMap(), m.cursor, text, m.textColor) } m.mode = ModeNormal m.movingLabel = -1 m.textEditing = false m.textInput.Blur() } // --- Dialogs --- func (m *AppModel) handleDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() isFilePicker := m.dialog == DialogFileSave || m.dialog == DialogFileOpen || m.dialog == DialogSaveAs || m.dialog == DialogOpenMap if isFilePicker && m.filePicker != nil { switch key { case "esc": m.mode = ModeNormal m.dialog = DialogNone m.filePicker = nil return m, nil case "up", "k": if m.filePicker.Selected > 0 { m.filePicker.Selected-- } return m, nil case "down", "j": if m.filePicker.Selected < len(m.filePicker.Files)-1 { m.filePicker.Selected++ } return m, nil case "pgup": m.filePicker.Selected -= 10 if m.filePicker.Selected < 0 { m.filePicker.Selected = 0 } return m, nil case "pgdown": m.filePicker.Selected += 10 if m.filePicker.Selected >= len(m.filePicker.Files) { m.filePicker.Selected = len(m.filePicker.Files) - 1 } return m, nil case "left", "h": parent := filepath.Dir(m.filePicker.CurDir) m.filePicker.CurDir = parent m.filePicker.Selected = 0 m.refreshFilePicker() return m, nil case "enter": tiVal := strings.TrimSpace(m.ti.Value()) if tiVal != "" { m.filePicker = nil return m.doFileAction(tiVal) } if m.filePicker.Selected >= 0 && m.filePicker.Selected < len(m.filePicker.Files) { entry := m.filePicker.Files[m.filePicker.Selected] if entry.IsDir() { m.filePicker.CurDir = filepath.Join(m.filePicker.CurDir, entry.Name()) m.filePicker.Selected = 0 m.refreshFilePicker() return m, nil } fullPath := filepath.Join(m.filePicker.CurDir, entry.Name()) m.ti.SetValue(fullPath) m.filePicker = nil return m.doFileAction(fullPath) } return m, nil } var cmd tea.Cmd m.ti, cmd = m.ti.Update(msg) return m, cmd } switch key { case "esc": if m.dialog == DialogQuitConfirm { m.quitting = true return m, tea.Quit } m.mode = ModeNormal m.dialog = DialogNone return m, nil case "q": if m.dialog == DialogQuitConfirm { m.quitting = true return m, tea.Quit } fallthrough default: var cmd tea.Cmd m.ti, cmd = m.ti.Update(msg) return m, cmd case "enter": switch m.dialog { case DialogSaveAs: name := m.ti.Value() if name != "" { m.rootMap.Filename = name if err := SerializeMap(m.rootMap, name); err != nil { m.dialogMsg = err.Error() } else { m.dialogMsg = fmt.Sprintf("Saved %s", name) m.dirty = false m.undoPosAtSave = m.undo.pos } } case DialogResize: var w, h int val := m.ti.Value() if n, _ := fmt.Sscanf(val, "%dx%d", &w, &h); n == 2 && w > 0 && h > 0 && w < 1000 && h < 1000 { m.resizeMap(w, h) } case DialogQuitConfirm: m.quitting = true return m, tea.Quit case DialogDeleteSubmapConfirm: m.dirty = true m.undo.Push(m.curMap()) delete(m.curMap().Submaps, m.cursor) case DialogRenameSymbol: name := m.ti.Value() if name != "" && m.selected < len(m.curPalette()) { m.curPalette()[m.selected].Name = name } case DialogRenameMap: name := m.ti.Value() if name != "" { m.curMap().Name = name } case DialogOpenMap: name := m.ti.Value() if name != "" { return m, m.loadMapCmd(name) } } m.mode = ModeNormal m.dialog = DialogNone return m, nil } } func (m *AppModel) doFileAction(path string) (tea.Model, tea.Cmd) { switch m.dialog { case DialogFileSave, DialogSaveAs: m.rootMap.Filename = path if err := SerializeMap(m.rootMap, path); err != nil { m.dialogMsg = err.Error() } else { m.dialogMsg = fmt.Sprintf("Saved %s", path) m.dirty = false m.undoPosAtSave = m.undo.pos } case DialogFileOpen, DialogOpenMap: m.mode = ModeNormal m.dialog = DialogNone return m, m.loadMapCmd(path) } m.mode = ModeNormal m.dialog = DialogNone return m, nil } func (m *AppModel) handleFilePickerMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { _, y := msg.X, msg.Y if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress { return m, nil } entryY := y - m.filePicker.PopupY if entryY >= 0 && entryY < len(m.filePicker.Files) { idx := entryY + m.filePicker.ListTop if idx >= 0 && idx < len(m.filePicker.Files) { m.filePicker.Selected = idx entry := m.filePicker.Files[idx] if entry.IsDir() { m.filePicker.CurDir = filepath.Join(m.filePicker.CurDir, entry.Name()) m.filePicker.Selected = 0 m.refreshFilePicker() } else { fullPath := filepath.Join(m.filePicker.CurDir, entry.Name()) m.ti.SetValue(fullPath) m.filePicker = nil return m.doFileAction(fullPath) } } } return m, nil } func (m *AppModel) saveMap() { if m.rootMap.Filename == "" { m.mode = ModeDialog m.dialog = DialogFileSave m.openFilePicker() m.ti.Focus() return } if err := SerializeMap(m.rootMap, m.rootMap.Filename); err != nil { m.dialogMsg = fmt.Sprintf("Save error: %v", err) } else { m.dialogMsg = fmt.Sprintf("Saved %s", m.rootMap.Filename) m.dirty = false m.undoPosAtSave = m.undo.pos } } func (m *AppModel) openFilePicker() { cur := "." m.filePicker = &FilePickerState{CurDir: cur, Selected: 0} m.refreshFilePicker() m.ti.SetValue("") } func (m *AppModel) refreshFilePicker() { entries, err := os.ReadDir(m.filePicker.CurDir) if err != nil { m.filePicker.Files = nil return } sort.Slice(entries, func(i, j int) bool { if entries[i].IsDir() != entries[j].IsDir() { return entries[i].IsDir() } return entries[i].Name() < entries[j].Name() }) m.filePicker.Files = entries if m.filePicker.Selected >= len(entries) { m.filePicker.Selected = len(entries) - 1 } if m.filePicker.Selected < 0 { m.filePicker.Selected = 0 } } func (m *AppModel) doFilePickerSelect() { if m.filePicker == nil || len(m.filePicker.Files) == 0 { return } entry := m.filePicker.Files[m.filePicker.Selected] if entry.IsDir() { m.filePicker.CurDir = filepath.Join(m.filePicker.CurDir, entry.Name()) m.filePicker.Selected = 0 m.refreshFilePicker() return } m.ti.SetValue(filepath.Join(m.filePicker.CurDir, entry.Name())) } // --- Color picker --- func (m *AppModel) openColorPicker(forText bool) { m.colorPicker = &ColorPickerState{ Active: true, Cursor: Point{X: -1, Y: -1}, Selected: nil, ForText: forText, } if !forText && m.selected < len(m.curPalette()) { for _, c := range m.curPalette()[m.selected].Colors { m.colorPicker.Selected = append(m.colorPicker.Selected, c.Color) } } } func (m *AppModel) handleColorPickerMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { x, y := msg.X, msg.Y col := (x - m.colorPicker.GridX) / 2 row := y - m.colorPicker.GridY if msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress { if row >= 0 && row < 16 && col >= 0 && col < 16 { idx := row*16 + col cstr := fmt.Sprintf("%d", idx) found := false for i, c := range m.colorPicker.Selected { if c == cstr { m.colorPicker.Selected = append(m.colorPicker.Selected[:i], m.colorPicker.Selected[i+1:]...) found = true break } } if !found { m.colorPicker.Selected = append(m.colorPicker.Selected, cstr) } } return m, nil } if msg.Action == tea.MouseActionRelease { m.colorPicker.Cursor = Point{X: -1, Y: -1} return m, nil } if row >= 0 && row < 16 && col >= 0 && col < 16 { m.colorPicker.Cursor = Point{X: col, Y: row} } return m, nil } func (m *AppModel) handleColorPickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if m.colorPicker.Cursor.X < 0 { m.colorPicker.Cursor = Point{X: 0, Y: 0} } switch key { case "esc": m.colorPicker = nil return m, nil case "enter": if m.colorPicker.ForText { idx := FindTextLabelAt(m.curMap(), m.cursor) if idx >= 0 && len(m.colorPicker.Selected) > 0 { m.dirty = true m.undo.Push(m.curMap()) m.curMap().TextLabels[idx].Color = m.colorPicker.Selected[0] } } else { if m.selected < len(m.curPalette()) { var colors []TerrainColor for _, c := range m.colorPicker.Selected { colors = append(colors, TerrainColor{Color: c, Weight: 100}) } if len(colors) > 0 { m.curPalette()[m.selected].Colors = colors } } } m.colorPicker = nil return m, nil case "space": idx := m.colorPicker.Cursor.Y*16 + m.colorPicker.Cursor.X cstr := fmt.Sprintf("%d", idx) found := false for i, c := range m.colorPicker.Selected { if c == cstr { m.colorPicker.Selected = append(m.colorPicker.Selected[:i], m.colorPicker.Selected[i+1:]...) found = true break } } if !found { m.colorPicker.Selected = append(m.colorPicker.Selected, cstr) } case "up", "k": m.colorPicker.Cursor.Y = (m.colorPicker.Cursor.Y - 1 + 16) % 16 case "down", "j": m.colorPicker.Cursor.Y = (m.colorPicker.Cursor.Y + 1) % 16 case "left", "h": m.colorPicker.Cursor.X = (m.colorPicker.Cursor.X - 1 + 16) % 16 case "right", "l": m.colorPicker.Cursor.X = (m.colorPicker.Cursor.X + 1) % 16 case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9": // Direct color numeral input — not used } return m, nil } // --- Submaps --- func (m *AppModel) drillDown() { m.cancelPreview() sub, ok := m.curMap().Submaps[m.cursor] if !ok { sub = NewMap(fmt.Sprintf("%s/sub", m.curMap().Name), m.cfg.DefaultMapWidth, m.cfg.DefaultMapHeight, m.curMap().Palette) sub.Parent = m.curMap() sub.Filename = m.rootMap.Filename m.curMap().Submaps[m.cursor] = sub } m.prevCursor = m.cursor m.map_ = sub m.cursor = Point{X: 0, Y: 0} m.offset = Point{X: 0, Y: 0} } func (m *AppModel) drillUp() { if m.curMap().Parent == nil { return } if m.isMapBlank(m.curMap()) { delete(m.curMap().Parent.Submaps, m.prevCursor) } parent := m.curMap().Parent m.map_ = parent if m.prevCursor.X >= 0 { m.cursor = m.prevCursor } else { m.cursor = Point{X: -1, Y: -1} } m.offset = Point{X: 0, Y: 0} m.prevCursor = Point{X: -1, Y: -1} } func (m *AppModel) isMapBlank(mm *Map) bool { for y := range mm.Grid { for x := range mm.Grid[y] { if mm.Grid[y][x].Terrain >= 0 || mm.Grid[y][x].Text != "" { return false } } } return len(mm.Submaps) == 0 } // --- Toolkit --- func (m *AppModel) handleToolbarClick(x int) { widths := []int{16, 10, 21, 5, 5, 5, 6, 6, 6} pos := 0 for i, w := range widths { if x >= pos && x < pos+w { switch i { case 0: // Name m.mode = ModeDialog m.dialog = DialogRenameMap m.ti.SetValue(m.curMap().Name) m.ti.Focus() case 1: // Size m.mode = ModeDialog m.dialog = DialogResize m.ti.SetValue(fmt.Sprintf("%dx%d", m.curMap().Width, m.curMap().Height)) m.ti.Focus() case 2: // File m.mode = ModeDialog m.dialog = DialogFileSave m.openFilePicker() m.ti.SetValue(m.rootMap.Filename) m.ti.Focus() case 3: // Uni m.unicode = !m.unicode case 4: // Col m.colorMode = !m.colorMode case 5: // Fil m.fillShapes = !m.fillShapes case 6: // Save m.saveMap() case 7: // Load m.mode = ModeDialog m.dialog = DialogFileOpen m.openFilePicker() m.ti.Focus() case 8: // Quit if m.dirty { m.mode = ModeDialog m.dialog = DialogQuitConfirm } else { m.quitting = true } } return } pos += w } } func (m *AppModel) handleSidebarClick(x, y int) { relY := y - 1 // Palette symbols: rows 1-10 if relY >= 1 && relY <= 10 { idx := m.palettePage*10 + (relY - 1) if idx < len(m.curPalette()) { m.selected = idx } return } // Page buttons: row 11 if relY == 11 { if x < m.width-sidebarW+7 { m.palettePagePrev() } else { m.palettePageNext() } return } // Palette management: rows 12-14 if relY == 12 { if x < m.width-sidebarW+8 { m.addSymbol() } else { m.removeSymbol() } return } if relY == 13 { if x < m.width-sidebarW+8 { m.moveSymbolUp() } else { m.moveSymbolDown() } return } if relY == 14 { if x < m.width-sidebarW+10 { m.mode = ModeDialog m.dialog = DialogRenameSymbol m.ti.SetValue(m.curPalette()[clamp(m.selected, 0, len(m.curPalette())-1)].Name) m.ti.Focus() } else { m.openColorPicker(false) } return } // Tools: rows 16+, then 3 brush width rows toolRow := relY - 16 if toolRow >= 0 && toolRow < 8 { m.tool = Tool(toolRow) } bwRow := relY - 25 if bwRow >= 0 && bwRow < 3 { m.brushWidth = []int{1, 3, 5}[bwRow] } } func (m *AppModel) addSymbol() { p := m.curPalette() if len(p) >= 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 { 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) } }