Fixed render

This commit is contained in:
Magnus Root 2026-07-13 09:23:01 +03:00
parent 68b3d1834a
commit 5db905a12a
4 changed files with 85 additions and 29 deletions

View file

@ -225,9 +225,11 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
switch key {
case "i":
m.mode = INSERT
m.insertUndoTaken = false
m.SetStatusMsg("")
case "a":
m.mode = INSERT
m.insertUndoTaken = false
line := []rune(m.lines[m.cursorY])
if m.cursorX < len(line) {
m.cursorX++
@ -235,10 +237,12 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
m.SetStatusMsg("")
case "A":
m.mode = INSERT
m.insertUndoTaken = false
m.cursorX = len([]rune(m.lines[m.cursorY]))
m.SetStatusMsg("")
case "I":
m.mode = INSERT
m.insertUndoTaken = false
m.cursorX = 0
m.SetStatusMsg("")
case "o":
@ -247,6 +251,7 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
m.cursorY++
m.cursorX = 0
m.mode = INSERT
m.insertUndoTaken = true
m.MarkDirty()
m.SetStatusMsg("")
case "O":
@ -254,6 +259,7 @@ func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
m.insertLine(m.cursorY)
m.cursorX = 0
m.mode = INSERT
m.insertUndoTaken = true
m.MarkDirty()
m.SetStatusMsg("")
default:
@ -632,6 +638,7 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key {
case "esc":
m.mode = NORMAL
m.insertUndoTaken = false
line := []rune(m.lines[m.cursorY])
if m.cursorX > 0 && m.cursorX >= len(line) {
m.cursorX = len(line) - 1
@ -664,13 +671,13 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "end":
m.cursorX = len([]rune(m.lines[m.cursorY]))
case "enter":
m.saveUndoSnapshot()
m.saveInsertUndoSnapshot()
m.splitLineRune(m.cursorY, m.cursorX)
m.cursorY++
m.cursorX = 0
m.MarkDirty()
case "backspace":
m.saveUndoSnapshot()
m.saveInsertUndoSnapshot()
if m.cursorX > 0 {
m.deleteCharAt(m.cursorY, m.cursorX-1)
m.cursorX--
@ -684,7 +691,7 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.MarkDirty()
}
case "delete":
m.saveUndoSnapshot()
m.saveInsertUndoSnapshot()
line := []rune(m.lines[m.cursorY])
if m.cursorX < len(line) {
m.deleteCharAt(m.cursorY, m.cursorX)
@ -695,7 +702,7 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.MarkDirty()
}
case "tab":
m.saveUndoSnapshot()
m.saveInsertUndoSnapshot()
m.insertRuneAt(m.cursorY, m.cursorX, []rune(strings.Repeat(" ", m.tabWidth)))
m.cursorX += m.tabWidth
m.MarkDirty()
@ -703,7 +710,7 @@ func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
runes := []rune(msg.String())
if len(runes) == 1 && runes[0] >= 32 &&
(msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace) {
m.saveUndoSnapshot()
m.saveInsertUndoSnapshot()
m.insertRuneAt(m.cursorY, m.cursorX, runes)
m.cursorX++
m.MarkDirty()

View file

@ -90,6 +90,12 @@ type EditorModel struct {
// Undo/Redo
undoStack []undoSnapshot
redoStack []undoSnapshot
// insertUndoTaken — снимок для undo уже сделан в текущей INSERT-сессии.
// Без этого saveUndoSnapshot() копировал весь []string на каждый
// введённый символ. Реальный vim тоже группирует набор текста между
// входом в INSERT и Esc в один undo-шаг — так что это одновременно
// и оптимизация, и исправление поведения под ожидаемое от vim.
insertUndoTaken bool
// Help экран
showHelp bool
@ -277,6 +283,7 @@ func (m *EditorModel) openFile(path string) {
m.dirty = false
m.undoStack = nil
m.redoStack = nil
m.insertUndoTaken = false
m.searchMatches = nil
m.numPrefix = ""
m.pendingKeys = ""
@ -296,7 +303,11 @@ func (m *EditorModel) SetStatusMsg(msg string) { m.statusMsg = msg }
func (m *EditorModel) MarkDirty() {
m.dirty = true
m.previewDirty = true
InvalidateHighlightCache()
// Кеш подсветки НЕ сбрасываем: ключ кеша уже включает содержимое строки
// (highlightCacheKey{line, fileType}), так что неизменившиеся строки
// остаются валидными в кеше сами по себе. Полный сброс на каждый
// keystroke означал, что все видимые строки перевычислялись заново
// после ввода одного символа — реальный источник лагов на больших файлах.
}
func (m *EditorModel) CursorWithinBounds() {
@ -348,6 +359,17 @@ func (m *EditorModel) saveUndoSnapshot() {
m.redoStack = nil
}
// saveInsertUndoSnapshot берёт снимок для undo не чаще одного раза за
// INSERT-сессию (от входа в режим до Esc), а не на каждый введённый символ.
// Так делает и настоящий vim: набор текста целиком отменяется одним `u`.
func (m *EditorModel) saveInsertUndoSnapshot() {
if m.insertUndoTaken {
return
}
m.saveUndoSnapshot()
m.insertUndoTaken = true
}
func (m *EditorModel) Undo() {
if len(m.undoStack) == 0 {
m.SetStatusMsg("Already at oldest change")
@ -369,7 +391,6 @@ func (m *EditorModel) Undo() {
m.CursorWithinBounds()
m.dirty = true
m.previewDirty = true
InvalidateHighlightCache()
m.SetStatusMsg("Undo")
}
@ -395,6 +416,5 @@ func (m *EditorModel) Redo() {
m.CursorWithinBounds()
m.dirty = true
m.previewDirty = true
InvalidateHighlightCache()
m.SetStatusMsg("Redo")
}

View file

@ -19,6 +19,26 @@ var (
vwCacheMax = 4000
)
// === Скомпилированные regex для markdown ===
// Компилируются один раз при старте программы вместо повторной компиляции
// на каждый вызов рендера (который происходит на каждый keystroke при
// открытом live preview). regexp.Compile — это не просто аллокация, а
// разбор паттерна в NFA, повторение этого на каждую строку документа на
// каждое нажатие клавиши создавало заметную нагрузку на больших файлах.
var (
reMdHeadingLoose = regexp.MustCompile(`^(#+)\s+(.*)`)
reMdBold = regexp.MustCompile(`\*\*([^*]+)\*\*`)
reMdItalic = regexp.MustCompile(`\*([^*]+)\*`)
reMdInlineCode = regexp.MustCompile("`([^`]+)`")
reMdLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
reMdLinkText = regexp.MustCompile(`\[([^\]]+)\]`)
reMdHR = regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})$`)
reMdHeadingStrict = regexp.MustCompile(`^(#{1,4})\s+(.+)`)
reMdBullet = regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`)
reMdOrdered = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`)
reMdTableSep = regexp.MustCompile(`^\|[\s\-:|]+\|`)
)
// === UI стили ===
var (
colorBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
@ -460,8 +480,7 @@ func truncateANSI(s string, maxWidth int) string {
// === Markdown ===
func colorizeMarkdown(line string) string {
headingRe := regexp.MustCompile(`^(#+)\s+(.*)`)
if m := headingRe.FindStringSubmatch(line); m != nil {
if m := reMdHeadingLoose.FindStringSubmatch(line); m != nil {
level := len(m[1])
text := m[2]
switch level {
@ -475,17 +494,17 @@ func colorizeMarkdown(line string) string {
}
result := line
result = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllStringFunc(result, func(s string) string {
result = reMdBold.ReplaceAllStringFunc(result, func(s string) string {
return colorBold.Render(s[2 : len(s)-2])
})
result = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllStringFunc(result, func(s string) string {
result = reMdItalic.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 {
result = reMdInlineCode.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)
result = reMdLink.ReplaceAllStringFunc(result, func(s string) string {
m := reMdLinkText.FindStringSubmatch(s)
if m != nil {
return colorLink.Render(m[1])
}
@ -540,10 +559,10 @@ func renderMarkdown(lines []string) string {
}
func renderMarkdownLine(line string) string {
if regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})$`).MatchString(strings.TrimSpace(line)) {
if reMdHR.MatchString(strings.TrimSpace(line)) {
return colorHR.Render(strings.Repeat("─", 60))
}
if m := regexp.MustCompile(`^(#{1,4})\s+(.+)`).FindStringSubmatch(line); m != nil {
if m := reMdHeadingStrict.FindStringSubmatch(line); m != nil {
level := len(m[1])
text := applyInlineMarkdown(m[2])
switch level {
@ -560,14 +579,14 @@ func renderMarkdownLine(line string) string {
if strings.HasPrefix(line, "> ") {
return colorQuote.Render("┃ " + applyInlineMarkdown(strings.TrimPrefix(line, "> ")))
}
if m := regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`).FindStringSubmatch(line); m != nil {
if m := reMdBullet.FindStringSubmatch(line); m != nil {
return m[1] + colorListBul.Render("•") + " " + applyInlineMarkdown(m[3])
}
if m := regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`).FindStringSubmatch(line); m != nil {
if m := reMdOrdered.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)) {
if reMdTableSep.MatchString(strings.TrimSpace(line)) {
return colorHR.Render(strings.Repeat("─", 40))
}
cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|")
@ -585,17 +604,17 @@ func renderMarkdownLine(line string) string {
func applyInlineMarkdown(text string) string {
result := text
result = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllStringFunc(result, func(s string) string {
result = reMdBold.ReplaceAllStringFunc(result, func(s string) string {
return colorBold.Render(s[2 : len(s)-2])
})
result = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllStringFunc(result, func(s string) string {
result = reMdItalic.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 {
result = reMdInlineCode.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)
result = reMdLink.ReplaceAllStringFunc(result, func(s string) string {
m := reMdLink.FindStringSubmatch(s)
if m != nil {
return colorLink.Render(m[1]) + lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(" ("+m[2]+")")
}

View file

@ -193,17 +193,27 @@ func HighlightLine(line, fileType string) string {
tokens := tokenizeWithPatterns(line, cfg, patterns)
result := renderTokens(tokens, cfg)
// Сохраняем в кеш
// Сохраняем в кеш. Раньше при достижении highlightCacheMax кеш просто
// переставал принимать новые записи навсегда (пока кто-то не вызовет
// InvalidateHighlightCache вручную) — в паре с прошлым поведением, когда
// кеш сбрасывался целиком на каждый keystroke, это было не страшно, кеш
// никогда не успевал заполниться. Теперь кеш живёт долго, поэтому при
// заполнении просто освобождаем место под свежие строки.
highlightCacheMu.Lock()
if len(highlightCache) < highlightCacheMax {
highlightCache[key] = result
if len(highlightCache) >= highlightCacheMax {
highlightCache = map[highlightCacheKey]string{}
}
highlightCache[key] = result
highlightCacheMu.Unlock()
return result
}
// InvalidateHighlightCache сбрасывает кеш подсветки (вызывается при изменении файла)
// InvalidateHighlightCache сбрасывает кеш подсветки целиком. Кеш ключуется
// по (содержимое строки, тип файла), поэтому корректность не требует сброса
// на каждое изменение файла — неизменившиеся строки остаются валидными сами
// по себе. Эта функция оставлена как ручной сброс на случай, если появится
// сценарий, где это реально нужно (например смена цветовой темы на лету).
func InvalidateHighlightCache() {
highlightCacheMu.Lock()
highlightCache = map[highlightCacheKey]string{}