21 KiB
Executable file
grimoir — Архитектура и устройство утилиты
Обзор
grimoir — терминальный текстовый редактор в стиле Vim, написанный на Go. Поддерживает markdown с live preview, подсветку синтаксиса для 10 языков программирования, файловое дерево, экспорт в PDF, загрузку статей по URL и гибкую настройку через конфиг-файл. Построен на фреймворке Bubble Tea, реализующем архитектуру 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
Центральная структура — единственное место хранения состояния:
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.
// Правильно:
line := []rune(m.lines[m.cursorY])
before := string(line[:m.cursorX])
// Неправильно (сломает кириллицу):
before := m.lines[m.cursorY][:m.cursorX]
undoSnapshot
type undoSnapshot struct {
lines []string
cursorX int
cursorY int
}
Стек ограничен 100 записями. Перед каждой мутацией вызывается saveUndoSnapshot().
LanguageConfig (syntax.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 режиме
// 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)
Формат файла
[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 архитектура
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 → <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> → 
├── <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)
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 конвертер — всё реализовано внутри без внешних зависимостей.
Сборка
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 скачать