514 lines
No EOL
21 KiB
Markdown
514 lines
No EOL
21 KiB
Markdown
# 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 → <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)
|
||
|
||
```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 скачать
|
||
``` |