515 lines
15 KiB
Go
515 lines
15 KiB
Go
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.2"
|
||
|
||
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)))
|
||
out.WriteString("\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)
|
||
}
|
||
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-последовательности и многобайтные Unicode символы.
|
||
// Всегда добавляет \x1b[0m в конце чтобы стили не вытекали в следующую строку.
|
||
func truncateANSI(s string, maxWidth int) string {
|
||
if maxWidth <= 0 {
|
||
return "\x1b[0m"
|
||
}
|
||
|
||
visible := 0
|
||
inEscape := false
|
||
var result strings.Builder
|
||
|
||
// Итерируем по рунам чтобы корректно считать видимые символы
|
||
i := 0
|
||
runes := []rune(s)
|
||
for i < len(runes) {
|
||
r := runes[i]
|
||
|
||
// Начало ANSI escape-последовательности
|
||
if r == '\x1b' {
|
||
inEscape = true
|
||
result.WriteRune(r)
|
||
i++
|
||
continue
|
||
}
|
||
|
||
// Внутри escape-последовательности
|
||
if inEscape {
|
||
result.WriteRune(r)
|
||
if r == 'm' {
|
||
inEscape = false
|
||
}
|
||
i++
|
||
continue
|
||
}
|
||
|
||
// Достигли максимальной ширины — прекращаем добавлять видимые символы
|
||
if visible >= maxWidth {
|
||
break
|
||
}
|
||
|
||
result.WriteRune(r)
|
||
visible++
|
||
i++
|
||
}
|
||
|
||
// Всегда сбрасываем стиль в конце строки
|
||
result.WriteString("\x1b[0m")
|
||
return result.String()
|
||
}
|
||
|
||
func max(a, b int) int {
|
||
if a > b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|