grimoir/render.go
2026-07-13 09:23:01 +03:00

668 lines
No EOL
20 KiB
Go
Executable file
Raw Permalink 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"
"sync"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
// === Кеш visibleWidth ===
// Большинство строк рендерятся повторно без изменений — кешируем результат.
var (
vwCache = map[string]int{}
vwCacheMu sync.RWMutex
vwCacheMax = 4000
)
// === Скомпилированные regex для markdown ===
// Компилируются один раз при старте программы вместо повторной компиляции
// на каждый вызов рендера (который происходит на каждый keystroke при
// открытом live preview). regexp.Compile — это не просто аллокация, а
// разбор паттерна в NFA, повторение этого на каждую строку документа на
// каждое нажатие клавиши создавало заметную нагрузку на больших файлах.
var (
reMdHeadingLoose = regexp.MustCompile(`^(#+)\s+(.*)`)
reMdBold = regexp.MustCompile(`\*\*([^*]+)\*\*`)
reMdItalic = regexp.MustCompile(`\*([^*]+)\*`)
reMdInlineCode = regexp.MustCompile("`([^`]+)`")
reMdLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
reMdLinkText = regexp.MustCompile(`\[([^\]]+)\]`)
reMdHR = regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})$`)
reMdHeadingStrict = regexp.MustCompile(`^(#{1,4})\s+(.+)`)
reMdBullet = regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`)
reMdOrdered = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`)
reMdTableSep = regexp.MustCompile(`^\|[\s\-:|]+\|`)
)
// === 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)
}
// === Построители строк (возвращают []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
}
// Диапазон выделения для VISUAL LINE режима (если активен)
var visStart, visEnd int = -1, -1
if m.mode == VISUAL {
visStart, visEnd = m.visualRange()
}
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)
}
// Обрезаем по ширине контента
truncated := truncateVisible(rendered, contentW)
// Подсветка фона для выделенных строк VISUAL LINE
if visStart >= 0 && lineNum >= visStart && lineNum <= visEnd {
truncated = visualHighlight(truncated, contentW)
}
sb.WriteString(truncated)
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
}
// 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++ {
out.WriteString(clearLine)
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 {
withClear := make([]string, len(rows))
for i, r := range rows {
withClear[i] = clearLine + r
}
return strings.Join(withClear, "\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.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 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 утилиты ===
// visibleWidth возвращает визуальную ширину строки в терминале,
// учитывая ANSI escape-коды и double-width символы через go-runewidth.
// Результат кешируется — большинство строк повторяются между кадрами.
func visibleWidth(s string) int {
// Быстрый путь для коротких строк без ANSI
if len(s) == 0 {
return 0
}
vwCacheMu.RLock()
if v, ok := vwCache[s]; ok {
vwCacheMu.RUnlock()
return v
}
vwCacheMu.RUnlock()
w := 0
inEsc := false
for _, r := range s {
if r == '\x1b' {
inEsc = true
continue
}
if inEsc {
if r == 'm' {
inEsc = false
}
continue
}
w += runewidth.RuneWidth(r)
}
vwCacheMu.Lock()
if len(vwCache) < vwCacheMax {
vwCache[s] = w
}
vwCacheMu.Unlock()
return w
}
// InvalidateVWCache сбрасывает кеш visibleWidth
func InvalidateVWCache() {
vwCacheMu.Lock()
vwCache = map[string]int{}
vwCacheMu.Unlock()
}
// 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 видимых столбцов терминала
// visualHighlight оборачивает строку в фон VISUAL LINE выделения и
// добивает её пробелами с тем же фоном до полной ширины колонки,
// чтобы подсветка визуально покрывала всю строку, а не только текст.
func visualHighlight(s string, width int) string {
vis := visibleWidth(s)
pad := width - vis
if pad < 0 {
pad = 0
}
return "\x1b[48;5;24m" + s + "\x1b[0m\x1b[48;5;24m" + strings.Repeat(" ", pad) + "\x1b[0m"
}
// truncateVisible обрезает строку до maxWidth видимых столбцов терминала.
// ВСЕГДА возвращает строку ровно maxWidth видимой ширины — добивает пробелами
// если короче. Это критично: если просто обрезать без добивки, при замене
// длинной строки на более короткую между кадрами (например при скролле)
// "хвост" предыдущей строки остаётся не стёртым в терминальном буфере,
// что выглядит как дублирование/застревание старого контента.
// clearLine — ANSI escape для полной очистки текущей строки терминала.
// Используется как защита от artifacts в терминальных мультиплексорах
// (Zellij, tmux), которые могут кешировать содержимое строки построчно
// и не полностью перерисовывать её, если новая строка короче предыдущей
// даже при наличии добивки пробелами с другим цветом фона.
const clearLine = "\x1b[2K"
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.RuneWidth(r)
if vis+rw > maxWidth {
break
}
sb.WriteRune(r)
vis += rw
}
sb.WriteString("\x1b[0m")
// Добиваем пробелами до точной ширины — без этого короткие строки
// не перекрывают остатки предыдущего, более длинного контента.
if vis < maxWidth {
sb.WriteString(strings.Repeat(" ", maxWidth-vis))
}
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 {
if m := reMdHeadingLoose.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 = reMdBold.ReplaceAllStringFunc(result, func(s string) string {
return colorBold.Render(s[2 : len(s)-2])
})
result = reMdItalic.ReplaceAllStringFunc(result, func(s string) string {
return lipgloss.NewStyle().Italic(true).Render(s[1 : len(s)-1])
})
result = reMdInlineCode.ReplaceAllStringFunc(result, func(s string) string {
return colorCode.Render(s[1 : len(s)-1])
})
result = reMdLink.ReplaceAllStringFunc(result, func(s string) string {
m := reMdLinkText.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
}
// Пересчитываем только если контент изменился
if !m.previewDirty {
return
}
m.previewBuf = renderMarkdown(m.lines)
m.previewDirty = false
}
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 reMdHR.MatchString(strings.TrimSpace(line)) {
return colorHR.Render(strings.Repeat("─", 60))
}
if m := reMdHeadingStrict.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 := reMdBullet.FindStringSubmatch(line); m != nil {
return m[1] + colorListBul.Render("•") + " " + applyInlineMarkdown(m[3])
}
if m := reMdOrdered.FindStringSubmatch(line); m != nil {
return m[1] + colorListBul.Render(m[2]+".") + " " + applyInlineMarkdown(m[3])
}
if strings.Contains(line, "|") && strings.HasPrefix(strings.TrimSpace(line), "|") {
if reMdTableSep.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 = reMdBold.ReplaceAllStringFunc(result, func(s string) string {
return colorBold.Render(s[2 : len(s)-2])
})
result = reMdItalic.ReplaceAllStringFunc(result, func(s string) string {
return lipgloss.NewStyle().Italic(true).Render(s[1 : len(s)-1])
})
result = reMdInlineCode.ReplaceAllStringFunc(result, func(s string) string {
return colorCode.Render(s[1 : len(s)-1])
})
result = reMdLink.ReplaceAllStringFunc(result, func(s string) string {
m := reMdLink.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
}