package tui import ( "fmt" "strings" "tui-ascii-mapper/internal/model" "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") ) 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 := model.Point{X: mx, Y: my} if !m.curMap().InBounds(p) { sb.WriteString(m.cellStr("\u00b7", "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 = "\u00b7" fg = "250" } if m.mode == model.ModeTextEdit && m.tool == model.ToolText { text := m.textInput.Value() cursorCh := "" if m.textInput.Focused() { cursorCh = "\u2502" } 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 } } } if m.mode != model.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 model.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') } 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_ := []model.Tool{model.ToolBrush, model.ToolSelect, model.ToolErase, model.ToolFill, model.ToolLine, model.ToolRect, model.ToolCircle, model.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) renderDialogBox() string { switch m.dialog { case model.DialogSaveAs: return m.renderFilePickerPopup("Save As") case model.DialogOpenMap: return m.renderFilePickerPopup("Open Map") case model.DialogFileSave: return m.renderFilePickerPopup("Save As") case model.DialogFileOpen: return m.renderFilePickerPopup("Open Map") case model.DialogResize: return renderPopup("Resize Map", "Size (e.g. 80x25):", m.ti.View()) case model.DialogQuitConfirm: return renderPopup("Quit", "Quit without saving?", "[Enter] Quit [Esc] Cancel") case model.DialogDeleteSubmapConfirm: return renderPopup("Delete Submap", fmt.Sprintf("Delete submap at %d,%d?", m.cursor.X, m.cursor.Y), "[Enter] Confirm [Esc] Cancel") case model.DialogRenameSymbol: return renderPopup("Rename Symbol", "New name:", m.ti.View()) case model.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 }