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

View file

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

View file

@ -19,6 +19,26 @@ var (
vwCacheMax = 4000 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 стили === // === UI стили ===
var ( var (
colorBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) colorBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
@ -460,8 +480,7 @@ func truncateANSI(s string, maxWidth int) string {
// === Markdown === // === Markdown ===
func colorizeMarkdown(line string) string { func colorizeMarkdown(line string) string {
headingRe := regexp.MustCompile(`^(#+)\s+(.*)`) if m := reMdHeadingLoose.FindStringSubmatch(line); m != nil {
if m := headingRe.FindStringSubmatch(line); m != nil {
level := len(m[1]) level := len(m[1])
text := m[2] text := m[2]
switch level { switch level {
@ -475,17 +494,17 @@ func colorizeMarkdown(line string) string {
} }
result := line 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]) 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]) 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]) return colorCode.Render(s[1 : len(s)-1])
}) })
result = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllStringFunc(result, func(s string) string { result = reMdLink.ReplaceAllStringFunc(result, func(s string) string {
m := regexp.MustCompile(`\[([^\]]+)\]`).FindStringSubmatch(s) m := reMdLinkText.FindStringSubmatch(s)
if m != nil { if m != nil {
return colorLink.Render(m[1]) return colorLink.Render(m[1])
} }
@ -540,10 +559,10 @@ func renderMarkdown(lines []string) string {
} }
func renderMarkdownLine(line 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)) 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]) level := len(m[1])
text := applyInlineMarkdown(m[2]) text := applyInlineMarkdown(m[2])
switch level { switch level {
@ -560,14 +579,14 @@ func renderMarkdownLine(line string) string {
if strings.HasPrefix(line, "> ") { if strings.HasPrefix(line, "> ") {
return colorQuote.Render("┃ " + applyInlineMarkdown(strings.TrimPrefix(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]) 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]) 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 reMdTableSep.MatchString(strings.TrimSpace(line)) {
return colorHR.Render(strings.Repeat("─", 40)) return colorHR.Render(strings.Repeat("─", 40))
} }
cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|") cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|")
@ -585,17 +604,17 @@ func renderMarkdownLine(line string) string {
func applyInlineMarkdown(text string) string { func applyInlineMarkdown(text string) string {
result := text 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]) 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]) 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]) return colorCode.Render(s[1 : len(s)-1])
}) })
result = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllStringFunc(result, func(s string) string { result = reMdLink.ReplaceAllStringFunc(result, func(s string) string {
m := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).FindStringSubmatch(s) m := reMdLink.FindStringSubmatch(s)
if m != nil { if m != nil {
return colorLink.Render(m[1]) + lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(" ("+m[2]+")") 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) tokens := tokenizeWithPatterns(line, cfg, patterns)
result := renderTokens(tokens, cfg) result := renderTokens(tokens, cfg)
// Сохраняем в кеш // Сохраняем в кеш. Раньше при достижении highlightCacheMax кеш просто
// переставал принимать новые записи навсегда (пока кто-то не вызовет
// InvalidateHighlightCache вручную) — в паре с прошлым поведением, когда
// кеш сбрасывался целиком на каждый keystroke, это было не страшно, кеш
// никогда не успевал заполниться. Теперь кеш живёт долго, поэтому при
// заполнении просто освобождаем место под свежие строки.
highlightCacheMu.Lock() highlightCacheMu.Lock()
if len(highlightCache) < highlightCacheMax { if len(highlightCache) >= highlightCacheMax {
highlightCache[key] = result highlightCache = map[highlightCacheKey]string{}
} }
highlightCache[key] = result
highlightCacheMu.Unlock() highlightCacheMu.Unlock()
return result return result
} }
// InvalidateHighlightCache сбрасывает кеш подсветки (вызывается при изменении файла) // InvalidateHighlightCache сбрасывает кеш подсветки целиком. Кеш ключуется
// по (содержимое строки, тип файла), поэтому корректность не требует сброса
// на каждое изменение файла — неизменившиеся строки остаются валидными сами
// по себе. Эта функция оставлена как ручной сброс на случай, если появится
// сценарий, где это реально нужно (например смена цветовой темы на лету).
func InvalidateHighlightCache() { func InvalidateHighlightCache() {
highlightCacheMu.Lock() highlightCacheMu.Lock()
highlightCache = map[highlightCacheKey]string{} highlightCache = map[highlightCacheKey]string{}