# 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 →