grimoir/model.go
2026-07-13 09:23:01 +03:00

420 lines
No EOL
10 KiB
Go
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
type VimMode int
const (
NORMAL VimMode = iota
INSERT
VISUAL
COMMAND
SEARCH
REPLACE
)
// undoSnapshot — снимок состояния для undo/redo
type undoSnapshot struct {
lines []string
cursorX int
cursorY int
}
// Минимальная ширина панели preview
const minPreviewWidth = 20
type EditorModel struct {
// Контент
lines []string
cursorX int
cursorY int
// Vim режимы
mode VimMode
pendingKeys string
visualStartY int // строка где начато VISUAL LINE выделение
// Файл
filepath string
fileType string
dirty bool
lastSave time.Time
statusMsg string
// UI
width int
height int
previewWidth int
// Видимость панелей
showPreview bool
// Настройки
showLineNumbers bool
tabWidth int
autosaveInterval int
cfgShowPreview bool
// Viewport
viewport viewport.Model
previewBuf string
previewDirty bool
// Команда
commandBuffer string
// Поиск и замена
searchBuffer string
searchMatches []struct {
line, col int
}
searchIdx int
lastSearchTerm string
replaceBuffer string
inReplace bool
// Расширенная навигация
lastFindChar rune
lastFindIsForward bool
numPrefix string
// Clipboard
clipboard string
// Undo/Redo
undoStack []undoSnapshot
redoStack []undoSnapshot
// insertUndoTaken — снимок для undo уже сделан в текущей INSERT-сессии.
// Без этого saveUndoSnapshot() копировал весь []string на каждый
// введённый символ. Реальный vim тоже группирует набор текста между
// входом в INSERT и Esc в один undo-шаг — так что это одновременно
// и оптимизация, и исправление поведения под ожидаемое от vim.
insertUndoTaken bool
// Help экран
showHelp bool
// Пасхалка
showTzeentch bool
tzeentchIdx int
// Конфиг
config Config
}
// Типы событий
type TickMsg time.Time
// NewEditor создаёт новый редактор
func NewEditor(path, fileType, content string, cfg Config) *EditorModel {
lines := strings.Split(content, "\n")
if len(lines) == 0 {
lines = []string{""}
}
m := &EditorModel{
lines: lines,
filepath: path,
fileType: fileType,
dirty: false,
mode: NORMAL,
cursorX: 0,
cursorY: 0,
showLineNumbers: cfg.LineNumbers,
tabWidth: cfg.TabWidth,
autosaveInterval: cfg.AutosaveInterval,
cfgShowPreview: cfg.ShowPreview,
showPreview: cfg.ShowPreview && fileType == "md",
lastSave: time.Now(),
viewport: viewport.New(0, 0),
width: 120,
height: 30,
previewWidth: cfg.PreviewWidth,
previewDirty: true,
config: cfg,
}
m.clampPanelWidths()
m.updatePreview()
return m
}
func (m *EditorModel) Init() tea.Cmd {
return m.tickCmd()
}
func (m *EditorModel) tickCmd() tea.Cmd {
interval := time.Duration(m.autosaveInterval) * time.Second
if interval <= 0 {
interval = 60 * time.Second
}
return tea.Tick(interval, func(t time.Time) tea.Msg {
return TickMsg(t)
})
}
func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.clampPanelWidths()
m.viewport.Width = m.editorWidth()
m.viewport.Height = m.height - 3
return m, nil
case tea.KeyMsg:
// Bubble Tea использует scroll-region оптимизацию терминала при
// построчном сдвиге видимой области текста. На некоторых терминалах
// и мультиплексорах (замечено в Zellij) это приводит к артефактам:
// при прокрутке вверх старая строка остаётся видна поверх новой,
// потому что терминал "доскроллил" контент вместо полной перерисовки.
// Решение: сравниваем видимый диапазон строк до и после обработки
// клавиши, и если он сдвинулся — форсируем tea.ClearScreen, что
// гарантирует полную перерисовку без scroll-оптимизаций.
edHeight := m.height - 3
if edHeight < 1 {
edHeight = 1
}
prevStart, _ := visibleRange(m.cursorY, edHeight, len(m.lines))
newModel, cmd := m.handleKey(msg)
em, ok := newModel.(*EditorModel)
if !ok {
return newModel, cmd
}
newStart, _ := visibleRange(em.cursorY, edHeight, len(em.lines))
if newStart != prevStart {
return em, tea.Batch(cmd, tea.ClearScreen)
}
return em, cmd
case TickMsg:
if m.autosaveInterval > 0 &&
time.Since(m.lastSave) >= time.Duration(m.autosaveInterval)*time.Second &&
m.dirty {
m.save() //nolint:errcheck
}
return m, m.tickCmd()
}
return m, nil
}
func (m *EditorModel) View() string {
if m.showTzeentch {
return renderTzeentch(m)
}
if m.showHelp {
return renderHelp(m)
}
if m.showPreview && m.fileType == "md" {
return renderSplit(m)
}
return renderEditor(m)
}
// === Ширины панелей ===
func (m *EditorModel) editorWidth() int {
w := m.width
if m.showPreview && m.fileType == "md" {
w -= m.previewWidth
}
if w < 20 {
w = 20
}
return w
}
func (m *EditorModel) clampPanelWidths() {
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
if m.showPreview && m.fileType == "md" {
needed := 20 + m.previewWidth
if needed > m.width && m.width > 0 {
m.previewWidth = m.width - 20
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
}
}
}
func (m *EditorModel) resizePreview(delta int) {
m.previewWidth += delta
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
max := m.width - 20
if m.previewWidth > max {
m.previewWidth = max
}
}
// === Открытие файла ===
func (m *EditorModel) openFile(path string) {
content, err := LoadFile(path)
if err != nil {
m.SetStatusMsg("Error opening: " + err.Error())
return
}
fileType := DetectFileType(path)
lines := strings.Split(content, "\n")
if len(lines) == 0 {
lines = []string{""}
}
m.filepath = path
m.fileType = fileType
m.lines = lines
m.cursorX = 0
m.cursorY = 0
m.dirty = false
m.undoStack = nil
m.redoStack = nil
m.insertUndoTaken = false
m.searchMatches = nil
m.numPrefix = ""
m.pendingKeys = ""
m.showPreview = fileType == "md" && m.cfgShowPreview
m.previewDirty = true
m.mode = NORMAL
m.clampPanelWidths()
m.SetStatusMsg("Opened: " + path)
m.updatePreview()
}
// === Вспомогательные методы ===
func (m *EditorModel) IsInsertMode() bool { return m.mode == INSERT }
func (m *EditorModel) SetStatusMsg(msg string) { m.statusMsg = msg }
func (m *EditorModel) MarkDirty() {
m.dirty = true
m.previewDirty = true
// Кеш подсветки НЕ сбрасываем: ключ кеша уже включает содержимое строки
// (highlightCacheKey{line, fileType}), так что неизменившиеся строки
// остаются валидными в кеше сами по себе. Полный сброс на каждый
// keystroke означал, что все видимые строки перевычислялись заново
// после ввода одного символа — реальный источник лагов на больших файлах.
}
func (m *EditorModel) CursorWithinBounds() {
if m.cursorY < 0 {
m.cursorY = 0
}
if m.cursorY >= len(m.lines) {
m.cursorY = len(m.lines) - 1
}
maxX := len([]rune(m.lines[m.cursorY]))
if m.mode != INSERT && maxX > 0 {
maxX--
}
if m.cursorX > maxX {
m.cursorX = maxX
}
if m.cursorX < 0 {
m.cursorX = 0
}
}
// === Undo / Redo ===
func (m *EditorModel) undoLimit() int {
n := len(m.lines)
switch {
case n > 50000:
return 5
case n > 5000:
return 20
default:
return 100
}
}
func (m *EditorModel) saveUndoSnapshot() {
snap := undoSnapshot{
lines: make([]string, len(m.lines)),
cursorX: m.cursorX,
cursorY: m.cursorY,
}
copy(snap.lines, m.lines)
limit := m.undoLimit()
m.undoStack = append(m.undoStack, snap)
if len(m.undoStack) > limit {
m.undoStack = m.undoStack[len(m.undoStack)-limit:]
}
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")
return
}
redoSnap := undoSnapshot{
lines: make([]string, len(m.lines)),
cursorX: m.cursorX,
cursorY: m.cursorY,
}
copy(redoSnap.lines, m.lines)
m.redoStack = append(m.redoStack, redoSnap)
snap := m.undoStack[len(m.undoStack)-1]
m.undoStack = m.undoStack[:len(m.undoStack)-1]
m.lines = snap.lines
m.cursorX = snap.cursorX
m.cursorY = snap.cursorY
m.CursorWithinBounds()
m.dirty = true
m.previewDirty = true
m.SetStatusMsg("Undo")
}
func (m *EditorModel) Redo() {
if len(m.redoStack) == 0 {
m.SetStatusMsg("Already at newest change")
return
}
snap := m.redoStack[len(m.redoStack)-1]
m.redoStack = m.redoStack[:len(m.redoStack)-1]
undoSnap := undoSnapshot{
lines: make([]string, len(m.lines)),
cursorX: m.cursorX,
cursorY: m.cursorY,
}
copy(undoSnap.lines, m.lines)
m.undoStack = append(m.undoStack, undoSnap)
m.lines = snap.lines
m.cursorX = snap.cursorX
m.cursorY = snap.cursorY
m.CursorWithinBounds()
m.dirty = true
m.previewDirty = true
m.SetStatusMsg("Redo")
}