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

21 KiB
Executable file
Raw Permalink Blame History

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

Вместо всего шрифта (~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)

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 скачать