grimoir/render.go
2026-05-07 09:52:06 +03:00

503 lines
14 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)
)
// === Компоновки экрана ===
// renderEditor — только редактор, на весь экран
func renderEditor(m *EditorModel) string {
var out strings.Builder
w := m.width
out.WriteString(renderHeader(m, w))
out.WriteString(renderEditorLines(m, w, m.height-3))
out.WriteString(renderStatusBar(m))
return out.String()
}
// renderSplit — редактор + preview (без дерева)
func renderSplit(m *EditorModel) string {
edW := m.editorWidth()
prW := m.previewWidth
return lipgloss.JoinHorizontal(lipgloss.Top,
renderEditorBlock(m, edW),
renderPreviewBlock(m, prW),
)
}
// renderTreeEditor — дерево + редактор
func renderTreeEditor(m *EditorModel) string {
edW := m.editorWidth()
return lipgloss.JoinHorizontal(lipgloss.Top,
renderFileTree(m),
renderEditorBlock(m, edW),
)
}
// renderTreeEditorPreview — дерево + редактор + preview
func renderTreeEditorPreview(m *EditorModel) string {
edW := m.editorWidth()
prW := m.previewWidth
return lipgloss.JoinHorizontal(lipgloss.Top,
renderFileTree(m),
renderEditorBlock(m, edW),
renderPreviewBlock(m, prW),
)
}
// === Блоки ===
// renderEditorBlock рендерит редактор заданной ширины (для split/tree компоновок)
func renderEditorBlock(m *EditorModel, width int) string {
var out strings.Builder
out.WriteString(renderHeader(m, width))
out.WriteString(renderEditorLines(m, width, m.height-3))
out.WriteString(renderEditorStatusLine(m, width))
return out.String()
}
// renderPreviewBlock рендерит панель preview заданной ширины
func renderPreviewBlock(m *EditorModel, width int) string {
var out strings.Builder
height := m.height - 3
headerStyle := lipgloss.NewStyle().
Background(lipgloss.Color("8")).
Foreground(lipgloss.Color("15")).
Width(width).Padding(0, 1)
out.WriteString(headerStyle.Render(" PREVIEW "))
out.WriteString("\n")
lines := strings.Split(m.previewBuf, "\n")
rendered := 0
for i := 0; i < height && i < len(lines); i++ {
line := truncateANSI(lines[i], width)
out.WriteString(line)
out.WriteString("\n")
rendered++
}
for i := rendered; i < height; i++ {
out.WriteString("\n")
}
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width)
hint := " </>: ширина preview"
out.WriteString(hintStyle.Render(hint))
return out.String()
}
// === Внутренние части редактора ===
const grimVersion = "grimoir v0.1"
func renderHeader(m *EditorModel, width int) string {
name := m.filepath
if m.dirty {
name += " [+]"
}
left := fmt.Sprintf(" %s [%s]", name, m.fileType)
right := fmt.Sprintf(" %s ", grimVersion)
// Заполняем пространство между левой и правой частью
pad := width - len(left) - len(right)
if pad < 0 {
pad = 0
}
header := left + strings.Repeat(" ", pad) + right
style := lipgloss.NewStyle().
Background(lipgloss.Color("8")).
Foreground(lipgloss.Color("15")).
Width(width)
return style.Render(header) + "\n"
}
func renderEditorLines(m *EditorModel, width, height int) string {
var out strings.Builder
start, end := visibleRange(m.cursorY, height, len(m.lines))
// Ширина под номера строк
numW := 0
if m.showLineNumbers {
numW = 5 // "1234 "
}
contentW := width - numW
if contentW < 1 {
contentW = 1
}
for lineNum := start; lineNum < end; lineNum++ {
if m.showLineNumbers {
out.WriteString(colorDim.Render(fmt.Sprintf("%4d ", lineNum+1)))
}
line := m.lines[lineNum]
var rendered string
if lineNum == m.cursorY {
rendered = renderLineWithCursor(line, m.cursorX, m.fileType)
} else {
rendered = renderLine(line, m.fileType)
}
out.WriteString(truncateANSI(rendered, contentW))
out.WriteString("\n")
}
// Пустые строки (~)
for i := end - start; i < height; i++ {
out.WriteString(colorDim.Render("~") + "\n")
}
return out.String()
}
// renderEditorStatusLine — нижняя строка блока редактора (в split режимах)
func renderEditorStatusLine(m *EditorModel, width int) string {
style := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width)
msg := m.statusMsg
if msg == "" {
msg = fmt.Sprintf("Ln %d/%d Col %d", m.cursorY+1, len(m.lines), m.cursorX+1)
}
return style.Render(" " + msg)
}
// === Строки текста ===
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
}
// === Markdown colorize (построчно, для редактора) ===
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
}
// === Markdown preview (полный рендер для правой панели) ===
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
inCodeBlock := false
codeBlockLang := ""
var codeLines []string
for _, line := range lines {
if strings.HasPrefix(line, "```") {
if inCodeBlock {
out.WriteString(renderCodeBlock(codeLines, codeBlockLang))
inCodeBlock = false
codeLines = nil
codeBlockLang = ""
} else {
inCodeBlock = true
codeBlockLang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
}
continue
}
if inCodeBlock {
codeLines = append(codeLines, line)
continue
}
out.WriteString(renderMarkdownLine(line))
out.WriteString("\n")
}
if inCodeBlock && len(codeLines) > 0 {
out.WriteString(renderCodeBlock(codeLines, codeBlockLang))
}
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(`^(#+)\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)
}
}
// Blockquote
if strings.HasPrefix(line, "> ") {
return colorQuote.Render("┃ " + applyInlineMarkdown(strings.TrimPrefix(line, "> ")))
}
if line == ">" {
return colorQuote.Render("┃")
}
// Маркированные списки
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))
}
return renderTableRow(line)
}
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 renderTableRow(line string) string {
cells := strings.Split(strings.Trim(line, "|"), "|")
rendered := make([]string, len(cells))
for i, c := range cells {
rendered[i] = applyInlineMarkdown(strings.TrimSpace(c))
}
return "│ " + strings.Join(rendered, " │ ") + " │"
}
func renderCodeBlock(codeLines []string, lang string) string {
var out strings.Builder
lang = strings.ToLower(strings.TrimSpace(lang))
label := lang
if label == "" {
label = "code"
}
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true)
blockStyle := lipgloss.NewStyle().Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252"))
out.WriteString(labelStyle.Render(" ┌─ "+label) + "\n")
for _, line := range codeLines {
out.WriteString(blockStyle.Render(" │ ") + HighlightLine(line, lang) + "\n")
}
out.WriteString(labelStyle.Render(" └─") + "\n")
return out.String()
}
// === Статус бар (полноэкранный) ===
func renderStatusBar(m *EditorModel) string {
modeStr := ""
switch m.mode {
case INSERT:
modeStr = colorMode.Render("INSERT") + " "
case VISUAL:
modeStr = colorMode.Render("VISUAL") + " "
}
status := m.statusMsg
if status == "" {
status = fmt.Sprintf("Ln %d/%d Col %d", m.cursorY+1, len(m.lines), m.cursorX+1)
if len(m.undoStack) > 0 {
status += fmt.Sprintf(" u:%d", len(m.undoStack))
}
}
// Правая часть: тип файла + подсказки по панелям
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
pad := m.width - len(left) - len(right)
if pad < 0 {
pad = 0
}
bar := left + strings.Repeat(" ", pad) + lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Render(right)
return lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Foreground(lipgloss.Color("8")).
Width(m.width).
Render(bar)
}
// === Утилиты ===
// visibleRange вычисляет диапазон видимых строк
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
}
// truncateANSI обрезает строку до maxWidth видимых символов,
// игнорируя ANSI escape-последовательности
func truncateANSI(s string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
visible := 0
inEscape := false
var result strings.Builder
for i := 0; i < len(s); {
b := s[i]
if b == '\x1b' {
inEscape = true
result.WriteByte(b)
i++
continue
}
if inEscape {
result.WriteByte(b)
if b == 'm' {
inEscape = false
}
i++
continue
}
if visible >= maxWidth {
break
}
result.WriteByte(b)
visible++
i++
}
// Сбрасываем ANSI если оборвали посередине стиля
if inEscape || visible >= maxWidth {
result.WriteString("\x1b[0m")
}
return result.String()
}
func max(a, b int) int {
if a > b {
return a
}
return b
}