package tui import ( "fmt" "tui-ascii-mapper/internal/model" "tui-ascii-mapper/internal/tools" tea "github.com/charmbracelet/bubbletea" ) func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.dialogMsg = "" if m.mode == model.ModeDialog { return m.handleDialogKey(msg) } if m.colorPicker != nil && m.colorPicker.Active { return m.handleColorPickerKey(msg) } if m.mode == model.ModeTextEdit { return m.handleTextEditKey(msg) } key := msg.String() cfg := m.cfg.Keybindings switch key { case cfg.Quit: if m.curMap().Parent != nil { m.drillUp() } else if m.dirty { m.mode = model.ModeDialog m.dialog = model.DialogQuitConfirm } else { m.quitting = true return m, tea.Quit } return m, nil case cfg.Save: m.saveMap() return m, nil case cfg.Undo: if entry := m.undo.Undo(); entry != nil { *entry.Target = *entry.State m.dirty = m.undo.Pos() != m.undoPosAtSave } return m, nil case cfg.Redo: if entry := m.undo.Redo(); entry != nil { *entry.Target = *entry.State m.dirty = m.undo.Pos() != m.undoPosAtSave } return m, nil case cfg.UnicodeToggle: m.unicode = !m.unicode return m, nil case cfg.ColorToggle: m.colorMode = !m.colorMode return m, nil case cfg.FillToggle, "f": m.fillShapes = !m.fillShapes return m, nil case cfg.Resize: m.mode = model.ModeDialog m.dialog = model.DialogResize m.ti.SetValue(fmt.Sprintf("%dx%d", m.curMap().Width, m.curMap().Height)) m.ti.Focus() return m, nil case "!": m.tool = model.ToolBrush m.drawHeld, m.eraseHeld = false, false case "@": m.tool = model.ToolSelect m.drawHeld, m.eraseHeld = false, false case "#": m.tool = model.ToolErase m.drawHeld, m.eraseHeld = false, false case "$": m.tool = model.ToolFill m.drawHeld, m.eraseHeld = false, false case "%": m.tool = model.ToolLine m.drawHeld, m.eraseHeld = false, false case "^": m.tool = model.ToolRect m.drawHeld, m.eraseHeld = false, false case "&": m.tool = model.ToolCircle m.drawHeld, m.eraseHeld = false, false case "*": m.tool = model.ToolText m.drawHeld, m.eraseHeld = false, false case "(": m.tool = model.ToolText m.drawHeld, m.eraseHeld = false, false case "ctrl+1": m.brushWidth = 1 case "ctrl+2": m.brushWidth = 3 case "ctrl+3": m.brushWidth = 5 case "[": if m.brushWidth > 1 { m.brushWidth -= 2 } case "]": if m.brushWidth < 5 { m.brushWidth += 2 } case "enter": if m.tool == model.ToolText { return m.handleSpace() } if m.movingLabel >= 0 { m.placeMovingLabel() return m, nil } if m.mode == model.ModeLinePreview { m.finalizeLinePreview() } else if m.mode == model.ModeRectPreview { m.finalizeRectPreview() } else if m.mode == model.ModeCirclePreview { m.finalizeCirclePreview() } else if "enter" == cfg.DrillDown { m.drillDown() } return m, nil case cfg.DrillDown: m.drillDown() return m, nil case cfg.DrillUp: m.drillUp() return m, nil case cfg.DeleteSubmap: if _, ok := m.curMap().Submaps[m.cursor]; ok { m.mode = model.ModeDialog m.dialog = model.DialogDeleteSubmapConfirm } return m, nil case " ", "space": return m.handleSpace() case "backspace", "x": return m.handleBackspace() case "e": if m.tool == model.ToolText { return m.editTextAtCursor() } case "c": if m.tool == model.ToolText { idx := tools.FindTextLabelAt(m.curMap(), m.cursor) if idx >= 0 { m.openColorPicker(true) return m, nil } } case "esc": if m.movingLabel >= 0 { m.movingLabel = -1 m.dragLabelOrigin = model.Point{X: 0, Y: 0} m.dragMouseOrigin = model.Point{X: 0, Y: 0} return m, nil } m.cancelPreview() return m, nil case "up", "down", "left", "right", "h", "j", "k", "l": switch key { case "up", "k": m.moveCursor(0, -1) case "down", "j": m.moveCursor(0, 1) case "left", "h": m.moveCursor(-1, 0) case "right", "l": m.moveCursor(1, 0) } if m.movingLabel < 0 && m.drawHeld { m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(m.cursor) } else if m.movingLabel < 0 && m.eraseHeld { m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(m.cursor, true) } return m, nil default: if len(msg.Runes) == 1 { r := msg.Runes[0] switch r { case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': hk := (int(r-'0') + 9) % 10 var indices []int for i := range m.curPalette() { if i%10 == hk { indices = append(indices, i) } } if len(indices) == 0 { return m, nil } curOff := m.hotkeySelect[hk] if m.selected%10 == hk && model.ContainsInt(indices, m.selected) { curOff = (curOff + 1) % len(indices) } else { curOff = 0 } m.hotkeySelect[hk] = curOff m.selected = indices[curOff] m.palettePage = m.selected / 10 case '=': m.palettePageNext() case '-': m.palettePagePrev() case '<', ',': m.moveSymbolUp() case '>', '.': m.moveSymbolDown() } } } return m, nil } func (m *AppModel) handleSpace() (tea.Model, tea.Cmd) { m.drawHeld = false m.eraseHeld = false if m.mode == model.ModeLinePreview { m.finalizeLinePreview() return m, nil } if m.mode == model.ModeRectPreview { m.finalizeRectPreview() return m, nil } if m.mode == model.ModeCirclePreview { m.finalizeCirclePreview() return m, nil } if m.tool == model.ToolSelect { return m, nil } if m.tool == model.ToolLine && m.mode == model.ModeNormal { m.lineStart = m.cursor m.mode = model.ModeLinePreview m.linePreview = nil return m, nil } if m.tool == model.ToolRect && m.mode == model.ModeNormal { m.rectStart = m.cursor m.mode = model.ModeRectPreview m.rectPreview = nil return m, nil } if m.tool == model.ToolCircle && m.mode == model.ModeNormal { m.circleCenter = m.cursor m.mode = model.ModeCirclePreview m.circlePreview = nil return m, nil } if m.tool == model.ToolText { if m.movingLabel >= 0 { if m.movingLabel < len(m.curMap().TextLabels) { old := m.curMap().TextLabels[m.movingLabel].Start newPos := m.labelDragPos() if old != newPos && m.curMap().InBounds(newPos) { m.dirty = true m.undo.Push(m.curMap()) tools.MoveTextLabel(m.curMap(), old, newPos) } } m.movingLabel = -1 return m, nil } idx := tools.FindTextLabelAt(m.curMap(), m.cursor) if idx >= 0 { m.movingLabel = idx tl := m.curMap().TextLabels[idx] m.dragLabelOrigin = tl.Start m.dragMouseOrigin = m.cursor } else { m.startTextEdit(m.cursor) } return m, nil } if m.tool == model.ToolErase { m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(m.cursor, true) return m, nil } if m.tool == model.ToolFill { m.dirty = true m.undo.Push(m.curMap()) m.fillAt(m.cursor) return m, nil } m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(m.cursor) return m, nil } func (m *AppModel) handleBackspace() (tea.Model, tea.Cmd) { m.drawHeld = false m.eraseHeld = false if m.mode == model.ModeLinePreview || m.mode == model.ModeRectPreview || m.mode == model.ModeCirclePreview { m.cancelPreview() return m, nil } if m.tool == model.ToolText { idx := tools.FindTextLabelAt(m.curMap(), m.cursor) if idx >= 0 { m.dirty = true m.undo.Push(m.curMap()) tools.RemoveTextLabel(m.curMap(), m.curMap().TextLabels[idx].Start) } return m, nil } m.dirty = true m.undo.Push(m.curMap()) m.applyBrush(m.cursor, true) return m, nil } func (m *AppModel) finalizeLinePreview() { m.dirty = true m.undo.Push(m.curMap()) tools.ApplyPoints(m.curMap(), m.linePreview, m.selected, m.curPalette()) m.linePreview = nil m.mode = model.ModeNormal } func (m *AppModel) finalizeRectPreview() { m.dirty = true m.undo.Push(m.curMap()) tools.ApplyPoints(m.curMap(), m.rectPreview, m.selected, m.curPalette()) m.rectPreview = nil m.mode = model.ModeNormal } func (m *AppModel) finalizeCirclePreview() { m.dirty = true m.undo.Push(m.curMap()) tools.ApplyPoints(m.curMap(), m.circlePreview, m.selected, m.curPalette()) m.circlePreview = nil m.mode = model.ModeNormal } func (m *AppModel) cancelPreview() { m.linePreview = nil m.rectPreview = nil m.circlePreview = nil m.mode = model.ModeNormal } func (m *AppModel) moveCursor(dx, dy int) { m.cursor.X = model.Clamp(m.cursor.X+dx, 0, m.curMap().Width-1) m.cursor.Y = model.Clamp(m.cursor.Y+dy, 0, m.curMap().Height-1) if m.mode == model.ModeLinePreview { m.linePreview = tools.ThickenPoints(tools.BresenhamLine(m.lineStart, m.cursor), m.brushWidth) } else if m.mode == model.ModeRectPreview { m.rectPreview = tools.ThickenPoints(tools.DrawRect(m.rectStart, m.cursor, m.fillShapes), m.brushWidth) } else if m.mode == model.ModeCirclePreview { m.circlePreview = tools.ThickenPoints(tools.DrawCircle(m.circleCenter, m.cursor, m.fillShapes), m.brushWidth) } } func (m *AppModel) applyBrush(center model.Point, erase ...bool) { if !m.curMap().InBounds(center) { return } terrain := m.selected if len(erase) > 0 && erase[0] { terrain = -1 } palette := m.curPalette() switch m.tool { case model.ToolBrush: tools.Brush(m.curMap(), center, terrain, m.brushWidth, palette) case model.ToolErase: tools.Brush(m.curMap(), center, terrain, m.brushWidth, palette) default: color := "" if terrain >= 0 && terrain < len(palette) { color = palette[terrain].PickColor() } m.curMap().SetCell(center, terrain, color) } } func (m *AppModel) fillAt(p model.Point) { if m.selected >= 0 && m.selected < len(m.curPalette()) { tools.FloodFill(m.curMap(), p, m.selected, m.curPalette()) } } func (m *AppModel) clampCursor() { m.cursor.X = model.Clamp(m.cursor.X, 0, m.curMap().Width-1) m.cursor.Y = model.Clamp(m.cursor.Y, 0, m.curMap().Height-1) } func (m *AppModel) startTextEdit(p model.Point) { m.mode = model.ModeTextEdit m.textInput.SetValue("") m.textInput.Focus() m.cursor = p m.textCursorStart = p m.movingLabel = -1 } func (m *AppModel) startTextEditText(idx int) { if idx < 0 || idx >= len(m.curMap().TextLabels) { return } m.mode = model.ModeTextEdit m.textInput.SetValue(m.curMap().TextLabels[idx].Text) m.textInput.Focus() m.textCursorStart = m.curMap().TextLabels[idx].Start m.movingLabel = idx m.textEditing = true } func (m *AppModel) editTextAtCursor() (tea.Model, tea.Cmd) { idx := tools.FindTextLabelAt(m.curMap(), m.cursor) if idx < 0 { return m, nil } m.startTextEditText(idx) return m, nil } func (m *AppModel) placeMovingLabel() { if m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) { old := m.curMap().TextLabels[m.movingLabel].Start newPos := m.labelDragPos() if old != newPos && m.curMap().InBounds(newPos) { m.dirty = true m.undo.Push(m.curMap()) tools.MoveTextLabel(m.curMap(), old, newPos) } } m.movingLabel = -1 m.dragLabelOrigin = model.Point{X: 0, Y: 0} m.dragMouseOrigin = model.Point{X: 0, Y: 0} } func (m *AppModel) labelDragPos() model.Point { return model.Point{ X: m.dragLabelOrigin.X + (m.cursor.X - m.dragMouseOrigin.X), Y: m.dragLabelOrigin.Y + (m.cursor.Y - m.dragMouseOrigin.Y), } } func (m *AppModel) handleTextEditKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() switch key { case "esc": m.mode = model.ModeNormal m.movingLabel = -1 m.textEditing = false m.textInput.Blur() return m, nil case "backspace": val := m.textInput.Value() runes := []rune(val) if len(runes) > 0 { m.textInput.SetValue(string(runes[:len(runes)-1])) } return m, nil case "enter": m.commitTextEdit() return m, nil } var cmd tea.Cmd m.textInput, cmd = m.textInput.Update(msg) return m, cmd } func (m *AppModel) commitTextEdit() { text := m.textInput.Value() if text != "" { m.dirty = true m.undo.Push(m.curMap()) start := m.cursor if m.textEditing && m.movingLabel >= 0 && m.movingLabel < len(m.curMap().TextLabels) { tools.RemoveTextLabel(m.curMap(), m.curMap().TextLabels[m.movingLabel].Start) start = m.textCursorStart m.textEditing = false } tools.PlaceTextLabel(m.curMap(), start, text, m.textColor) } m.mode = model.ModeNormal m.movingLabel = -1 m.textEditing = false m.textInput.Blur() }