grimoir/model.go
2026-07-04 13:04:33 +03:00

400 lines
No EOL
8.6 KiB
Go
Executable file
Raw 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
// 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.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
InvalidateHighlightCache()
}
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
}
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
InvalidateHighlightCache()
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
InvalidateHighlightCache()
m.SetStatusMsg("Redo")
}