Fixed render
This commit is contained in:
parent
68b3d1834a
commit
5db905a12a
4 changed files with 85 additions and 29 deletions
|
|
@ -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()
|
||||
|
|
|
|||
26
model.go
26
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")
|
||||
}
|
||||
53
render.go
53
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]+")")
|
||||
}
|
||||
|
|
|
|||
18
syntax.go
18
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{}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue