This commit is contained in:
Magnus Root 2026-05-07 14:57:14 +03:00
parent eaa61eeb84
commit 1ca37c4a47
3 changed files with 465 additions and 281 deletions

View file

@ -30,11 +30,11 @@ type FileNode struct {
// FileTree — состояние панели файлового дерева // FileTree — состояние панели файлового дерева
type FileTree struct { type FileTree struct {
root string // корневая директория root string // корневая директория
nodes []*FileNode // плоский список видимых узлов nodes []*FileNode // плоский список видимых узлов
cursor int // индекс выбранного узла cursor int // индекс выбранного узла
scroll int // смещение прокрутки scroll int // смещение прокрутки
focused bool // фокус на этой панели focused bool // фокус на этой панели
} }
// NewFileTree создаёт новое дерево для директории rootDir // NewFileTree создаёт новое дерево для директории rootDir
@ -216,13 +216,11 @@ func renderFileTree(m *EditorModel) string {
line := renderFileNode(node, m.filepath, width) line := renderFileNode(node, m.filepath, width)
if i == ft.cursor && ft.focused { if i == ft.cursor && ft.focused {
line = styleTreeSelected.Width(width).Render(line) line = styleTreeSelected.Render(line)
} else if i == ft.cursor { } else if i == ft.cursor {
// Курсор без фокуса — подчёркиваем текущий элемент line = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Render(line)
line = lipgloss.NewStyle().Width(width).Foreground(lipgloss.Color("245")).Render(line)
} else {
line = lipgloss.NewStyle().Width(width).Render(line)
} }
// plain строки — без Width() рендера, joinColumns дополнит пробелами
output.WriteString(line) output.WriteString(line)
output.WriteString("\n") output.WriteString("\n")
@ -231,7 +229,7 @@ func renderFileTree(m *EditorModel) string {
// Заполнить пустые строки // Заполнить пустые строки
rendered := min(height, max(0, len(ft.nodes)-ft.scroll)) rendered := min(height, max(0, len(ft.nodes)-ft.scroll))
for i := rendered; i < height; i++ { for i := rendered; i < height; i++ {
output.WriteString(strings.Repeat(" ", width) + "\n") output.WriteString("\n")
} }
// Нижняя строка с подсказкой // Нижняя строка с подсказкой

20
help.go
View file

@ -254,22 +254,4 @@ func renderHelp(m *EditorModel) string {
return out.String() return out.String()
} }
// visibleLen возвращает видимую длину строки (без ANSI кодов) // visibleLen определена в render.go
func visibleLen(s string) int {
visible := 0
inEscape := false
for _, b := range s {
if b == '\x1b' {
inEscape = true
continue
}
if inEscape {
if b == 'm' {
inEscape = false
}
continue
}
visible++
}
return visible
}

706
render.go
View file

@ -22,124 +22,55 @@ var (
colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
) )
// === Компоновки экрана === const grimVersion = "grimoir v1.0"
// === Главная точка рендера ===
// renderEditor — только редактор, на весь экран
func renderEditor(m *EditorModel) string { func renderEditor(m *EditorModel) string {
var out strings.Builder rows := buildEditorRows(m, m.width, m.height)
w := m.width return rowsToString(rows)
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 { func renderSplit(m *EditorModel) string {
edW := m.editorWidth() edW := m.editorWidth()
prW := m.previewWidth prW := m.previewWidth
return lipgloss.JoinHorizontal(lipgloss.Top, edRows := buildEditorRows(m, edW, m.height)
renderEditorBlock(m, edW), prRows := buildPreviewRows(m, prW, m.height)
renderPreviewBlock(m, prW), return joinRowColumns([][]string{edRows, prRows}, []int{edW, prW}, m.height)
)
} }
// renderTreeEditor — дерево + редактор
func renderTreeEditor(m *EditorModel) string { func renderTreeEditor(m *EditorModel) string {
edW := m.editorWidth() edW := m.editorWidth()
return lipgloss.JoinHorizontal(lipgloss.Top, trRows := buildTreeRows(m, m.treeWidth, m.height)
renderFileTree(m), edRows := buildEditorRows(m, edW, m.height)
renderEditorBlock(m, edW), return joinRowColumns([][]string{trRows, edRows}, []int{m.treeWidth, edW}, m.height)
)
} }
// renderTreeEditorPreview — дерево + редактор + preview
func renderTreeEditorPreview(m *EditorModel) string { func renderTreeEditorPreview(m *EditorModel) string {
edW := m.editorWidth() edW := m.editorWidth()
prW := m.previewWidth prW := m.previewWidth
return lipgloss.JoinHorizontal(lipgloss.Top, trRows := buildTreeRows(m, m.treeWidth, m.height)
renderFileTree(m), edRows := buildEditorRows(m, edW, m.height)
renderEditorBlock(m, edW), prRows := buildPreviewRows(m, prW, m.height)
renderPreviewBlock(m, prW), return joinRowColumns([][]string{trRows, edRows, prRows}, []int{m.treeWidth, edW, prW}, m.height)
)
} }
// === Блоки === // === Построители строк (возвращают []string, по одной на строку экрана) ===
// renderEditorBlock рендерит редактор заданной ширины (для split/tree компоновок) // buildEditorRows строит строки редактора. Каждый элемент — одна строка без \n.
func renderEditorBlock(m *EditorModel, width int) string { func buildEditorRows(m *EditorModel, width, height int) []string {
var out strings.Builder rows := make([]string, 0, height)
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 { rows = append(rows, buildHeader(m, width))
var out strings.Builder
height := m.height - 3
headerStyle := lipgloss.NewStyle(). // Строки текста
Background(lipgloss.Color("8")). edHeight := height - 2 // заголовок + статус
Foreground(lipgloss.Color("15")). start, end := visibleRange(m.cursorY, edHeight, len(m.lines))
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 numW := 0
if m.showLineNumbers { if m.showLineNumbers {
numW = 5 // "1234 " numW = 5
} }
contentW := width - numW contentW := width - numW
if contentW < 1 { if contentW < 1 {
@ -147,9 +78,11 @@ func renderEditorLines(m *EditorModel, width, height int) string {
} }
for lineNum := start; lineNum < end; lineNum++ { for lineNum := start; lineNum < end; lineNum++ {
var sb strings.Builder
if m.showLineNumbers { if m.showLineNumbers {
out.WriteString(colorDim.Render(fmt.Sprintf("%4d ", lineNum+1))) // Номер строки без ANSI-заливки ширины — просто форматируем текст
out.WriteString("\x1b[0m") // сброс после номера строки num := fmt.Sprintf("%4d ", lineNum+1)
sb.WriteString("\x1b[38;5;8m" + num + "\x1b[0m")
} }
line := m.lines[lineNum] line := m.lines[lineNum]
var rendered string var rendered string
@ -158,28 +91,207 @@ func renderEditorLines(m *EditorModel, width, height int) string {
} else { } else {
rendered = renderLine(line, m.fileType) rendered = renderLine(line, m.fileType)
} }
out.WriteString(truncateANSI(rendered, contentW)) // Обрезаем по ширине контента
out.WriteString("\n") sb.WriteString(truncateVisible(rendered, contentW))
rows = append(rows, sb.String())
} }
// Пустые строки (~) // Пустые строки ~
for i := end - start; i < height; i++ { for i := end - start; i < edHeight; i++ {
out.WriteString(colorDim.Render("~") + "\n") 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() return out.String()
} }
// renderEditorStatusLine — нижняя строка блока редактора (в split режимах) func rowsToString(rows []string) string {
func renderEditorStatusLine(m *EditorModel, width int) string { return strings.Join(rows, "\n") + "\n"
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 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 { func renderLine(line, fileType string) string {
if fileType == "md" { if fileType == "md" {
@ -190,7 +302,6 @@ func renderLine(line, fileType string) string {
func renderLineWithCursor(line string, cursorX int, fileType string) string { func renderLineWithCursor(line string, cursorX int, fileType string) string {
runes := []rune(line) runes := []rune(line)
if cursorX < 0 { if cursorX < 0 {
cursorX = 0 cursorX = 0
} }
@ -217,7 +328,211 @@ func renderLineWithCursor(line string, cursorX int, fileType string) string {
return before + colorCursor.Render(cursorChar) + after return before + colorCursor.Render(cursorChar) + after
} }
// === Markdown colorize (построчно, для редактора) === // === 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 { func colorizeMarkdown(line string) string {
headingRe := regexp.MustCompile(`^(#+)\s+(.*)`) headingRe := regexp.MustCompile(`^(#+)\s+(.*)`)
@ -254,8 +569,6 @@ func colorizeMarkdown(line string) string {
return result return result
} }
// === Markdown preview (полный рендер для правой панели) ===
func (m *EditorModel) updatePreview() { func (m *EditorModel) updatePreview() {
if m.fileType != "md" { if m.fileType != "md" {
m.previewBuf = "" m.previewBuf = ""
@ -266,44 +579,41 @@ func (m *EditorModel) updatePreview() {
func renderMarkdown(lines []string) string { func renderMarkdown(lines []string) string {
var out strings.Builder var out strings.Builder
inCodeBlock := false inCode := false
codeBlockLang := "" lang := ""
var codeLines []string var codeLines []string
for _, line := range lines { for _, line := range lines {
if strings.HasPrefix(line, "```") { if strings.HasPrefix(line, "```") {
if inCodeBlock { if inCode {
out.WriteString(renderCodeBlock(codeLines, codeBlockLang)) out.WriteString(renderCodeBlock(codeLines, lang))
inCodeBlock = false inCode = false
codeLines = nil codeLines = nil
codeBlockLang = "" lang = ""
} else { } else {
inCodeBlock = true inCode = true
codeBlockLang = strings.TrimSpace(strings.TrimPrefix(line, "```")) lang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
} }
continue continue
} }
if inCodeBlock { if inCode {
codeLines = append(codeLines, line) codeLines = append(codeLines, line)
continue continue
} }
out.WriteString(renderMarkdownLine(line)) out.WriteString(renderMarkdownLine(line))
out.WriteString("\n") out.WriteString("\n")
} }
if inCodeBlock && len(codeLines) > 0 { if inCode && len(codeLines) > 0 {
out.WriteString(renderCodeBlock(codeLines, codeBlockLang)) out.WriteString(renderCodeBlock(codeLines, lang))
} }
return out.String() return out.String()
} }
func renderMarkdownLine(line string) string { func renderMarkdownLine(line string) string {
// Горизонтальная линия
if regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})$`).MatchString(strings.TrimSpace(line)) { if regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})$`).MatchString(strings.TrimSpace(line)) {
return colorHR.Render(strings.Repeat("─", 60)) return colorHR.Render(strings.Repeat("─", 60))
} }
if m := regexp.MustCompile(`^(#{1,4})\s+(.+)`).FindStringSubmatch(line); m != nil {
// Заголовки
if m := regexp.MustCompile(`^(#+)\s+(.+)`).FindStringSubmatch(line); m != nil {
level := len(m[1]) level := len(m[1])
text := applyInlineMarkdown(m[2]) text := applyInlineMarkdown(m[2])
switch level { switch level {
@ -317,33 +627,26 @@ func renderMarkdownLine(line string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("51")).Render(text) return lipgloss.NewStyle().Foreground(lipgloss.Color("51")).Render(text)
} }
} }
// Blockquote
if strings.HasPrefix(line, "> ") { if strings.HasPrefix(line, "> ") {
return colorQuote.Render("┃ " + applyInlineMarkdown(strings.TrimPrefix(line, "> "))) return colorQuote.Render("┃ " + applyInlineMarkdown(strings.TrimPrefix(line, "> ")))
} }
if line == ">" {
return colorQuote.Render("┃")
}
// Маркированные списки
if m := regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`).FindStringSubmatch(line); m != nil { if m := regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`).FindStringSubmatch(line); m != nil {
return m[1] + colorListBul.Render("•") + " " + applyInlineMarkdown(m[3]) return m[1] + colorListBul.Render("•") + " " + applyInlineMarkdown(m[3])
} }
// Нумерованные списки
if m := regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`).FindStringSubmatch(line); m != nil { if m := regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`).FindStringSubmatch(line); m != nil {
return m[1] + colorListBul.Render(m[2]+".") + " " + applyInlineMarkdown(m[3]) return m[1] + colorListBul.Render(m[2]+".") + " " + applyInlineMarkdown(m[3])
} }
// Таблицы
if strings.Contains(line, "|") && strings.HasPrefix(strings.TrimSpace(line), "|") { if strings.Contains(line, "|") && strings.HasPrefix(strings.TrimSpace(line), "|") {
if regexp.MustCompile(`^\|[\s\-:|]+\|`).MatchString(strings.TrimSpace(line)) { if regexp.MustCompile(`^\|[\s\-:|]+\|`).MatchString(strings.TrimSpace(line)) {
return colorHR.Render(strings.Repeat("─", 40)) return colorHR.Render(strings.Repeat("─", 40))
} }
return renderTableRow(line) 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) == "" { if strings.TrimSpace(line) == "" {
return "" return ""
} }
@ -371,15 +674,6 @@ func applyInlineMarkdown(text string) string {
return result 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 { func renderCodeBlock(codeLines []string, lang string) string {
var out strings.Builder var out strings.Builder
lang = strings.ToLower(strings.TrimSpace(lang)) lang = strings.ToLower(strings.TrimSpace(lang))
@ -387,63 +681,20 @@ func renderCodeBlock(codeLines []string, lang string) string {
if label == "" { if label == "" {
label = "code" label = "code"
} }
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) out.WriteString(colorDim.Render(" ┌─ " + label))
blockStyle := lipgloss.NewStyle().Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252")) out.WriteString("\n")
out.WriteString(labelStyle.Render(" ┌─ "+label) + "\n")
for _, line := range codeLines { for _, line := range codeLines {
out.WriteString(blockStyle.Render(" │ ") + HighlightLine(line, lang) + "\n") out.WriteString(colorDim.Render(" │ "))
out.WriteString(HighlightLine(line, lang))
out.WriteString("\n")
} }
out.WriteString(labelStyle.Render(" └─") + "\n") out.WriteString(colorDim.Render(" └─"))
out.WriteString("\n")
return out.String() 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) { func visibleRange(cursorY, height, totalLines int) (start, end int) {
start = 0 start = 0
if cursorY > height/2 { if cursorY > height/2 {
@ -456,55 +707,8 @@ func visibleRange(cursorY, height, totalLines int) (start, end int) {
return return
} }
// truncateANSI обрезает строку до maxWidth видимых символов, func renderStatusBar(m *EditorModel) string {
// корректно обрабатывая ANSI escape-последовательности и многобайтные Unicode символы. return buildStatusBar(m, m.width)
// Всегда добавляет \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 { func max(a, b int) int {