# 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 → → <h1> │ ├── extractMainContent() │ ├── Удаляем: <script>, <style>, <nav>, <header>, <footer>, <aside> │ ├── Ищем: <article>, <main>, div[role=main], div.content/post/article │ └── Fallback: блок с максимальной длиной текста │ └── htmlToMarkdown() ├── <h1>–<h6> → # заголовки ├── <p> → параграфы ├── <ul>/<ol>/<li> → - / 1. списки ├── <pre>/<code> → ``` блоки ├── <blockquote> → > цитаты ├── <strong>/<em> → **bold**/*italic* ├── <img> → ![alt](src) ├── <table>/<tr>/<td> → | таблицы | └── HTML entities → нормальные символы ``` **Имя файла** генерируется из заголовка через `slugify()` — транслитерация + латиница + дефисы, максимум 60 символов. --- ## Undo / Redo (model.go) ``` saveUndoSnapshot() ← перед КАЖДОЙ мутацией │ копирует lines[], cursorX, cursorY → undoStack │ очищает redoStack │ ограничивает стек 100 записями u → Undo(): undoStack → текущее, текущее → redoStack Ctrl+R → Redo(): redoStack → текущее, текущее → undoStack ``` --- ## Поиск (search.go) ### Глобальный `/pattern` Поиск по рунам — позиции корректны для любого Unicode. `n`/`N` циклически обходят все совпадения. ### f/t навигация | Команда | Действие | |---------|----------| | `f{c}` / `F{c}` | символ вправо/влево | | `t{c}` / `T{c}` | позиция до символа вправо/влево | | `;` / `,` | повторить в том же / обратном направлении | | `%` | парная скобка `()[]{}` через строки | --- ## Автосейв (model.go) ```go tea.Tick(interval, ...) // интервал из конфига // В Update(): if autosaveInterval > 0 && time.Since(lastSave) >= interval && dirty { save() } ``` `:set autosave=30` — изменить интервал на лету. `:set autosave=0` — выключить. --- ## Команды (:) | Команда | Действие | |---------|----------| | `:w` / `:w path` | сохранить / сохранить копию | | `:saveas path` | сохранить как (переключиться на новый файл) | | `:q` / `:q!` / `:wq` | выйти | | `:e path` / `:e` | открыть файл / перезагрузить текущий | | `:new` / `:new path` | новый буфер / новый файл | | `:fetch URL` | скачать статью и открыть | | `:export pdf` | экспорт в PDF | | `:export pdf path` | экспорт в указанный путь | | `:tree` / `:notree` | показать/скрыть дерево | | `:N` | перейти на строку N | | `:s/old/new/` | заменить первое вхождение | | `:s/old/new/g` | заменить все вхождения | | `:set` | показать все настройки | | `:set number` / `:set nonumber` | номера строк | | `:set tab_width=2` | ширина таба | | `:set autosave=30` | интервал автосейва | | `:u` | undo | | `:config show/paths/write/reload` | управление конфигом | --- ## Зависимости | Пакет | Назначение | |-------|------------| | `charmbracelet/bubbletea` | event loop, Model-Update-View | | `charmbracelet/lipgloss` | стилизация (цвет, bold, width) | | `charmbracelet/bubbles/viewport` | скроллируемая область | | `net/http` | загрузка URL (стандартная библиотека) | Подсветка синтаксиса, PDF генерация, TTF парсинг, INI парсер, HTML→Markdown конвертер — всё реализовано внутри без внешних зависимостей. --- ## Сборка ```bash go mod tidy go build -ldflags="-s -w" -o grimoir # Записать дефолтный конфиг: ./grimoir --write-config # Запуск: ./grimoir README.md ./grimoir main.go ./grimoir https://habr.com/ru/articles/123456/ ``` --- ## Шпаргалка ``` НАВИГАЦИЯ РЕДАКТИРОВАНИЕ ПОИСК h j k l i / a / A /pattern поиск w b e I / o / O n / N следующий/предыдущий gg / G x удалить f{c} символ в строке 0 / ^ / $ dd строку ; , повторить f/t 5j / 3dd yy скопировать % парная скобка p / P вставить u undo Ctrl+R redo ПАНЕЛИ ФАЙЛ INSERT режим Ctrl+B дерево :w сохранить стрелки навигация Tab фокус :wq сохранить+выйти home/end начало/конец строки < / > ширина :e path открыть любой Unicode включая кириллицу Пробел preview :fetch URL скачать ```