diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44a5f7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Binaries +grimoir +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Credentials +*.env + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Config with sensitive data +config.yaml +config.yml +*.local.yaml +*.local.yml + +# Temporary files +*.tmp +*.temp diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..6d7a364 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,514 @@ +# grimoir — Архитектура и устройство утилиты + +## Обзор + +`grimoir` — терминальный текстовый редактор в стиле Vim, написанный на Go. Поддерживает markdown с live preview, подсветку синтаксиса для 10 языков программирования, файловое дерево, экспорт в PDF, загрузку статей по URL и гибкую настройку через конфиг-файл. Построен на фреймворке [Bubble Tea](https://github.com/charmbracelet/bubbletea), реализующем архитектуру Elm (Model-Update-View). + +``` +Запуск: grimoir notes.md + grimoir main.go + grimoir https://habr.com/ru/articles/123456/ + grimoir --write-config + grimoir --config ~/my.conf notes.md +``` + +--- + +## Архитектура: Elm / Bubble Tea + +``` +┌─────────────────────────────────────────────────┐ +│ Bubble Tea │ +│ │ +│ Init() ──→ начальные команды (тики) │ +│ │ +│ Update(msg) ──→ обрабатывает события │ +│ • KeyMsg — нажатие клавиши │ +│ • WindowSizeMsg — изменение размера окна │ +│ • TickMsg — таймер (автосейв) │ +│ │ +│ View() ──→ возвращает строку для вывода │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## Структура файлов + +``` +grimoir/ +├── main.go (105 строк) — точка входа, флаги, URL-детектор +├── model.go (451 строка) — EditorModel, Init/Update/View, undo/redo +├── keybindings.go (626 строк) — обработка клавиш по режимам и фокусу +├── commands.go (521 строка) — команды : (save, quit, new, fetch, export...) +├── search.go (284 строки) — поиск, f/t навигация, замена, скобки +├── syntax.go (174 строки) — конфиги языков, подсветка синтаксиса +├── render.go (491 строка) — весь рендеринг UI и markdown +├── config.go (432 строки) — INI парсер, поиск конфига, биндинги +├── filetree.go (307 строк) — файловое дерево, TTF навигация +├── fetch.go (551 строка) — HTTP загрузка, HTML→Markdown конвертер +├── pdf.go (905 строк) — PDF генератор, dual-engine архитектура +├── pdf_font.go (1113 строк) — TTF парсер, subset builder, PDF embedding +├── file.go (28 строк) — сохранение файла +├── file_io.go (54 строки) — загрузка файла, определение типа +└── go.mod — зависимости +``` + +**Итого: ~6000 строк, 218 функций, 0 внешних зависимостей кроме charmbracelet.** + +--- + +## Ключевые структуры данных + +### EditorModel + +Центральная структура — единственное место хранения состояния: + +```go +type EditorModel struct { + // Контент (cursorX/Y в рунах, не байтах) + lines []string + cursorX int + cursorY int + + // Режим и ввод + mode VimMode // NORMAL / INSERT / COMMAND / SEARCH / VISUAL + pendingKeys string // буфер для dd, gg, f+char + numPrefix string // числовой префикс (3 в команде 3dd) + + // Файл + filepath string + fileType string // "go", "py", "md", "txt" и т.д. + dirty bool + + // UI — панели + width, height int + treeWidth int // ширина дерева (0 если скрыта) + previewWidth int // ширина preview + showFileTree bool + showPreview bool + focus PanelFocus // FocusEditor / FocusFileTree + + // Настройки из конфига + showLineNumbers bool + tabWidth int + autosaveInterval int + config Config + + // Поиск + searchMatches []struct{ line, col int } // позиции в рунах + lastFindChar rune + + // Undo/Redo + undoStack []undoSnapshot + redoStack []undoSnapshot + + // Файловое дерево + fileTree FileTree +} +``` + +### Важно: все позиции в рунах + +`cursorX` и все операции со строками используют `[]rune`, а не байтовые индексы. Это обеспечивает корректную работу с кириллицей, китайскими иероглифами и любым другим Unicode. + +```go +// Правильно: +line := []rune(m.lines[m.cursorY]) +before := string(line[:m.cursorX]) + +// Неправильно (сломает кириллицу): +before := m.lines[m.cursorY][:m.cursorX] +``` + +### undoSnapshot + +```go +type undoSnapshot struct { + lines []string + cursorX int + cursorY int +} +``` + +Стек ограничен 100 записями. Перед каждой мутацией вызывается `saveUndoSnapshot()`. + +### LanguageConfig (syntax.go) + +```go +type LanguageConfig struct { + Keywords []string + LineCommentPrefix string // "//" или "#" или "--" + StringDelimiters []string // `"`, `'`, `` ` `` + HasNumbers bool + CaseInsensitive bool // для SQL + VarPrefix string // для Bash: "$" +} +``` + +--- + +## Vim режимы и переходы + +``` + ┌──────────────┐ + esc │ │ i / a / A / I / o / O + ┌──────▶│ NORMAL │◀──────────────────────────┐ + │ │ │ │ + │ └──┬──────┬───┘ │ + │ │ : │ / │ + │ ▼ ▼ │ + │ COMMAND SEARCH ──── enter ───────────────┘ + │ │ │ │ + │ enter │ │ INSERT + └──────────┘ └───────────────────────────────┘ +``` + +| Режим | Описание | +|---------|----------| +| NORMAL | навигация, команды (dd, yy, p, u, Ctrl+R) | +| INSERT | ввод текста, стрелки, Unicode включая кириллицу | +| COMMAND | ввод : команд | +| SEARCH | ввод паттерна после `/` | + +--- + +## Обработка клавиш (keybindings.go) + +``` +handleKey(msg) +├── Глобальные (quit, toggle_tree, focus_switch, panel_shrink/grow) +│ └── берутся из config.KeyForAction() — переопределяемые +│ +├── FocusFileTree → handleFileTreeKey() +│ └── j/k/h/l/enter/r/gg/G/esc +│ +└── FocusEditor + ├── NORMAL → handleNormalMode() + │ ├── числовые префиксы (1-9, 0) + │ ├── handleModeSwitch() — i, a, A, I, o, O + │ ├── handleMovement() — h/j/k/l, w/b/e, gg/G, f/t, n/N, % + │ └── handleEditing() — x, dd, yy, p, P, u, Ctrl+R + │ + ├── INSERT → handleInsertMode() + │ ├── Стрелки up/down/left/right, home, end, delete + │ ├── enter, backspace, tab + │ └── KeyRunes — любой Unicode символ (включая кириллицу) + │ + ├── COMMAND → handleCommandMode() + └── SEARCH → handleSearchMode() +``` + +### Ввод Unicode в INSERT режиме + +```go +// msg.Type == tea.KeyRunes — единственно правильная проверка +// для печатаемых символов в bubbletea v0.25+ +if len(runes) == 1 && runes[0] >= 32 && msg.Type == tea.KeyRunes { + m.insertRuneAt(m.cursorY, m.cursorX, runes) + m.cursorX++ +} +``` + +--- + +## Файловое дерево (filetree.go) + +``` +FileTree { + root string // корневая директория + nodes []*FileNode // плоский список видимых узлов + cursor int + scroll int + focused bool +} +``` + +Дерево хранится иерархически, для рендера разворачивается в плоский список `flattenTree()`. Директории сортируются перед файлами, скрытые файлы и `node_modules/vendor/__pycache__` пропускаются. + +**Клавиши в дереве:** `j/k` навигация, `l/enter` открыть/развернуть, `h` свернуть/к родителю, `r` обновить, `gg/G` начало/конец, `esc` вернуть фокус редактору. + +--- + +## Компоновки экрана (render.go) + +``` +View() +├── tree + editor + preview → renderTreeEditorPreview() +├── tree + editor → renderTreeEditor() +├── editor + preview → renderSplit() +└── editor only → renderEditor() +``` + +**Управление шириной панелей:** + +| Способ | Действие | +|--------|----------| +| `<` / `>` | ±2 символа к фокусированной панели | +| `:treewidth 30` / `:tw 30` | точная ширина дерева | +| `:previewwidth 45` / `:pw 45` | точная ширина preview | +| `Ctrl+B` | показать/скрыть дерево | +| `Tab` | переключить фокус | +| ` ` (пробел) | показать/скрыть preview (только .md) | + +`clampPanelWidths()` гарантирует что все панели помещаются на экран, пропорционально уменьшая при нехватке. + +`truncateANSI()` обрезает строку по видимой ширине не ломая ANSI escape-последовательности. + +--- + +## Подсветка синтаксиса (syntax.go + render.go) + +### Алгоритм + +``` +HighlightLine(line, fileType) +└── highlightWithConfig(line, config) + ├── 1. Комментарии → colorComment, остальное не трогать + └── highlightTokens(text, config) + ├── 1. Строки (regexp по кавычкам) → colorString + ├── 2. Ключевые слова (\bKEYWORD\b) → colorKeyword + ├── 3. Числа (\b\d+\b) → colorNumber + └── 4. Переменные ($VAR для Bash) → colorFunc +``` + +### Поддерживаемые языки + +`go`, `py`, `js`, `ts`, `rs`, `c`, `cpp`, `java`, `sql`, `sh` + +--- + +## Конфиг-система (config.go) + +### Формат файла + +```ini +[settings] +tree_width = 25 +preview_width = 40 +line_numbers = true +tab_width = 4 +autosave = 60 +show_preview = false +show_tree = false + +[keys] +quit = ctrl+c +toggle_tree = ctrl+b +focus_switch = tab +panel_shrink = < +panel_grow = > +toggle_preview = " " +redo = ctrl+r +``` + +### Пути поиска (приоритет: первый найденный) + +| ОС | Пути | +|----|------| +| Linux | `./grimoir.conf` → `$XDG_CONFIG_HOME/grimoir/` → `~/.config/grimoir/` → `~/.grimoir.conf` | +| macOS | `./grimoir.conf` → `~/Library/Application Support/grimoir/` → `~/.config/grimoir/` | +| Windows | `./grimoir.conf` → `%APPDATA%\grimoir\` → `%USERPROFILE%\grimoir.conf` | + +### Команды управления конфигом + +| Команда | Действие | +|---------|----------| +| `:config show` | текущие настройки и откуда загружен конфиг | +| `:config paths` | список путей поиска | +| `:config write` | записать дефолтный конфиг с комментариями | +| `:config reload` | перезагрузить конфиг без перезапуска | +| `--config path` | флаг запуска: явный путь к конфигу | +| `--write-config` | записать конфиг и выйти | + +--- + +## PDF экспорт (pdf.go + pdf_font.go) + +### Dual-engine архитектура + +```go +type pdfEngine interface { + textCmd(text string, font pdfFont, color pdfColor, x, y float64) string + textWidth(text string, font pdfFont) float64 + fontResources() string + writeObjects(doc *pdfDoc) +} +``` + +**TTF engine** — включается если найден системный шрифт с кириллицей. Текст кодируется в UTF-16BE, шрифт регистрируется как Type0/CIDFont с Identity-H. Полноценный русский текст в PDF. + +**Fallback engine** — встроенные Helvetica/Courier без встраивания. Кириллица транслитерируется. Включается если шрифт не найден. + +### Поиск системного шрифта + +``` +FindCyrillicFont() +├── Список приоритетных имён: LiberationSans, DejaVuSans, Arial, Roboto, Noto... +├── Директории по ОС: +│ Linux: /usr/share/fonts, ~/.fonts, ~/.local/share/fonts +│ macOS: /Library/Fonts, ~/Library/Fonts +│ Windows: %WINDIR%\Fonts +└── Рекурсивный обход если по именам не найдено +``` + +### TTF Subset Builder + +Вместо всего шрифта (~300–500 KB) в PDF встраивается только подмножество реально использованных глифов. Для типичного русского текста: 200–400 глифов вместо 3000+. Composite glyphs обрабатываются рекурсивно. + +### Что рендерится в PDF + +Заголовки H1–H4 с цветами и линией под H1, параграфы с переносом строк, **bold**/*italic*/`code`, блоки кода с фоном и полоской, цитаты blockquote, маркированные и нумерованные списки, таблицы, горизонтальные линии, многостраничность. + +--- + +## Загрузка статей (fetch.go) + +``` +FetchAndConvert(url) +├── fetchHTML() +│ ├── net/http с таймаутом 30с, лимитом 5 MB +│ └── User-Agent чтобы не получить 403 +│ +├── extractTitle() — og:title →