grimoir/render.go
2026-05-07 14:57:14 +03:00

719 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"regexp"
"strings"
"github.com/charmbracelet/lipgloss"
)
// === UI стили ===
var (
colorBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
colorHeading = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
colorCode = lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("11"))
colorLink = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Underline(true)
colorDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
colorCursor = lipgloss.NewStyle().Reverse(true)
colorMode = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11"))
colorQuote = lipgloss.NewStyle().Foreground(lipgloss.Color("243")).Italic(true)
colorHR = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
)
const grimVersion = "grimoir v1.0"
// === Главная точка рендера ===
func renderEditor(m *EditorModel) string {
rows := buildEditorRows(m, m.width, m.height)
return rowsToString(rows)
}
func renderSplit(m *EditorModel) string {
edW := m.editorWidth()
prW := m.previewWidth
edRows := buildEditorRows(m, edW, m.height)
prRows := buildPreviewRows(m, prW, m.height)
return joinRowColumns([][]string{edRows, prRows}, []int{edW, prW}, m.height)
}
func renderTreeEditor(m *EditorModel) string {
edW := m.editorWidth()
trRows := buildTreeRows(m, m.treeWidth, m.height)
edRows := buildEditorRows(m, edW, m.height)
return joinRowColumns([][]string{trRows, edRows}, []int{m.treeWidth, edW}, m.height)
}
func renderTreeEditorPreview(m *EditorModel) string {
edW := m.editorWidth()
prW := m.previewWidth
trRows := buildTreeRows(m, m.treeWidth, m.height)
edRows := buildEditorRows(m, edW, m.height)
prRows := buildPreviewRows(m, prW, m.height)
return joinRowColumns([][]string{trRows, edRows, prRows}, []int{m.treeWidth, edW, prW}, m.height)
}
// === Построители строк (возвращают []string, по одной на строку экрана) ===
// buildEditorRows строит строки редактора. Каждый элемент — одна строка без \n.
func buildEditorRows(m *EditorModel, width, height int) []string {
rows := make([]string, 0, height)
// Заголовок
rows = append(rows, buildHeader(m, width))
// Строки текста
edHeight := height - 2 // заголовок + статус
start, end := visibleRange(m.cursorY, edHeight, len(m.lines))
numW := 0
if m.showLineNumbers {
numW = 5
}
contentW := width - numW
if contentW < 1 {
contentW = 1
}
for lineNum := start; lineNum < end; lineNum++ {
var sb strings.Builder
if m.showLineNumbers {
// Номер строки без ANSI-заливки ширины — просто форматируем текст
num := fmt.Sprintf("%4d ", lineNum+1)
sb.WriteString("\x1b[38;5;8m" + num + "\x1b[0m")
}
line := m.lines[lineNum]
var rendered string
if lineNum == m.cursorY {
rendered = renderLineWithCursor(line, m.cursorX, m.fileType)
} else {
rendered = renderLine(line, m.fileType)
}
// Обрезаем по ширине контента
sb.WriteString(truncateVisible(rendered, contentW))
rows = append(rows, sb.String())
}
// Пустые строки ~
for i := end - start; i < edHeight; i++ {
rows = append(rows, "\x1b[38;5;8m~\x1b[0m")
}
// Статус-строка
rows = append(rows, buildStatusBar(m, width))
return rows
}
// buildTreeRows строит строки файлового дерева
func buildTreeRows(m *EditorModel, width, height int) []string {
ft := &m.fileTree
rows := make([]string, 0, height)
// Заголовок
title := truncateString(filepath_base(ft.root), width-4)
bgColor := "8"
if ft.focused {
bgColor = "4"
}
headerContent := " > " + title
rows = append(rows, ansiLine(
"\x1b[48;5;"+bgColor+"m\x1b[38;5;15m\x1b[1m"+headerContent+"\x1b[0m",
width,
))
listHeight := height - 2 // заголовок + подсказка
ft.adjustScroll(listHeight)
shown := 0
for i := ft.scroll; i < ft.scroll+listHeight && i < len(ft.nodes); i++ {
node := ft.nodes[i]
line := buildFileNodeLine(node, m.filepath)
if i == ft.cursor && ft.focused {
// Выделенный элемент — инвертируем фон
line = "\x1b[48;5;237m\x1b[38;5;15m\x1b[1m" + stripANSI(line) + "\x1b[0m"
} else if i == ft.cursor {
line = "\x1b[38;5;245m" + stripANSI(line) + "\x1b[0m"
}
rows = append(rows, line)
shown++
}
// Пустые строки
for shown < listHeight {
rows = append(rows, "")
shown++
}
// Подсказка
hint := " Enter:open Tab:focus </>:width"
hint = truncateString(hint, width)
rows = append(rows, "\x1b[38;5;8m"+hint+"\x1b[0m")
return rows
}
// buildPreviewRows строит строки preview
func buildPreviewRows(m *EditorModel, width, height int) []string {
rows := make([]string, 0, height)
// Заголовок
rows = append(rows, ansiLine("\x1b[48;5;8m\x1b[38;5;15m PREVIEW \x1b[0m", width))
contentHeight := height - 2
lines := strings.Split(m.previewBuf, "\n")
shown := 0
for i := 0; i < contentHeight && i < len(lines); i++ {
rows = append(rows, truncateVisible(lines[i], width))
shown++
}
for shown < contentHeight {
rows = append(rows, "")
shown++
}
rows = append(rows, "\x1b[38;5;8m </>: preview width\x1b[0m")
return rows
}
// === Объединение колонок ===
// joinRowColumns объединяет колонки построчно, добивая каждую до нужной ширины
func joinRowColumns(cols [][]string, widths []int, height int) string {
var out strings.Builder
for row := 0; row < height; row++ {
for ci, col := range cols {
w := widths[ci]
line := ""
if row < len(col) {
line = col[row]
}
vis := visibleWidth(line)
if vis < w {
out.WriteString(line)
out.WriteString(strings.Repeat(" ", w-vis))
} else if vis > w {
out.WriteString(truncateVisible(line, w))
} else {
out.WriteString(line)
}
}
out.WriteString("\n")
}
return out.String()
}
func rowsToString(rows []string) string {
return strings.Join(rows, "\n") + "\n"
}
// === Построители отдельных элементов ===
func buildHeader(m *EditorModel, width int) string {
name := m.filepath
if m.dirty {
name += " [+]"
}
left := " " + name + " [" + m.fileType + "]"
right := " " + grimVersion + " "
leftW := len([]rune(left))
rightW := len([]rune(right))
pad := width - leftW - rightW
if pad < 0 {
pad = 0
}
content := left + strings.Repeat(" ", pad) + right
return "\x1b[48;5;8m\x1b[38;5;15m" + content + "\x1b[0m"
}
func buildStatusBar(m *EditorModel, width int) string {
modeStr := ""
switch m.mode {
case INSERT:
modeStr = "\x1b[1;38;5;11mINSERT\x1b[0m "
case VISUAL:
modeStr = "\x1b[1;38;5;11mVISUAL\x1b[0m "
}
status := m.statusMsg
if status == "" {
undoInfo := ""
if len(m.undoStack) > 0 {
undoInfo = fmt.Sprintf(" u:%d", len(m.undoStack))
}
status = fmt.Sprintf("Ln %d/%d Col %d%s", m.cursorY+1, len(m.lines), m.cursorX+1, undoInfo)
}
hints := []string{m.fileType}
if m.showFileTree {
hints = append(hints, fmt.Sprintf("tree:%d", m.treeWidth))
}
if m.showPreview && m.fileType == "md" {
hints = append(hints, fmt.Sprintf("prev:%d", m.previewWidth))
}
right := " " + strings.Join(hints, " ") + " "
left := modeStr + status
leftVis := visibleWidth(left)
rightVis := len([]rune(right))
pad := width - leftVis - rightVis
if pad < 0 {
pad = 0
}
return "\x1b[48;5;236m\x1b[38;5;8m" + left + strings.Repeat(" ", pad) +
"\x1b[38;5;11m" + right + "\x1b[0m"
}
func buildFileNodeLine(node *FileNode, currentFile string) string {
indent := ""
if node.depth > 0 {
indent = strings.Repeat(" ", node.depth-1)
}
if node.isDir {
icon := "▸ "
if node.expanded {
icon = "▾ "
}
return indent + "\x1b[1;38;5;33m" + icon + node.name + "\x1b[0m"
}
if node.path == currentFile {
return indent + " \x1b[38;5;214m● " + node.name + "\x1b[0m"
}
return indent + " \x1b[38;5;252m" + node.name + "\x1b[0m"
}
// === Рендер строк текста ===
func renderLine(line, fileType string) string {
if fileType == "md" {
return colorizeMarkdown(line)
}
return HighlightLine(line, fileType)
}
func renderLineWithCursor(line string, cursorX int, fileType string) string {
runes := []rune(line)
if cursorX < 0 {
cursorX = 0
}
if cursorX > len(runes) {
cursorX = len(runes)
}
before := string(runes[:cursorX])
cursorChar := " "
after := ""
if cursorX < len(runes) {
cursorChar = string(runes[cursorX])
after = string(runes[cursorX+1:])
}
if fileType == "md" {
before = colorizeMarkdown(before)
after = colorizeMarkdown(after)
} else {
before = HighlightLine(before, fileType)
after = HighlightLine(after, fileType)
}
return before + colorCursor.Render(cursorChar) + after
}
// === ANSI утилиты ===
// runeWidth возвращает визуальную ширину символа в терминале.
// Большинство символов = 1, но wide characters (CJK, emoji) = 2.
func runeWidth(r rune) int {
// Управляющие символы
if r < 32 || r == 127 {
return 0
}
// ASCII и Latin-1
if r < 0x300 {
return 1
}
// Combining marks (нулевая ширина)
if r >= 0x300 && r <= 0x36F {
return 0
}
if r >= 0x1AB0 && r <= 0x1AFF {
return 0
}
if r >= 0x1DC0 && r <= 0x1DFF {
return 0
}
if r >= 0x20D0 && r <= 0x20FF {
return 0
}
if r >= 0xFE20 && r <= 0xFE2F {
return 0
}
// CJK и широкие блоки
switch {
case r >= 0x1100 && r <= 0x115F:
return 2 // Hangul Jamo
case r == 0x2329 || r == 0x232A:
return 2
case r >= 0x2E80 && r <= 0x303E:
return 2 // CJK Radicals
case r >= 0x3040 && r <= 0x33FF:
return 2 // Japanese
case r >= 0x3400 && r <= 0x4DBF:
return 2 // CJK Extension A
case r >= 0x4E00 && r <= 0xA4CF:
return 2 // CJK Unified
case r >= 0xA960 && r <= 0xA97F:
return 2 // Hangul Jamo Extended-A
case r >= 0xAC00 && r <= 0xD7AF:
return 2 // Hangul Syllables
case r >= 0xF900 && r <= 0xFAFF:
return 2 // CJK Compatibility
case r >= 0xFE10 && r <= 0xFE19:
return 2
case r >= 0xFE30 && r <= 0xFE6F:
return 2
case r >= 0xFF00 && r <= 0xFF60:
return 2 // Fullwidth
case r >= 0xFFE0 && r <= 0xFFE6:
return 2
case r >= 0x1B000 && r <= 0x1B0FF:
return 2 // Kana Supplement
case r >= 0x1F004 && r <= 0x1F0CF:
return 2 // Playing cards etc
case r >= 0x1F100 && r <= 0x1F1FF:
return 1 // Enclosed Alphanumeric Supplement (flags base)
case r >= 0x1F200 && r <= 0x1F2FF:
return 2 // Enclosed IdeographicSupplement
case r >= 0x1F300 && r <= 0x1F9FF:
return 2 // Misc symbols, emoji
case r >= 0x1FA00 && r <= 0x1FAFF:
return 2 // Chess, symbols
case r >= 0x20000 && r <= 0x2FFFD:
return 2 // CJK Extension B-F
case r >= 0x30000 && r <= 0x3FFFD:
return 2
}
return 1
}
// visibleWidth возвращает визуальную ширину строки в терминале,
// учитывая ANSI escape-коды и double-width символы (CJK, emoji).
func visibleWidth(s string) int {
w := 0
inEsc := false
for _, r := range s {
if r == '\x1b' {
inEsc = true
continue
}
if inEsc {
if r == 'm' {
inEsc = false
}
continue
}
w += runeWidth(r)
}
return w
}
// stripANSI удаляет все ANSI escape-последовательности
func stripANSI(s string) string {
var sb strings.Builder
inEsc := false
for _, r := range s {
if r == '\x1b' {
inEsc = true
continue
}
if inEsc {
if r == 'm' {
inEsc = false
}
continue
}
sb.WriteRune(r)
}
return sb.String()
}
// truncateVisible обрезает строку до maxWidth видимых столбцов терминала
func truncateVisible(s string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
var sb strings.Builder
vis := 0
inEsc := false
for _, r := range s {
if r == '\x1b' {
inEsc = true
sb.WriteRune(r)
continue
}
if inEsc {
sb.WriteRune(r)
if r == 'm' {
inEsc = false
}
continue
}
rw := runeWidth(r)
if vis+rw > maxWidth {
break
}
sb.WriteRune(r)
vis += rw
}
sb.WriteString("\x1b[0m")
return sb.String()
}
// ansiLine создаёт строку заданной видимой ширины.
// Возвращает: ANSI-контент + reset + пробелы до нужной ширины.
// Пробелы идут ПОСЛЕ reset чтобы visibleWidth считал их корректно.
func ansiLine(s string, width int) string {
// Считаем видимую ширину исходной строки (без учёта \x1b[0m в конце если есть)
// Убираем финальный reset если он уже есть
base := s
if strings.HasSuffix(base, "\x1b[0m") {
base = base[:len(base)-4]
}
vis := visibleWidth(base)
if vis < width {
return base + "\x1b[0m" + strings.Repeat(" ", width-vis)
}
return truncateVisible(base, width)
}
// truncateString обрезает строку по рунам (без ANSI)
func truncateString(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max])
}
// filepath_base — аналог filepath.Base без импорта
func filepath_base(path string) string {
if path == "" {
return "."
}
// Убираем trailing slashes
for len(path) > 1 && (path[len(path)-1] == '/' || path[len(path)-1] == '\\') {
path = path[:len(path)-1]
}
// Берём последний компонент
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' || path[i] == '\\' {
return path[i+1:]
}
}
return path
}
// visibleLen — псевдоним visibleWidth для обратной совместимости с help.go
func visibleLen(s string) int {
return visibleWidth(s)
}
// truncateANSI — псевдоним truncateVisible для обратной совместимости
func truncateANSI(s string, maxWidth int) string {
return truncateVisible(s, maxWidth)
}
// === Markdown ===
func colorizeMarkdown(line string) string {
headingRe := regexp.MustCompile(`^(#+)\s+(.*)`)
if m := headingRe.FindStringSubmatch(line); m != nil {
level := len(m[1])
text := m[2]
switch level {
case 1:
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")).Render(m[1] + " " + text)
case 2:
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render(m[1] + " " + text)
default:
return lipgloss.NewStyle().Foreground(lipgloss.Color("45")).Render(m[1] + " " + text)
}
}
result := line
result = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllStringFunc(result, func(s string) string {
return colorBold.Render(s[2 : len(s)-2])
})
result = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllStringFunc(result, func(s string) string {
return lipgloss.NewStyle().Italic(true).Render(s[1 : len(s)-1])
})
result = regexp.MustCompile("`([^`]+)`").ReplaceAllStringFunc(result, func(s string) string {
return colorCode.Render(s[1 : len(s)-1])
})
result = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllStringFunc(result, func(s string) string {
m := regexp.MustCompile(`\[([^\]]+)\]`).FindStringSubmatch(s)
if m != nil {
return colorLink.Render(m[1])
}
return s
})
return result
}
func (m *EditorModel) updatePreview() {
if m.fileType != "md" {
m.previewBuf = ""
return
}
m.previewBuf = renderMarkdown(m.lines)
}
func renderMarkdown(lines []string) string {
var out strings.Builder
inCode := false
lang := ""
var codeLines []string
for _, line := range lines {
if strings.HasPrefix(line, "```") {
if inCode {
out.WriteString(renderCodeBlock(codeLines, lang))
inCode = false
codeLines = nil
lang = ""
} else {
inCode = true
lang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
}
continue
}
if inCode {
codeLines = append(codeLines, line)
continue
}
out.WriteString(renderMarkdownLine(line))
out.WriteString("\n")
}
if inCode && len(codeLines) > 0 {
out.WriteString(renderCodeBlock(codeLines, lang))
}
return out.String()
}
func renderMarkdownLine(line string) string {
if regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})$`).MatchString(strings.TrimSpace(line)) {
return colorHR.Render(strings.Repeat("─", 60))
}
if m := regexp.MustCompile(`^(#{1,4})\s+(.+)`).FindStringSubmatch(line); m != nil {
level := len(m[1])
text := applyInlineMarkdown(m[2])
switch level {
case 1:
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")).Render("▌ " + text)
case 2:
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render("▌ " + text)
case 3:
return lipgloss.NewStyle().Foreground(lipgloss.Color("45")).Render("▌ " + text)
default:
return lipgloss.NewStyle().Foreground(lipgloss.Color("51")).Render(text)
}
}
if strings.HasPrefix(line, "> ") {
return colorQuote.Render("┃ " + applyInlineMarkdown(strings.TrimPrefix(line, "> ")))
}
if m := regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`).FindStringSubmatch(line); m != nil {
return m[1] + colorListBul.Render("•") + " " + applyInlineMarkdown(m[3])
}
if m := regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`).FindStringSubmatch(line); m != nil {
return m[1] + colorListBul.Render(m[2]+".") + " " + applyInlineMarkdown(m[3])
}
if strings.Contains(line, "|") && strings.HasPrefix(strings.TrimSpace(line), "|") {
if regexp.MustCompile(`^\|[\s\-:|]+\|`).MatchString(strings.TrimSpace(line)) {
return colorHR.Render(strings.Repeat("─", 40))
}
cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|")
rendered := make([]string, len(cells))
for i, c := range cells {
rendered[i] = applyInlineMarkdown(strings.TrimSpace(c))
}
return "│ " + strings.Join(rendered, " │ ") + " │"
}
if strings.TrimSpace(line) == "" {
return ""
}
return applyInlineMarkdown(line)
}
func applyInlineMarkdown(text string) string {
result := text
result = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllStringFunc(result, func(s string) string {
return colorBold.Render(s[2 : len(s)-2])
})
result = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllStringFunc(result, func(s string) string {
return lipgloss.NewStyle().Italic(true).Render(s[1 : len(s)-1])
})
result = regexp.MustCompile("`([^`]+)`").ReplaceAllStringFunc(result, func(s string) string {
return colorCode.Render(s[1 : len(s)-1])
})
result = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllStringFunc(result, func(s string) string {
m := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).FindStringSubmatch(s)
if m != nil {
return colorLink.Render(m[1]) + lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(" ("+m[2]+")")
}
return s
})
return result
}
func renderCodeBlock(codeLines []string, lang string) string {
var out strings.Builder
lang = strings.ToLower(strings.TrimSpace(lang))
label := lang
if label == "" {
label = "code"
}
out.WriteString(colorDim.Render(" ┌─ " + label))
out.WriteString("\n")
for _, line := range codeLines {
out.WriteString(colorDim.Render(" │ "))
out.WriteString(HighlightLine(line, lang))
out.WriteString("\n")
}
out.WriteString(colorDim.Render(" └─"))
out.WriteString("\n")
return out.String()
}
// === Прочие утилиты ===
func visibleRange(cursorY, height, totalLines int) (start, end int) {
start = 0
if cursorY > height/2 {
start = cursorY - height/2
}
end = start + height
if end > totalLines {
end = totalLines
}
return
}
func renderStatusBar(m *EditorModel) string {
return buildStatusBar(m, m.width)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}