grimoir/model.go
2026-05-07 09:52:06 +03:00

457 lines
10 KiB
Go
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 (
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
type VimMode int
const (
NORMAL VimMode = iota
INSERT
VISUAL
COMMAND
SEARCH
REPLACE
)
// PanelFocus — какая панель принимает клавиши
type PanelFocus int
const (
FocusEditor PanelFocus = iota
FocusFileTree
)
// undoSnapshot — снимок состояния для undo/redo
type undoSnapshot struct {
lines []string
cursorX int
cursorY int
}
// Минимальные ширины панелей
const (
minEditorWidth = 20
minTreeWidth = 15
minPreviewWidth = 20
)
type EditorModel struct {
// Контент
lines []string
cursorX int
cursorY int
// Vim режимы
mode VimMode
pendingKeys string
selection struct {
startX, startY int
endX, endY int
}
// Файл
filepath string
fileType string
dirty bool
lastSave time.Time
statusMsg string
// UI — размеры
width int
height int
treeWidth int
previewWidth int
// Видимость панелей
showFileTree bool
showPreview bool
// Фокус
focus PanelFocus
// Настройки (из конфига или :set)
showLineNumbers bool
tabWidth int
autosaveInterval int // секунды
cfgShowPreview bool // дефолт для preview из конфига
// Viewport
viewport viewport.Model
previewBuf string
// Команда
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
// Файловое дерево
fileTree FileTree
// Help экран
showHelp bool
// Конфиг (хранится для :config show)
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{""}
}
rootDir := filepath.Dir(path)
if rootDir == "" || rootDir == "." {
rootDir, _ = filepath.Abs(".")
}
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" || fileType == "md" && cfg.ShowPreview,
showFileTree: cfg.ShowTree,
focus: FocusEditor,
lastSave: time.Now(),
viewport: viewport.New(0, 0),
width: 120,
height: 30,
treeWidth: cfg.TreeWidth,
previewWidth: cfg.PreviewWidth,
config: cfg,
fileTree: NewFileTree(rootDir),
}
// Для markdown включаем preview если задано в конфиге или по умолчанию
if fileType == "md" && cfg.ShowPreview {
m.showPreview = true
}
if cfg.ShowTree {
m.fileTree.reload()
m.fileTree.focused = false
}
m.clampPanelWidths()
m.updatePreview()
return m
}
// Init инициализирует модель
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 // fallback
}
return tea.Tick(interval, func(t time.Time) tea.Msg {
return TickMsg(t)
})
}
// Update обрабатывает сообщения
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:
return m.handleKey(msg)
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
}
// View возвращает строку для вывода
func (m *EditorModel) View() string {
if m.showHelp {
return renderHelp(m)
}
switch {
case m.showFileTree && m.showPreview && m.fileType == "md":
return m.viewTreeEditorPreview()
case m.showFileTree:
return m.viewTreeEditor()
case m.showPreview && m.fileType == "md":
return m.viewEditorPreview()
default:
return m.viewEditorOnly()
}
}
func (m *EditorModel) viewEditorOnly() string { return renderEditor(m) }
func (m *EditorModel) viewEditorPreview() string { return renderSplit(m) }
func (m *EditorModel) viewTreeEditor() string { return renderTreeEditor(m) }
func (m *EditorModel) viewTreeEditorPreview() string { return renderTreeEditorPreview(m) }
// === Вычисление ширин ===
func (m *EditorModel) editorWidth() int {
w := m.width
if m.showFileTree {
w -= m.treeWidth
}
if m.showPreview && m.fileType == "md" {
w -= m.previewWidth
}
if w < minEditorWidth {
w = minEditorWidth
}
return w
}
func (m *EditorModel) clampPanelWidths() {
if m.treeWidth < minTreeWidth {
m.treeWidth = minTreeWidth
}
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
needed := minEditorWidth
if m.showFileTree {
needed += m.treeWidth
}
if m.showPreview && m.fileType == "md" {
needed += m.previewWidth
}
if needed > m.width && m.width > 0 {
available := m.width - minEditorWidth
if available < 0 {
available = 0
}
if m.showFileTree && m.showPreview && m.fileType == "md" {
total := m.treeWidth + m.previewWidth
if total > 0 {
m.treeWidth = available * m.treeWidth / total
m.previewWidth = available - m.treeWidth
}
if m.treeWidth < minTreeWidth {
m.treeWidth = minTreeWidth
}
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
} else if m.showFileTree {
m.treeWidth = available
if m.treeWidth < minTreeWidth {
m.treeWidth = minTreeWidth
}
} else if m.showPreview {
m.previewWidth = available
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
}
}
}
func (m *EditorModel) resizeTree(delta int) {
m.treeWidth += delta
if m.treeWidth < minTreeWidth {
m.treeWidth = minTreeWidth
}
maxTree := m.width - minEditorWidth
if m.showPreview && m.fileType == "md" {
maxTree -= m.previewWidth
}
if m.treeWidth > maxTree {
m.treeWidth = maxTree
}
}
func (m *EditorModel) resizePreview(delta int) {
m.previewWidth += delta
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
maxPreview := m.width - minEditorWidth
if m.showFileTree {
maxPreview -= m.treeWidth
}
if m.previewWidth > maxPreview {
m.previewWidth = maxPreview
}
}
// === Открытие файла из дерева ===
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.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 }
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]))
// В NORMAL режиме курсор не может стоять за последним символом
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) saveUndoSnapshot() {
snapshot := undoSnapshot{
lines: make([]string, len(m.lines)),
cursorX: m.cursorX,
cursorY: m.cursorY,
}
copy(snapshot.lines, m.lines)
m.undoStack = append(m.undoStack, snapshot)
if len(m.undoStack) > 100 {
m.undoStack = m.undoStack[1:]
}
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 = make([]string, len(snap.lines))
copy(m.lines, snap.lines)
m.cursorX = snap.cursorX
m.cursorY = snap.cursorY
m.CursorWithinBounds()
m.dirty = 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 = make([]string, len(snap.lines))
copy(m.lines, snap.lines)
m.cursorX = snap.cursorX
m.cursorY = snap.cursorY
m.CursorWithinBounds()
m.dirty = true
m.SetStatusMsg("Redo")
}