diff options
| author | historia <[not public]> | 2026-06-29 04:03:38 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-29 04:03:38 -0400 |
| commit | ba34490aaed5f7c242c2b0453130fefc07d0d5d0 (patch) | |
| tree | db5818757d5080221494cead1c7e61a576d1cb65 /app.go | |
| parent | b1e252c996a6332d391f96624a7d6f149eb097c4 (diff) | |
| download | tui-ascii-mapper-ba34490aaed5f7c242c2b0453130fefc07d0d5d0.tar.gz | |
Diffstat (limited to 'app.go')
| -rw-r--r-- | app.go | 2249 |
1 files changed, 0 insertions, 2249 deletions
@@ -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) - } -} |
