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 ) // === 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 { 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 } // Пересчитываем только если контент изменился 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 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 }