719 lines
20 KiB
Go
719 lines
20 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)
|
||
)
|
||
|
||
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
|
||
}
|