package tui import ( "fmt" "os" "path/filepath" "sort" "strings" "tui-ascii-mapper/internal/mapio" "tui-ascii-mapper/internal/model" tea "github.com/charmbracelet/bubbletea" ) func (m *AppModel) handleDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() isFilePicker := m.dialog == model.DialogFileSave || m.dialog == model.DialogFileOpen || m.dialog == model.DialogSaveAs || m.dialog == model.DialogOpenMap if isFilePicker && m.filePicker != nil { switch key { case "esc": m.mode = model.ModeNormal m.dialog = model.DialogNone m.filePicker = nil return m, nil case "up": if m.filePicker.Selected > 0 { m.filePicker.Selected-- } return m, nil case "down": if m.filePicker.Selected < len(m.filePicker.Files)-1 { m.filePicker.Selected++ } return m, nil case "pgup": m.filePicker.Selected -= 10 if m.filePicker.Selected < 0 { m.filePicker.Selected = 0 } return m, nil case "pgdown": m.filePicker.Selected += 10 if m.filePicker.Selected >= len(m.filePicker.Files) { m.filePicker.Selected = len(m.filePicker.Files) - 1 } return m, nil case "left": parent := filepath.Dir(m.filePicker.CurDir) m.filePicker.CurDir = parent m.filePicker.Selected = 0 m.refreshFilePicker() return m, nil case "enter": tiVal := strings.TrimSpace(m.ti.Value()) if tiVal != "" { m.filePicker = nil return m.doFileAction(tiVal) } if m.filePicker.Selected >= 0 && m.filePicker.Selected < len(m.filePicker.Files) { entry := m.filePicker.Files[m.filePicker.Selected] if entry.IsDir() { m.filePicker.CurDir = filepath.Join(m.filePicker.CurDir, entry.Name()) m.filePicker.Selected = 0 m.refreshFilePicker() return m, nil } fullPath := filepath.Join(m.filePicker.CurDir, entry.Name()) m.ti.SetValue(fullPath) m.filePicker = nil return m.doFileAction(fullPath) } return m, nil } var cmd tea.Cmd m.ti, cmd = m.ti.Update(msg) return m, cmd } switch key { case "esc": if m.dialog == model.DialogQuitConfirm { m.quitting = true return m, tea.Quit } m.mode = model.ModeNormal m.dialog = model.DialogNone return m, nil case "q": if m.dialog == model.DialogQuitConfirm { m.quitting = true return m, tea.Quit } fallthrough default: var cmd tea.Cmd m.ti, cmd = m.ti.Update(msg) return m, cmd case "enter": switch m.dialog { case model.DialogSaveAs: name := m.ti.Value() if name != "" { m.rootMap.Filename = name if err := mapio.SerializeMap(m.rootMap, name); err != nil { m.dialogMsg = err.Error() } else { m.dialogMsg = fmt.Sprintf("Saved %s", name) m.dirty = false m.undoPosAtSave = m.undo.Pos() } } case model.DialogResize: var w, h int val := m.ti.Value() if n, _ := fmt.Sscanf(val, "%dx%d", &w, &h); n == 2 && w > 0 && h > 0 && w < 1000 && h < 1000 { m.resizeMap(w, h) } case model.DialogQuitConfirm: m.quitting = true return m, tea.Quit case model.DialogDeleteSubmapConfirm: m.dirty = true m.undo.Push(m.curMap()) delete(m.curMap().Submaps, m.cursor) case model.DialogRenameSymbol: name := m.ti.Value() if name != "" && m.selected < len(m.curPalette()) { m.curPalette()[m.selected].Name = name } case model.DialogRenameMap: name := m.ti.Value() if name != "" { m.curMap().Name = name } case model.DialogOpenMap: name := m.ti.Value() if name != "" { return m, m.loadMapCmd(name) } } m.mode = model.ModeNormal m.dialog = model.DialogNone return m, nil } } func (m *AppModel) doFileAction(path string) (tea.Model, tea.Cmd) { switch m.dialog { case model.DialogFileSave, model.DialogSaveAs: m.rootMap.Filename = path if err := mapio.SerializeMap(m.rootMap, path); err != nil { m.dialogMsg = err.Error() } else { m.dialogMsg = fmt.Sprintf("Saved %s", path) m.dirty = false m.undoPosAtSave = m.undo.Pos() } case model.DialogFileOpen, model.DialogOpenMap: m.mode = model.ModeNormal m.dialog = model.DialogNone return m, m.loadMapCmd(path) } m.mode = model.ModeNormal m.dialog = model.DialogNone return m, nil } func (m *AppModel) handleFilePickerMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { _, y := msg.X, msg.Y if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress { return m, nil } entryY := y - m.filePicker.PopupY if entryY >= 0 && entryY < len(m.filePicker.Files) { idx := entryY + m.filePicker.ListTop if idx >= 0 && idx < len(m.filePicker.Files) { m.filePicker.Selected = idx entry := m.filePicker.Files[idx] if entry.IsDir() { m.filePicker.CurDir = filepath.Join(m.filePicker.CurDir, entry.Name()) m.filePicker.Selected = 0 m.refreshFilePicker() } else { fullPath := filepath.Join(m.filePicker.CurDir, entry.Name()) m.ti.SetValue(fullPath) m.filePicker = nil return m.doFileAction(fullPath) } } } return m, nil } func (m *AppModel) saveMap() { if m.rootMap.Filename == "" { m.mode = model.ModeDialog m.dialog = model.DialogFileSave m.openFilePicker() m.ti.Focus() return } if err := mapio.SerializeMap(m.rootMap, m.rootMap.Filename); err != nil { m.dialogMsg = fmt.Sprintf("Save error: %v", err) } else { m.dialogMsg = fmt.Sprintf("Saved %s", m.rootMap.Filename) m.dirty = false m.undoPosAtSave = m.undo.Pos() } } func (m *AppModel) openFilePicker() { cur := "." m.filePicker = &FilePickerState{CurDir: cur, Selected: 0} m.refreshFilePicker() m.ti.SetValue("") } func (m *AppModel) refreshFilePicker() { entries, err := os.ReadDir(m.filePicker.CurDir) if err != nil { m.filePicker.Files = nil return } sort.Slice(entries, func(i, j int) bool { if entries[i].IsDir() != entries[j].IsDir() { return entries[i].IsDir() } return entries[i].Name() < entries[j].Name() }) m.filePicker.Files = entries if m.filePicker.Selected >= len(entries) { m.filePicker.Selected = len(entries) - 1 } if m.filePicker.Selected < 0 { m.filePicker.Selected = 0 } } func (m *AppModel) doFilePickerSelect() { if m.filePicker == nil || len(m.filePicker.Files) == 0 { return } entry := m.filePicker.Files[m.filePicker.Selected] if entry.IsDir() { m.filePicker.CurDir = filepath.Join(m.filePicker.CurDir, entry.Name()) m.filePicker.Selected = 0 m.refreshFilePicker() return } m.ti.SetValue(filepath.Join(m.filePicker.CurDir, entry.Name())) } func (m *AppModel) drillDown() { m.cancelPreview() sub, ok := m.curMap().Submaps[m.cursor] if !ok { sub = model.NewMap(fmt.Sprintf("%s/sub", m.curMap().Name), m.cfg.DefaultMapWidth, m.cfg.DefaultMapHeight, m.curMap().Palette) sub.Parent = m.curMap() sub.Filename = m.rootMap.Filename m.curMap().Submaps[m.cursor] = sub } m.prevCursor = m.cursor m.map_ = sub m.cursor = model.Point{X: 0, Y: 0} m.offset = model.Point{X: 0, Y: 0} } func (m *AppModel) drillUp() { if m.curMap().Parent == nil { return } if m.isMapBlank(m.curMap()) { delete(m.curMap().Parent.Submaps, m.prevCursor) } parent := m.curMap().Parent m.map_ = parent if m.prevCursor.X >= 0 { m.cursor = m.prevCursor } else { m.cursor = model.Point{X: -1, Y: -1} } m.offset = model.Point{X: 0, Y: 0} m.prevCursor = model.Point{X: -1, Y: -1} } func (m *AppModel) isMapBlank(mm *model.Map) bool { for y := range mm.Grid { for x := range mm.Grid[y] { if mm.Grid[y][x].Terrain >= 0 || mm.Grid[y][x].Text != "" { return false } } } return len(mm.Submaps) == 0 } func (m *AppModel) handleToolbarClick(x int) { widths := []int{16, 10, 21, 5, 5, 5, 6, 6, 6} pos := 0 for i, w := range widths { if x >= pos && x < pos+w { switch i { case 0: m.mode = model.ModeDialog m.dialog = model.DialogRenameMap m.ti.SetValue(m.curMap().Name) m.ti.Focus() case 1: m.mode = model.ModeDialog m.dialog = model.DialogResize m.ti.SetValue(fmt.Sprintf("%dx%d", m.curMap().Width, m.curMap().Height)) m.ti.Focus() case 2: m.mode = model.ModeDialog m.dialog = model.DialogFileSave m.openFilePicker() m.ti.SetValue(m.rootMap.Filename) m.ti.Focus() case 3: m.unicode = !m.unicode case 4: m.colorMode = !m.colorMode case 5: m.fillShapes = !m.fillShapes case 6: m.saveMap() case 7: m.mode = model.ModeDialog m.dialog = model.DialogFileOpen m.openFilePicker() m.ti.Focus() case 8: if m.dirty { m.mode = model.ModeDialog m.dialog = model.DialogQuitConfirm } else { m.quitting = true } } return } pos += w } } func (m *AppModel) handleSidebarClick(x, y int) { relY := y - 1 if relY >= 1 && relY <= 10 { idx := m.palettePage*10 + (relY - 1) if idx < len(m.curPalette()) { m.selected = idx } return } if relY == 11 { if x < m.width-sidebarW+7 { m.palettePagePrev() } else { m.palettePageNext() } return } if relY == 12 { if x < m.width-sidebarW+8 { m.addSymbol() } else { m.removeSymbol() } return } if relY == 13 { if x < m.width-sidebarW+8 { m.moveSymbolUp() } else { m.moveSymbolDown() } return } if relY == 14 { if x < m.width-sidebarW+10 { m.mode = model.ModeDialog m.dialog = model.DialogRenameSymbol m.ti.SetValue(m.curPalette()[model.Clamp(m.selected, 0, len(m.curPalette())-1)].Name) m.ti.Focus() } else { m.openColorPicker(false) } return } toolRow := relY - 16 if toolRow >= 0 && toolRow < 8 { newTool := model.Tool(toolRow) if m.tool != newTool && m.mode == model.ModeTextEdit { m.mode = model.ModeNormal m.movingLabel = -1 m.textEditing = false m.textInput.Blur() } m.tool = newTool } bwRow := relY - 25 if bwRow >= 0 && bwRow < 3 { m.brushWidth = []int{1, 3, 5}[bwRow] } } func (m *AppModel) addSymbol() { p := m.curPalette() if len(p) >= 100 { return } m.dirty = true m.undo.Push(m.curMap()) m.curMap().Palette = append(p, model.Terrain{Name: "new", Symbol: "?", ASCII: "?", Colors: []model.TerrainColor{{Color: "255", Weight: 100}}}) } func (m *AppModel) removeSymbol() { if len(m.curPalette()) <= 1 { return } m.dirty = true m.undo.Push(m.curMap()) idx := model.Clamp(m.selected, 0, len(m.curPalette())-1) for y := range m.curMap().Grid { for x := range m.curMap().Grid[y] { c := &m.curMap().Grid[y][x] if c.Terrain == idx { c.Terrain = -1 } else if c.Terrain > idx { c.Terrain-- } } } m.curMap().Palette = append(m.curMap().Palette[:idx], m.curMap().Palette[idx+1:]...) if m.selected >= len(m.curPalette()) { m.selected = len(m.curPalette()) - 1 } } func (m *AppModel) moveSymbolUp() { if m.selected <= 0 || m.selected >= len(m.curPalette()) { return } m.dirty = true m.undo.Push(m.curMap()) i := m.selected m.curPalette()[i], m.curPalette()[i-1] = m.curPalette()[i-1], m.curPalette()[i] for y := range m.curMap().Grid { for x := range m.curMap().Grid[y] { c := &m.curMap().Grid[y][x] if c.Terrain == i { c.Terrain = i - 1 } else if c.Terrain == i-1 { c.Terrain = i } } } m.selected = i - 1 } func (m *AppModel) moveSymbolDown() { if m.selected < 0 || m.selected >= len(m.curPalette())-1 { return } m.dirty = true m.undo.Push(m.curMap()) i := m.selected m.curPalette()[i], m.curPalette()[i+1] = m.curPalette()[i+1], m.curPalette()[i] for y := range m.curMap().Grid { for x := range m.curMap().Grid[y] { c := &m.curMap().Grid[y][x] if c.Terrain == i { c.Terrain = i + 1 } else if c.Terrain == i+1 { c.Terrain = i } } } m.selected = i + 1 } func (m *AppModel) palettePagePrev() { totalPages := (len(m.curPalette()) + 9) / 10 if totalPages <= 1 { return } m.palettePage = (m.palettePage - 1 + totalPages) % totalPages } func (m *AppModel) palettePageNext() { totalPages := (len(m.curPalette()) + 9) / 10 if totalPages <= 1 { return } m.palettePage = (m.palettePage + 1) % totalPages } func (m *AppModel) resizeMap(w, h int) { m.dirty = true m.undo.Push(m.curMap()) old := m.curMap() newGrid := make([][]model.Cell, h) for y := range newGrid { newGrid[y] = make([]model.Cell, w) for x := range newGrid[y] { if y < old.Height && x < old.Width { newGrid[y][x] = old.Grid[y][x] } else { newGrid[y][x].Terrain = -1 } } } old.Grid = newGrid old.Width = w old.Height = h m.cursor.X = model.Clamp(m.cursor.X, 0, w-1) m.cursor.Y = model.Clamp(m.cursor.Y, 0, h-1) }