aboutsummaryrefslogtreecommitdiff
path: root/internal/tui/render.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/render.go')
-rw-r--r--internal/tui/render.go465
1 files changed, 465 insertions, 0 deletions
diff --git a/internal/tui/render.go b/internal/tui/render.go
new file mode 100644
index 0000000..1b34a21
--- /dev/null
+++ b/internal/tui/render.go
@@ -0,0 +1,465 @@
+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
+}