diff --git a/keybindings.go b/keybindings.go index fb73f0c..fab2d7d 100755 --- a/keybindings.go +++ b/keybindings.go @@ -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() diff --git a/model.go b/model.go index 374ff6e..25ca5f0 100755 --- a/model.go +++ b/model.go @@ -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") } \ No newline at end of file diff --git a/render.go b/render.go index aa3cd51..2433693 100755 --- a/render.go +++ b/render.go @@ -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]+")") } diff --git a/syntax.go b/syntax.go index 862d714..249ed93 100755 --- a/syntax.go +++ b/syntax.go @@ -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{}