grimoir/ARCHITECTURE.md
2026-07-04 13:04:33 +03:00

514 lines
No EOL
21 KiB
Markdown
Executable file
Raw Permalink 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.

# 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
Вместо всего шрифта (~300500 KB) в PDF встраивается только подмножество реально использованных глифов. Для типичного русского текста: 200400 глифов вместо 3000+. Composite glyphs обрабатываются рекурсивно.
### Что рендерится в PDF
Заголовки H1H4 с цветами и линией под 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> → ![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 скачать
```