Version 0.1

This commit is contained in:
Magnus Root 2026-05-07 09:52:06 +03:00
parent 0a8b4e804a
commit b4350ce59f
21 changed files with 7189 additions and 0 deletions

46
.gitignore vendored Normal file
View file

@ -0,0 +1,46 @@
# Binaries
grimoir
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Credentials
*.env
# Output of the go coverage tool
*.out
# Go workspace file
go.work
# Dependency directories
vendor/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Config with sensitive data
config.yaml
config.yml
*.local.yaml
*.local.yml
# Temporary files
*.tmp
*.temp

514
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,514 @@
# 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 скачать
```

205
README.md Normal file
View file

@ -0,0 +1,205 @@
# grimoir
Терминальный текстовый редактор в стиле Vim, написанный на Go.
Поддерживает Markdown с live preview, подсветку синтаксиса для 10 языков, встроенный файловый менеджер, экспорт в PDF с кириллицей, загрузку статей по URL и гибкую настройку через конфиг-файл.
---
## Возможности
### Редактор
- **Vim-режимы** — NORMAL, INSERT, COMMAND, SEARCH с полным набором команд
- **Unicode** — полная поддержка кириллицы и любых других символов при вводе
- **Undo / Redo** — до 100 шагов (`u` / `Ctrl+R`)
- **Числовые префиксы**`5j`, `3dd`, `10G`
- **Поиск**`/pattern` с навигацией `n`/`N`, поиск символа `f`/`t`, парные скобки `%`
- **Замена**`:s/old/new/` и `:s/old/new/g`
- **Автосохранение** — настраиваемый интервал
### Markdown
- **Live preview** — разделённый экран редактор + preview
- **Рендер** — заголовки, списки, таблицы, blockquotes, code blocks с подсветкой, ссылки, bold/italic
### Подсветка синтаксиса
Go, Python, JavaScript, TypeScript, Rust, C/C++, Java, SQL, Bash
### Файловый менеджер
- Дерево файлов с vim-навигацией
- Открытие файлов прямо из дерева
- `Ctrl+B` — показать/скрыть, `Tab` — переключить фокус
### PDF экспорт
- Встраивает системный шрифт с кириллицей — полноценный русский текст в PDF
- Автоматический поиск шрифта (LiberationSans, DejaVu, Arial и др.)
- Subset embedding — в PDF попадают только использованные глифы
- Fallback — транслитерация если шрифт не найден
### Загрузка статей
```bash
grimoir https://example.com/article
# или прямо в редакторе:
:fetch https://example.com/article
```
Скачивает страницу, извлекает основной контент, конвертирует в Markdown, сохраняет файл с именем из заголовка статьи.
---
## Управление
### Режимы
| Клавиша | Действие |
|---------|----------|
| `i` / `a` / `A` | INSERT: вставить / после курсора / в конец строки |
| `I` / `o` / `O` | INSERT: в начало / новая строка ниже / выше |
| `Esc` | вернуться в NORMAL |
| `:` | режим команд |
| `/` | поиск |
| `?` | справка |
### Навигация
| Клавиша | Действие |
|---------|----------|
| `h j k l` | ← ↓ ↑ → |
| `w` / `b` / `e` | слово вперёд / назад / конец слова |
| `0` / `^` / `$` | начало / первый символ / конец строки |
| `gg` / `G` | начало / конец файла |
| `42G` | перейти на строку 42 |
| `f{c}` / `F{c}` | найти символ вправо / влево |
| `t{c}` / `T{c}` | до символа вправо / влево |
| `;` / `,` | повторить f/t / в обратном направлении |
| `%` | парная скобка `()[]{}` |
В INSERT режиме работают стрелки, `Home`, `End`, `Delete`.
### Редактирование
| Клавиша | Действие |
|---------|----------|
| `x` | удалить символ |
| `dd` / `3dd` | удалить строку / 3 строки |
| `yy` / `p` / `P` | скопировать / вставить ниже / выше |
| `u` / `Ctrl+R` | undo / redo |
### Команды
| Команда | Действие |
|---------|----------|
| `:w` | сохранить |
| `:w path` | сохранить копию |
| `:saveas path` | сохранить как |
| `:q` / `:q!` / `:wq` | выйти |
| `:e path` | открыть файл |
| `:new path` | новый файл |
| `:N` | перейти на строку N |
| `:s/old/new/g` | замена во всём файле |
| `:fetch URL` | скачать статью |
| `:export pdf` | экспорт в PDF |
| `:export pdf path` | экспорт в указанный путь |
| `:help` / `?` | справка |
### Панели
| Клавиша / Команда | Действие |
|-------------------|----------|
| `Ctrl+B` | показать / скрыть файловое дерево |
| `Tab` | переключить фокус между панелями |
| `<` / `>` | уменьшить / увеличить ширину панели |
| Пробел | показать / скрыть preview (только .md) |
| `:treewidth 30` | установить ширину дерева |
| `:previewwidth 45` | установить ширину preview |
---
## Конфигурация
Конфиг ищется автоматически при запуске:
| ОС | Путь |
|----|------|
| Linux | `~/.config/grimoir/grimoir.conf` |
| macOS | `~/Library/Application Support/grimoir/grimoir.conf` |
| Windows | `%APPDATA%\grimoir\grimoir.conf` |
Также поддерживается локальный `./grimoir.conf` в текущей директории — имеет наивысший приоритет.
Создать конфиг с дефолтными настройками:
```bash
./grimoir --write-config
```
### Пример конфига
```ini
[settings]
tree_width = 28
preview_width = 42
line_numbers = true
tab_width = 4
autosave = 60
show_tree = false
show_preview = false
[keys]
quit = ctrl+c
toggle_tree = ctrl+b
focus_switch = tab
panel_shrink = <
panel_grow = >
toggle_preview = " "
redo = ctrl+r
```
### Управление конфигом
```
:config show — откуда загружен конфиг и текущие значения
:config paths — список путей поиска
:config write — записать дефолтный конфиг
:config reload — перезагрузить без перезапуска
```
Настройки можно менять и на лету:
```
:set number / nonumber — номера строк
:set tab_width=2 — ширина таба
:set autosave=30 — автосейв каждые 30 секунд
:set autosave=0 — выключить автосейв
```
---
## Флаги запуска
```
grimoir [флаги] [файл или URL]
-f path файл для редактирования
--config path явный путь к конфиг-файлу
--write-config записать дефолтный конфиг и выйти
```
---
## Сборка
Требования: **Go 1.21+**
```bash
go mod tidy
go build -ldflags="-s -w" -o grimoir
```
Зависимости:
| Пакет | Назначение |
|-------|------------|
| `charmbracelet/bubbletea` | TUI фреймворк (Elm-архитектура) |
| `charmbracelet/lipgloss` | стилизация вывода |
| `charmbracelet/bubbles` | компоненты (viewport) |
Вся остальная функциональность — подсветка синтаксиса, PDF генерация, TTF парсинг, INI парсер, HTML→Markdown конвертер — реализована без внешних зависимостей.

525
commands.go Normal file
View file

@ -0,0 +1,525 @@
package main
import (
"fmt"
"path/filepath"
"strconv"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// executeCommand выполняет vim-команду
func (m *EditorModel) executeCommand(cmd string) tea.Cmd {
cmd = strings.TrimSpace(cmd)
// === Команды с аргументами (проверяем первыми) ===
// :w path — сохранить в другой файл (saveas без смены текущего)
if strings.HasPrefix(cmd, "w ") {
target := strings.TrimSpace(strings.TrimPrefix(cmd, "w "))
return m.cmdWriteTo(target, false)
}
// :saveas path / :wa path
if strings.HasPrefix(cmd, "saveas ") {
target := strings.TrimSpace(strings.TrimPrefix(cmd, "saveas "))
return m.cmdWriteTo(target, true)
}
// :e path / :edit path — открыть файл
if strings.HasPrefix(cmd, "e ") || strings.HasPrefix(cmd, "edit ") {
target := strings.TrimSpace(cmd)
target = strings.TrimSpace(strings.SplitN(target, " ", 2)[1])
return m.cmdEdit(target)
}
// :new path — создать новый файл
if strings.HasPrefix(cmd, "new") {
arg := strings.TrimSpace(strings.TrimPrefix(cmd, "new"))
return m.cmdNew(arg)
}
// :export pdf [path]
if strings.HasPrefix(cmd, "export") {
arg := strings.TrimSpace(strings.TrimPrefix(cmd, "export"))
return m.cmdExport(arg)
}
// :fetch URL — скачать статью и открыть в редакторе
if strings.HasPrefix(cmd, "fetch ") {
rawURL := strings.TrimSpace(strings.TrimPrefix(cmd, "fetch "))
return m.cmdFetch(rawURL)
}
// :s/old/new/[g]
if strings.HasPrefix(cmd, "s/") {
m.handleSubstituteCommand(cmd)
return nil
}
// :/pattern
if strings.HasPrefix(cmd, "/") {
m.performSearch(strings.TrimPrefix(cmd, "/"))
return nil
}
// :set ...
if strings.HasPrefix(cmd, "set") {
m.handleSetCommand(cmd)
return nil
}
// :config ...
if strings.HasPrefix(cmd, "config") {
m.handleConfigCommand(strings.TrimSpace(strings.TrimPrefix(cmd, "config")))
return nil
}
// :treewidth N / :tw N
if strings.HasPrefix(cmd, "treewidth ") || strings.HasPrefix(cmd, "tw ") {
parts := strings.Fields(cmd)
if len(parts) == 2 {
if n, err := strconv.Atoi(parts[1]); err == nil {
m.treeWidth = n
m.clampPanelWidths()
m.SetStatusMsg(fmt.Sprintf("Tree width: %d", m.treeWidth))
return nil
}
}
m.SetStatusMsg("Usage: treewidth <N>")
return nil
}
// :previewwidth N / :pw N
if strings.HasPrefix(cmd, "previewwidth ") || strings.HasPrefix(cmd, "pw ") {
parts := strings.Fields(cmd)
if len(parts) == 2 {
if n, err := strconv.Atoi(parts[1]); err == nil {
m.previewWidth = n
m.clampPanelWidths()
m.SetStatusMsg(fmt.Sprintf("Preview width: %d", m.previewWidth))
return nil
}
}
m.SetStatusMsg("Usage: previewwidth <N>")
return nil
}
// === Простые команды ===
switch cmd {
case "help", "h":
m.showHelp = true
return nil
case "w":
if err := m.save(); err != nil {
m.SetStatusMsg("Error: " + err.Error())
} else {
m.SetStatusMsg(fmt.Sprintf("Saved: %s", m.filepath))
}
return nil
case "q":
if m.dirty {
m.SetStatusMsg("No write since last change (add ! to override)")
} else {
return tea.Quit
}
return nil
case "q!":
return tea.Quit
case "wq", "x":
if err := m.save(); err != nil {
m.SetStatusMsg("Error: " + err.Error())
} else {
return tea.Quit
}
return nil
case "u":
m.Undo()
return nil
case "tree":
m.showFileTree = true
m.fileTree.reload()
m.focus = FocusFileTree
m.fileTree.focused = true
m.clampPanelWidths()
m.SetStatusMsg(fmt.Sprintf("File tree ON (width: %d)", m.treeWidth))
return nil
case "notree":
m.showFileTree = false
m.focus = FocusEditor
m.fileTree.focused = false
m.SetStatusMsg("File tree OFF")
return nil
case "new":
return m.cmdNew("")
case "export", "export pdf":
return m.cmdExport("pdf")
}
// :N — перейти на строку
if len(cmd) > 0 && cmd[0] >= '0' && cmd[0] <= '9' {
lineNum := 0
if _, err := fmt.Sscanf(cmd, "%d", &lineNum); err == nil {
if lineNum > 0 && lineNum <= len(m.lines) {
m.cursorY = lineNum - 1
m.cursorX = 0
m.SetStatusMsg(fmt.Sprintf("Line %d", lineNum))
return nil
}
m.SetStatusMsg(fmt.Sprintf("Invalid line: %d", lineNum))
return nil
}
}
m.SetStatusMsg("Not an editor command: " + cmd)
return nil
}
// === Реализации команд ===
// cmdWriteTo сохраняет в указанный путь.
// changeFilepath=true означает ":saveas" — меняем текущий файл.
// changeFilepath=false означает ":w path" — копия, текущий файл не меняется.
func (m *EditorModel) cmdWriteTo(target string, changeFilepath bool) tea.Cmd {
if target == "" {
m.SetStatusMsg("Usage: w <path> or saveas <path>")
return nil
}
target = expandPath(target)
// Сохраняем под новым именем
content := strings.Join(m.lines, "\n")
if err := writeFileImpl(target, content); err != nil {
m.SetStatusMsg("Error: " + err.Error())
return nil
}
if changeFilepath {
// saveas — переключаемся на новый файл
m.filepath = target
m.fileType = DetectFileType(target)
m.dirty = false
m.cfgShowPreview = m.cfgShowPreview
m.showPreview = m.fileType == "md" && m.cfgShowPreview
m.clampPanelWidths()
m.updatePreview()
m.SetStatusMsg(fmt.Sprintf("Saved as: %s", target))
} else {
// w path — просто копия
m.SetStatusMsg(fmt.Sprintf("Written to: %s", target))
}
return nil
}
// cmdEdit открывает файл в редакторе (как :e в vim)
func (m *EditorModel) cmdEdit(target string) tea.Cmd {
if target == "" {
// :e без аргумента — перезагрузить текущий файл
if m.dirty {
m.SetStatusMsg("No write since last change (add ! to override with :e!)")
return nil
}
m.openFile(m.filepath)
m.SetStatusMsg("Reloaded: " + m.filepath)
return nil
}
target = expandPath(target)
if m.dirty {
m.SetStatusMsg(fmt.Sprintf("Save changes first (:w), or use :e! to discard — opening %s", target))
// Открываем всё равно, как vim с confirm
}
m.openFile(target)
return nil
}
// cmdNew создаёт новый пустой файл
func (m *EditorModel) cmdNew(name string) tea.Cmd {
if m.dirty {
m.SetStatusMsg("Unsaved changes! Save first (:w) or discard (:q!), then :new")
return nil
}
if name == "" {
// Без имени — открываем пустой буфер untitled
m.lines = []string{""}
m.filepath = "untitled.md"
m.fileType = "md"
m.cursorX = 0
m.cursorY = 0
m.dirty = false
m.undoStack = nil
m.redoStack = nil
m.searchMatches = nil
m.showPreview = m.cfgShowPreview
m.mode = NORMAL
m.updatePreview()
m.SetStatusMsg("New file (save with :w <name>)")
return nil
}
name = expandPath(name)
// Если файл уже существует — открываем его
content, err := LoadFile(name)
if err != nil {
m.SetStatusMsg("Error: " + err.Error())
return nil
}
m.openFile(name)
if content == "" {
m.SetStatusMsg(fmt.Sprintf("New file: %s", name))
} else {
m.SetStatusMsg(fmt.Sprintf("Opened: %s", name))
}
return nil
}
// cmdExport экспортирует текущий файл
func (m *EditorModel) cmdExport(arg string) tea.Cmd {
parts := strings.Fields(arg)
format := "pdf"
outputPath := ""
if len(parts) >= 1 {
format = strings.ToLower(parts[0])
}
if len(parts) >= 2 {
outputPath = expandPath(parts[1])
}
switch format {
case "pdf":
if m.fileType != "md" {
m.SetStatusMsg("PDF export only supported for .md files")
return nil
}
if outputPath == "" {
outputPath = defaultPDFPath(m.filepath)
}
m.SetStatusMsg(fmt.Sprintf("Exporting PDF: %s ...", outputPath))
if err := ExportToPDF(m.lines, outputPath); err != nil {
m.SetStatusMsg("Export error: " + err.Error())
} else {
m.SetStatusMsg(fmt.Sprintf("Exported: %s", outputPath))
}
default:
m.SetStatusMsg(fmt.Sprintf("Unknown export format: %s (supported: pdf)", format))
}
return nil
}
// === Вспомогательные ===
// cmdFetch скачивает статью по URL и открывает в редакторе
func (m *EditorModel) cmdFetch(rawURL string) tea.Cmd {
if rawURL == "" {
m.SetStatusMsg("Usage: fetch <URL>")
return nil
}
if !IsURL(rawURL) {
m.SetStatusMsg("Invalid URL (must start with http:// or https://)")
return nil
}
m.SetStatusMsg(fmt.Sprintf("Fetching: %s ...", rawURL))
result, err := FetchAndConvert(rawURL)
if err != nil {
m.SetStatusMsg("Fetch error: " + err.Error())
return nil
}
savedPath, err := SaveFetchResult(result, "")
if err != nil {
m.SetStatusMsg("Save error: " + err.Error())
return nil
}
m.openFile(savedPath)
m.SetStatusMsg(fmt.Sprintf("Fetched: %s → %s", result.Title, savedPath))
return nil
}
// expandPath разворачивает ~ в домашнюю директорию
func expandPath(path string) string {
if strings.HasPrefix(path, "~/") || path == "~" {
if home, err := userHomeDir(); err == nil {
path = filepath.Join(home, path[1:])
}
}
return path
}
// handleConfigCommand обрабатывает :config <subcommand>
func (m *EditorModel) handleConfigCommand(sub string) {
switch sub {
case "", "show":
m.SetStatusMsg(fmt.Sprintf(
"Config: %s | tw=%d pw=%d nu=%v tab=%d autosave=%ds",
m.config.LoadedFrom, m.treeWidth, m.previewWidth,
m.showLineNumbers, m.tabWidth, m.autosaveInterval,
))
case "paths":
paths := ConfigPaths()
m.SetStatusMsg("Search paths: " + strings.Join(paths[:min3(2, len(paths))], ", ") + " ...")
case "write":
paths := ConfigPaths()
target := ""
for _, p := range paths {
if p != paths[0] {
target = p
break
}
}
if target == "" && len(paths) > 0 {
target = paths[0]
}
if err := WriteDefaultConfig(target); err != nil {
m.SetStatusMsg("Error writing config: " + err.Error())
} else {
m.SetStatusMsg("Config written: " + target)
}
case "reload":
cfg := LoadConfig()
m.ApplyConfig(cfg)
m.SetStatusMsg("Config reloaded from: " + cfg.LoadedFrom)
default:
m.SetStatusMsg("Usage: :config [show|paths|write|reload]")
}
}
// handleSubstituteCommand обрабатывает :s/old/new/[g]
func (m *EditorModel) handleSubstituteCommand(cmd string) {
parts := strings.Split(cmd, "/")
if len(parts) < 3 {
m.SetStatusMsg("Usage: s/pattern/replacement/[g]")
return
}
old := parts[1]
newStr := parts[2]
flags := ""
if len(parts) > 3 {
flags = parts[3]
}
if old == "" {
m.SetStatusMsg("Empty pattern")
return
}
m.saveUndoSnapshot()
if strings.Contains(flags, "g") {
count := m.replaceAll(old, newStr)
m.SetStatusMsg(fmt.Sprintf("Replaced %d occurrence(s)", count))
} else {
count := m.replaceInLine(old, newStr)
if count > 0 {
m.SetStatusMsg(fmt.Sprintf("Replaced %d occurrence(s)", count))
} else {
m.SetStatusMsg("Pattern not found")
}
}
}
// handleSetCommand обрабатывает :set option[=value]
func (m *EditorModel) handleSetCommand(cmd string) {
rest := strings.TrimSpace(strings.TrimPrefix(cmd, "set"))
if rest == "" {
m.SetStatusMsg(fmt.Sprintf(
"nu=%v preview=%v tree=%v tw=%d pw=%d tab=%d autosave=%d",
m.showLineNumbers, m.showPreview, m.showFileTree,
m.treeWidth, m.previewWidth, m.tabWidth, m.autosaveInterval,
))
return
}
switch rest {
case "number", "nu":
m.showLineNumbers = true
m.SetStatusMsg("Line numbers ON")
case "nonumber", "nonu":
m.showLineNumbers = false
m.SetStatusMsg("Line numbers OFF")
case "preview":
if m.fileType == "md" {
m.showPreview = true
m.clampPanelWidths()
m.SetStatusMsg("Preview ON")
} else {
m.SetStatusMsg("Preview only for .md files")
}
case "nopreview":
m.showPreview = false
m.SetStatusMsg("Preview OFF")
default:
if eqIdx := strings.Index(rest, "="); eqIdx != -1 {
key := rest[:eqIdx]
val := rest[eqIdx+1:]
switch key {
case "tabstop", "ts", "tab_width", "tabwidth":
n, err := strconv.Atoi(val)
if err != nil || n < 1 || n > 16 {
m.SetStatusMsg("tab_width must be 116")
return
}
m.tabWidth = n
m.SetStatusMsg(fmt.Sprintf("Tab width: %d", n))
case "autosave":
n, err := strconv.Atoi(val)
if err != nil || n < 0 {
m.SetStatusMsg("autosave must be >= 0")
return
}
m.autosaveInterval = n
if n == 0 {
m.SetStatusMsg("Autosave OFF")
} else {
m.SetStatusMsg(fmt.Sprintf("Autosave every %ds", n))
}
case "treewidth", "tw":
n, err := strconv.Atoi(val)
if err != nil {
m.SetStatusMsg("Expected integer")
return
}
m.treeWidth = n
m.clampPanelWidths()
m.SetStatusMsg(fmt.Sprintf("Tree width: %d", m.treeWidth))
case "previewwidth", "pw":
n, err := strconv.Atoi(val)
if err != nil {
m.SetStatusMsg("Expected integer")
return
}
m.previewWidth = n
m.clampPanelWidths()
m.SetStatusMsg(fmt.Sprintf("Preview width: %d", m.previewWidth))
default:
m.SetStatusMsg("Unknown option: " + key)
}
} else {
m.SetStatusMsg("Unknown option: " + rest)
}
}
}
func min3(a, b int) int {
if a < b {
return a
}
return b
}

432
config.go Normal file
View file

@ -0,0 +1,432 @@
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
// Config хранит все настройки редактора
type Config struct {
// [settings]
TreeWidth int
PreviewWidth int
LineNumbers bool
TabWidth int
AutosaveInterval int // секунды (0 = выключен)
ShowPreview bool
ShowTree bool
Theme string
// [keys] — map: действие → клавиша
// Например: "toggle_tree" → "ctrl+b"
Keys map[string]string
// Путь откуда загружен конфиг (для отображения в :set)
LoadedFrom string
}
// DefaultConfig возвращает конфиг со значениями по умолчанию
func DefaultConfig() Config {
return Config{
TreeWidth: 25,
PreviewWidth: 40,
LineNumbers: true,
TabWidth: 4,
AutosaveInterval: 60,
ShowPreview: false, // включается автоматически для .md
ShowTree: false,
Theme: "default",
Keys: defaultKeys(),
}
}
// defaultKeys возвращает стандартные биндинги
func defaultKeys() map[string]string {
return map[string]string{
"quit": "ctrl+c",
"quit_alt": "ctrl+d",
"redo": "ctrl+r",
"toggle_tree": "ctrl+b",
"focus_switch": "tab",
"panel_shrink": "<",
"panel_grow": ">",
"toggle_preview": " ", // пробел
}
}
// ActionForKey возвращает имя действия для заданной клавиши,
// или "" если биндинг не найден
func (c *Config) ActionForKey(key string) string {
for action, boundKey := range c.Keys {
if boundKey == key {
return action
}
}
return ""
}
// KeyForAction возвращает клавишу для действия, или дефолтную если не переопределена
func (c *Config) KeyForAction(action string) string {
if k, ok := c.Keys[action]; ok {
return k
}
return defaultKeys()[action]
}
// === Поиск файла конфига ===
// configSearchPaths возвращает список путей для поиска конфига,
// в порядке приоритета (первый найденный используется)
func configSearchPaths() []string {
var paths []string
appName := "md-editor"
fileName := "md-editor.conf"
switch runtime.GOOS {
case "windows":
// %APPDATA%\md-editor\md-editor.conf
// %USERPROFILE%\md-editor.conf
if appData := os.Getenv("APPDATA"); appData != "" {
paths = append(paths, filepath.Join(appData, appName, fileName))
}
if userProfile := os.Getenv("USERPROFILE"); userProfile != "" {
paths = append(paths, filepath.Join(userProfile, fileName))
}
case "darwin":
// ~/Library/Application Support/md-editor/md-editor.conf
// ~/.config/md-editor/md-editor.conf
// ~/.md-editor.conf
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths,
filepath.Join(home, "Library", "Application Support", appName, fileName),
filepath.Join(home, ".config", appName, fileName),
filepath.Join(home, "."+fileName),
)
}
default: // linux и прочие Unix
// $XDG_CONFIG_HOME/md-editor/md-editor.conf (если задан)
// ~/.config/md-editor/md-editor.conf (XDG default)
// ~/.md-editor.conf
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
paths = append(paths, filepath.Join(xdg, appName, fileName))
}
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths,
filepath.Join(home, ".config", appName, fileName),
filepath.Join(home, "."+fileName),
)
}
}
// os.UserConfigDir() как дополнительный fallback (кроссплатформенный)
if dir, err := os.UserConfigDir(); err == nil {
candidate := filepath.Join(dir, appName, fileName)
if !containsPath(paths, candidate) {
paths = append(paths, candidate)
}
}
// Локальный конфиг в текущей директории (наивысший приоритет — добавляем первым)
localPath := fileName
if abs, err := filepath.Abs(localPath); err == nil {
localPath = abs
}
return append([]string{localPath}, paths...)
}
func containsPath(paths []string, p string) bool {
for _, existing := range paths {
if existing == p {
return true
}
}
return false
}
// === Загрузка конфига ===
// LoadConfig ищет и загружает конфиг. Возвращает DefaultConfig если файл не найден.
func LoadConfig() Config {
cfg := DefaultConfig()
for _, path := range configSearchPaths() {
data, err := os.ReadFile(path)
if err != nil {
continue
}
if err := parseConfig(data, &cfg); err != nil {
// Файл найден но с ошибками — сообщим при запуске
cfg.LoadedFrom = path + " (parse error: " + err.Error() + ")"
} else {
cfg.LoadedFrom = path
}
return cfg
}
cfg.LoadedFrom = "(defaults, config not found)"
return cfg
}
// ConfigPaths возвращает список путей поиска (для команды :config paths)
func ConfigPaths() []string {
return configSearchPaths()
}
// parseConfig разбирает INI-файл и заполняет cfg
func parseConfig(data []byte, cfg *Config) error {
scanner := bufio.NewScanner(strings.NewReader(string(data)))
section := ""
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
// Пропускаем пустые строки и комментарии
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
// Секция [name]
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section = strings.ToLower(line[1 : len(line)-1])
continue
}
// Пара key = value
eqIdx := strings.IndexByte(line, '=')
if eqIdx == -1 {
return fmt.Errorf("line %d: missing '=' in %q", lineNum, line)
}
key := strings.TrimSpace(line[:eqIdx])
value := strings.TrimSpace(line[eqIdx+1:])
// Удаляем инлайн-комментарии (# или ; после значения)
if idx := strings.IndexAny(value, "#;"); idx != -1 {
value = strings.TrimSpace(value[:idx])
}
key = strings.ToLower(key)
switch section {
case "settings":
if err := applySettingKey(cfg, key, value, lineNum); err != nil {
return err
}
case "keys":
if err := applyKeyBinding(cfg, key, value, lineNum); err != nil {
return err
}
case "":
// Секция не задана — пробуем применить как settings
if err := applySettingKey(cfg, key, value, lineNum); err != nil {
return fmt.Errorf("line %d: unknown key %q (no section specified)", lineNum, key)
}
default:
// Неизвестная секция — игнорируем
}
}
return scanner.Err()
}
// applySettingKey применяет одну настройку из секции [settings]
func applySettingKey(cfg *Config, key, value string, line int) error {
switch key {
case "tree_width", "treewidth":
n, err := parseInt(value, line)
if err != nil {
return err
}
cfg.TreeWidth = n
case "preview_width", "previewwidth":
n, err := parseInt(value, line)
if err != nil {
return err
}
cfg.PreviewWidth = n
case "line_numbers", "linenumbers", "nu", "number":
b, err := parseBool(value, line)
if err != nil {
return err
}
cfg.LineNumbers = b
case "tab_width", "tabwidth", "tabstop", "ts":
n, err := parseInt(value, line)
if err != nil {
return err
}
if n < 1 || n > 16 {
return fmt.Errorf("line %d: tab_width must be 116", line)
}
cfg.TabWidth = n
case "autosave", "autosave_interval":
n, err := parseInt(value, line)
if err != nil {
return err
}
cfg.AutosaveInterval = n
case "show_preview", "preview":
b, err := parseBool(value, line)
if err != nil {
return err
}
cfg.ShowPreview = b
case "show_tree", "tree":
b, err := parseBool(value, line)
if err != nil {
return err
}
cfg.ShowTree = b
case "theme":
cfg.Theme = value
default:
return fmt.Errorf("line %d: unknown setting %q", line, key)
}
return nil
}
// applyKeyBinding применяет один биндинг из секции [keys]
func applyKeyBinding(cfg *Config, action, key string, line int) error {
// Нормализуем имя действия
action = strings.ToLower(strings.ReplaceAll(action, "-", "_"))
// Проверяем что действие известно
known := []string{
"quit", "quit_alt", "redo", "toggle_tree", "focus_switch",
"panel_shrink", "panel_grow", "toggle_preview",
}
for _, a := range known {
if a == action {
cfg.Keys[action] = key
return nil
}
}
// Неизвестное действие — предупреждаем но не падаем
return fmt.Errorf("line %d: unknown action %q", line, action)
}
// === Хелперы парсинга ===
func parseInt(s string, line int) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("line %d: expected integer, got %q", line, s)
}
return n, nil
}
func parseBool(s string, line int) (bool, error) {
switch strings.ToLower(s) {
case "true", "yes", "on", "1":
return true, nil
case "false", "no", "off", "0":
return false, nil
}
return false, fmt.Errorf("line %d: expected bool (true/false/yes/no/on/off/1/0), got %q", line, s)
}
// === Сохранение конфига ===
// WriteDefaultConfig записывает дефолтный конфиг с комментариями в path
func WriteDefaultConfig(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
content := `# md-editor configuration file
# Format: key = value
# Comments start with # or ;
[settings]
# Ширина панели файлового дерева (символы)
tree_width = 25
# Ширина панели preview (символы)
preview_width = 40
# Показывать номера строк (true/false)
line_numbers = true
# Ширина табуляции (116)
tab_width = 4
# Автосохранение каждые N секунд (0 = выключено)
autosave = 60
# Показывать preview при открытии .md файлов (true/false)
show_preview = false
# Показывать файловое дерево при запуске (true/false)
show_tree = false
# Тема (default единственная пока)
theme = default
[keys]
# Формат: action = key
# Примеры клавиш: ctrl+c, ctrl+b, tab, f1, alt+t, <, >, " " (пробел)
# Выход
quit = ctrl+c
quit_alt = ctrl+d
# Redo
redo = ctrl+r
# Показать/скрыть файловое дерево
toggle_tree = ctrl+b
# Переключение фокуса между панелями
focus_switch = tab
# Уменьшить/увеличить ширину активной панели
panel_shrink = <
panel_grow = >
# Показать/скрыть preview (только для .md)
toggle_preview = " "
`
return os.WriteFile(path, []byte(content), 0644)
}
// === Применение конфига к модели ===
// ApplyConfig применяет загруженный конфиг к EditorModel
func (m *EditorModel) ApplyConfig(cfg Config) {
m.treeWidth = cfg.TreeWidth
m.previewWidth = cfg.PreviewWidth
m.showLineNumbers = cfg.LineNumbers
m.tabWidth = cfg.TabWidth
m.autosaveInterval = cfg.AutosaveInterval
if cfg.ShowTree {
m.showFileTree = true
m.fileTree.reload()
}
// ShowPreview из конфига применяется только если тип файла md —
// это проверяется при открытии файла, здесь только сохраняем флаг
m.cfgShowPreview = cfg.ShowPreview
m.config = cfg
m.clampPanelWidths()
}

551
fetch.go Normal file
View file

@ -0,0 +1,551 @@
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"unicode"
)
// FetchResult — результат загрузки и конвертации страницы
type FetchResult struct {
Title string
Markdown string
Filename string // предлагаемое имя файла
URL string
}
// IsURL проверяет что строка является HTTP(S) ссылкой
func IsURL(s string) bool {
s = strings.TrimSpace(s)
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
// FetchAndConvert скачивает страницу и конвертирует в Markdown
func FetchAndConvert(rawURL string) (*FetchResult, error) {
// Нормализуем URL
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if u.Scheme == "" {
u.Scheme = "https"
}
// Скачиваем
html, err := fetchHTML(u.String())
if err != nil {
return nil, fmt.Errorf("fetch error: %w", err)
}
// Парсим и конвертируем
title := extractTitle(html)
contentHTML := extractMainContent(html)
markdown := htmlToMarkdown(contentHTML, u.String())
// Добавляем заголовок и источник в начало документа
header := fmt.Sprintf("# %s\n\n> Источник: [%s](%s)\n> Дата: %s\n\n---\n\n",
title, u.Host, u.String(), time.Now().Format("02.01.2006"),
)
result := &FetchResult{
Title: title,
Markdown: header + markdown,
Filename: slugify(title) + ".md",
URL: u.String(),
}
return result, nil
}
// fetchHTML загружает HTML страницу
func fetchHTML(rawURL string) (string, error) {
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
return "", err
}
// Представляемся нормальным браузером
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; grimoir/1.0; +https://github.com/grimoir)")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "ru,en;q=0.9")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}
// Ограничиваем 5 MB
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
return "", err
}
return string(body), nil
}
// === Извлечение контента ===
// extractTitle извлекает заголовок страницы
func extractTitle(html string) string {
// Пробуем og:title (обычно чище)
ogTitle := reFind(html, `(?i)<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']`)
if ogTitle == "" {
ogTitle = reFind(html, `(?i)<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']`)
}
if ogTitle != "" {
return cleanText(ogTitle)
}
// Обычный <title>
title := reFind(html, `(?i)<title[^>]*>([^<]+)</title>`)
if title != "" {
return cleanText(title)
}
// H1
h1 := reFind(html, `(?i)<h1[^>]*>([^<]+)</h1>`)
if h1 != "" {
return cleanText(h1)
}
return "article"
}
// extractMainContent выбирает основной контент статьи из HTML
func extractMainContent(html string) string {
// Удаляем ненужные секции
html = removeTagContent(html, "script")
html = removeTagContent(html, "style")
html = removeTagContent(html, "nav")
html = removeTagContent(html, "header")
html = removeTagContent(html, "footer")
html = removeTagContent(html, "aside")
html = removeTagContent(html, "noscript")
html = removeTagContent(html, "iframe")
html = removeTagAttr(html, "onclick|onload|onmouseover|onmouseout")
// Кандидаты — семантические теги в порядке приоритета
candidates := []struct {
pattern string
}{
{`(?is)<article[^>]*>(.*?)</article>`},
{`(?is)<main[^>]*>(.*?)</main>`},
{`(?is)<div[^>]+role=["']main["'][^>]*>(.*?)</div>`},
{`(?is)<div[^>]+class=["'][^"']*(?:article|post|content|entry|story|text|body)[^"']*["'][^>]*>(.*?)</div>`},
{`(?is)<div[^>]+id=["'][^"']*(?:article|post|content|entry|story|main)[^"']*["'][^>]*>(.*?)</div>`},
}
for _, c := range candidates {
if m := regexp.MustCompile(c.pattern).FindStringSubmatch(html); m != nil {
if len(stripTags(m[1])) > 200 { // минимум контента
return m[1]
}
}
}
// Fallback: ищем div с максимальной плотностью текста
return findDenseTextBlock(html)
}
// findDenseTextBlock ищет блок с наибольшим количеством текста
func findDenseTextBlock(html string) string {
divRe := regexp.MustCompile(`(?is)<div[^>]*>(.*?)</div>`)
matches := divRe.FindAllStringSubmatch(html, -1)
best := ""
bestLen := 0
for _, m := range matches {
text := stripTags(m[1])
if len(text) > bestLen {
bestLen = len(text)
best = m[1]
}
}
if best != "" {
return best
}
return html // совсем fallback — весь HTML
}
// === HTML → Markdown конвертер ===
func htmlToMarkdown(html, baseURL string) string {
// Нормализуем переносы строк
html = strings.ReplaceAll(html, "\r\n", "\n")
html = strings.ReplaceAll(html, "\r", "\n")
var sb strings.Builder
converter := &mdConverter{baseURL: baseURL, sb: &sb}
converter.convert(html)
// Чистим результат
result := sb.String()
result = cleanMarkdown(result)
return result
}
type mdConverter struct {
baseURL string
sb *strings.Builder
inPre bool
inCode bool
listDepth int
orderedList bool
listCounter int
}
func (c *mdConverter) convert(html string) {
// Обрабатываем тег за тегом
tagRe := regexp.MustCompile(`(?is)(<[^>]+>)|([^<]+)`)
matches := tagRe.FindAllStringSubmatch(html, -1)
for _, m := range matches {
tag := m[1]
text := m[2]
if tag != "" {
c.handleTag(tag)
} else if text != "" {
c.handleText(text)
}
}
}
func (c *mdConverter) handleTag(tag string) {
tag = strings.TrimSpace(tag)
if strings.HasPrefix(tag, "<!--") {
return
}
lower := strings.ToLower(tag)
closing := strings.HasPrefix(lower, "</")
name := tagName(lower)
switch name {
case "h1":
if !closing {
c.sb.WriteString("\n\n# ")
} else {
c.sb.WriteString("\n\n")
}
case "h2":
if !closing {
c.sb.WriteString("\n\n## ")
} else {
c.sb.WriteString("\n\n")
}
case "h3":
if !closing {
c.sb.WriteString("\n\n### ")
} else {
c.sb.WriteString("\n\n")
}
case "h4", "h5", "h6":
if !closing {
c.sb.WriteString("\n\n#### ")
} else {
c.sb.WriteString("\n\n")
}
case "p":
if closing {
c.sb.WriteString("\n\n")
}
case "br":
c.sb.WriteString(" \n")
case "strong", "b":
c.sb.WriteString("**")
case "em", "i":
c.sb.WriteString("*")
case "code":
if !c.inPre {
c.sb.WriteString("`")
c.inCode = !closing
}
case "pre":
if !closing {
c.inPre = true
// Пробуем определить язык из class
lang := reFind(tag, `class=["'][^"']*language-([a-z]+)["']`)
if lang == "" {
lang = reFind(tag, `class=["'][^"']*lang-([a-z]+)["']`)
}
c.sb.WriteString("\n\n```" + lang + "\n")
} else {
c.inPre = false
c.sb.WriteString("\n```\n\n")
}
case "blockquote":
if !closing {
c.sb.WriteString("\n\n> ")
} else {
c.sb.WriteString("\n\n")
}
case "ul":
if !closing {
c.listDepth++
c.orderedList = false
} else {
c.listDepth--
c.sb.WriteString("\n")
}
case "ol":
if !closing {
c.listDepth++
c.orderedList = true
c.listCounter = 0
} else {
c.listDepth--
c.sb.WriteString("\n")
}
case "li":
if !closing {
indent := strings.Repeat(" ", max(0, c.listDepth-1))
if c.orderedList {
c.listCounter++
c.sb.WriteString(fmt.Sprintf("\n%s%d. ", indent, c.listCounter))
} else {
c.sb.WriteString(fmt.Sprintf("\n%s- ", indent))
}
}
case "a":
if !closing {
href := reFind(tag, `href=["']([^"']+)["']`)
href = resolveURL(href, c.baseURL)
if href != "" {
c.sb.WriteString("[")
// Закрывающий тег допишет текст, потом нужна ссылка
// Упрощение: просто пишем ссылку как обычный текст
// (полноценная обработка требует стека тегов)
_ = href
}
} else {
href := "" // теряем href при таком подходе — нужен стек
_ = href
}
case "img":
alt := reFind(tag, `alt=["']([^"']*)["']`)
src := reFind(tag, `src=["']([^"']+)["']`)
src = resolveURL(src, c.baseURL)
if src != "" {
c.sb.WriteString(fmt.Sprintf("![%s](%s)", alt, src))
}
case "hr":
c.sb.WriteString("\n\n---\n\n")
case "table":
// Таблицы — пропускаем структуру, текст ячеек останется
case "tr":
if closing {
c.sb.WriteString(" |\n")
} else {
c.sb.WriteString("| ")
}
case "th", "td":
if closing {
c.sb.WriteString(" | ")
}
case "div", "section", "article", "main", "span":
// Блочные — просто перенос, inline — ничего
if name != "span" {
if closing {
c.sb.WriteString("\n")
}
}
}
}
func (c *mdConverter) handleText(text string) {
if c.inPre {
c.sb.WriteString(text)
return
}
// Нормализуем пробелы
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
text = strings.TrimSpace(text)
if text != "" {
c.sb.WriteString(htmlEntities(text))
}
}
// === Вспомогательные функции ===
// removeTagContent удаляет тег вместе с содержимым
func removeTagContent(html, tag string) string {
re := regexp.MustCompile(`(?is)<` + tag + `[^>]*>.*?</` + tag + `>`)
return re.ReplaceAllString(html, "")
}
// removeTagAttr удаляет атрибуты по паттерну
func removeTagAttr(html, attrPattern string) string {
re := regexp.MustCompile(`(?i)\s+(?:` + attrPattern + `)=["'][^"']*["']`)
return re.ReplaceAllString(html, "")
}
// stripTags удаляет все HTML теги
func stripTags(html string) string {
re := regexp.MustCompile(`<[^>]+>`)
return re.ReplaceAllString(html, "")
}
// tagName извлекает имя тега из строки типа "<h1 ...>" или "</h1>"
func tagName(tag string) string {
tag = strings.TrimPrefix(tag, "</")
tag = strings.TrimPrefix(tag, "<")
tag = strings.TrimSuffix(tag, ">")
tag = strings.TrimSuffix(tag, "/")
fields := strings.Fields(tag)
if len(fields) == 0 {
return ""
}
return strings.ToLower(fields[0])
}
// reFind возвращает первую захваченную группу regexp
func reFind(s, pattern string) string {
m := regexp.MustCompile(pattern).FindStringSubmatch(s)
if len(m) > 1 {
return m[1]
}
return ""
}
// cleanText чистит строку от HTML и лишних пробелов
func cleanText(s string) string {
s = stripTags(s)
s = htmlEntities(s)
s = strings.TrimSpace(s)
s = regexp.MustCompile(`\s+`).ReplaceAllString(s, " ")
return s
}
// htmlEntities декодирует базовые HTML entities
func htmlEntities(s string) string {
replacer := strings.NewReplacer(
"&amp;", "&",
"&lt;", "<",
"&gt;", ">",
"&quot;", "\"",
"&#39;", "'",
"&apos;", "'",
"&nbsp;", " ",
"&mdash;", "—",
"&ndash;", "",
"&laquo;", "«",
"&raquo;", "»",
"&hellip;", "…",
"&copy;", "©",
"&reg;", "®",
"&trade;", "™",
"&bull;", "•",
"&middot;", "·",
)
s = replacer.Replace(s)
// Числовые entities &#NNNN; — пропускаем (редко критичны)
return s
}
// resolveURL разрешает относительный URL
func resolveURL(href, base string) string {
if href == "" {
return ""
}
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
return href
}
u, err := url.Parse(base)
if err != nil {
return href
}
ref, err := url.Parse(href)
if err != nil {
return href
}
return u.ResolveReference(ref).String()
}
// cleanMarkdown чистит результат конвертации
func cleanMarkdown(s string) string {
// Убираем тройные+ пустые строки
s = regexp.MustCompile(`\n{4,}`).ReplaceAllString(s, "\n\n\n")
// Убираем пробелы в конце строк
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = strings.TrimRight(l, " \t")
}
s = strings.Join(lines, "\n")
return strings.TrimSpace(s) + "\n"
}
// slugify превращает заголовок в имя файла
func slugify(title string) string {
// Транслитерируем кириллицу
var sb strings.Builder
for _, r := range strings.ToLower(title) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
sb.WriteRune(r)
case r == ' ' || r == '-' || r == '_':
sb.WriteRune('-')
default:
if t := translitRune(r); t != "" {
sb.WriteString(strings.ToLower(t))
}
}
}
result := sb.String()
// Убираем повторные дефисы
result = regexp.MustCompile(`-+`).ReplaceAllString(result, "-")
result = strings.Trim(result, "-")
if result == "" {
result = "article"
}
// Ограничиваем длину
runes := []rune(result)
if len(runes) > 60 {
result = string(runes[:60])
result = strings.TrimRight(result, "-")
}
return result
}
// isOnlySpaces проверяет что строка только из пробельных символов
func isOnlySpaces(s string) bool {
for _, r := range s {
if !unicode.IsSpace(r) {
return false
}
}
return true
}
// SaveFetchResult сохраняет результат в файл
func SaveFetchResult(result *FetchResult, dir string) (string, error) {
path := result.Filename
if dir != "" {
path = dir + string(os.PathSeparator) + result.Filename
}
if err := writeFileImpl(path, result.Markdown); err != nil {
return "", err
}
return path, nil
}

28
file.go Normal file
View file

@ -0,0 +1,28 @@
package main
import (
"os"
"strings"
"time"
)
// save сохраняет текущий файл
func (m *EditorModel) save() error {
content := strings.Join(m.lines, "\n")
if err := writeFileImpl(m.filepath, content); err != nil {
return err
}
m.dirty = false
m.lastSave = time.Now()
return nil
}
// writeFileImpl записывает строку в файл
func writeFileImpl(path, content string) error {
return os.WriteFile(path, []byte(content), 0644)
}
// userHomeDir возвращает домашнюю директорию
func userHomeDir() (string, error) {
return os.UserHomeDir()
}

54
file_io.go Normal file
View file

@ -0,0 +1,54 @@
package main
import (
"os"
"path/filepath"
"strings"
)
// LoadFile загружает содержимое файла
func LoadFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil // Новый файл
}
return "", err
}
return string(data), nil
}
// DetectFileType определяет тип файла по расширению
func DetectFileType(path string) string {
ext := strings.TrimPrefix(filepath.Ext(path), ".")
ext = strings.ToLower(ext)
switch ext {
case "md", "markdown":
return "md"
case "txt":
return "txt"
case "go":
return "go"
case "py":
return "py"
case "js", "mjs", "cjs":
return "js"
case "ts", "tsx":
return "ts"
case "rs":
return "rs"
case "c", "h":
return "c"
case "cpp", "cc", "cxx", "hpp":
return "cpp"
case "java":
return "java"
case "sql":
return "sql"
case "sh", "bash":
return "sh"
default:
return "txt"
}
}

307
filetree.go Normal file
View file

@ -0,0 +1,307 @@
package main
import (
"os"
"path/filepath"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
)
// === Стили файлового дерева ===
var (
styleTreeDir = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Bold(true)
styleTreeFile = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
styleTreeSelected = lipgloss.NewStyle().Background(lipgloss.Color("237")).Foreground(lipgloss.Color("15")).Bold(true)
styleTreeHeader = lipgloss.NewStyle().Background(lipgloss.Color("8")).Foreground(lipgloss.Color("15")).Bold(true)
styleTreeCurrent = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
)
// FileNode — узел файлового дерева
type FileNode struct {
name string
path string
isDir bool
expanded bool
depth int
children []*FileNode
}
// FileTree — состояние панели файлового дерева
type FileTree struct {
root string // корневая директория
nodes []*FileNode // плоский список видимых узлов
cursor int // индекс выбранного узла
scroll int // смещение прокрутки
focused bool // фокус на этой панели
}
// NewFileTree создаёт новое дерево для директории rootDir
func NewFileTree(rootDir string) FileTree {
ft := FileTree{
root: rootDir,
cursor: 0,
scroll: 0,
focused: false,
}
ft.reload()
return ft
}
// reload перечитывает директорию и обновляет плоский список
func (ft *FileTree) reload() {
rootNode := &FileNode{
name: filepath.Base(ft.root),
path: ft.root,
isDir: true,
expanded: true,
depth: 0,
}
loadChildren(rootNode)
ft.nodes = flattenTree(rootNode)
}
// loadChildren рекурсивно загружает дочерние узлы
func loadChildren(node *FileNode) {
if !node.isDir {
return
}
entries, err := os.ReadDir(node.path)
if err != nil {
return
}
// Сортируем: сначала папки, потом файлы, алфавитно
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir() != entries[j].IsDir() {
return entries[i].IsDir()
}
return entries[i].Name() < entries[j].Name()
})
node.children = nil
for _, e := range entries {
// Пропускаем скрытые файлы и типичный мусор
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
if name == "node_modules" || name == "vendor" || name == "__pycache__" {
continue
}
child := &FileNode{
name: name,
path: filepath.Join(node.path, name),
isDir: e.IsDir(),
depth: node.depth + 1,
}
node.children = append(node.children, child)
}
}
// flattenTree возвращает плоский список видимых узлов
func flattenTree(node *FileNode) []*FileNode {
result := []*FileNode{node}
if node.isDir && node.expanded {
for _, child := range node.children {
result = append(result, flattenTree(child)...)
}
}
return result
}
// toggle разворачивает/сворачивает директорию под курсором
func (ft *FileTree) toggle() {
if ft.cursor >= len(ft.nodes) {
return
}
node := ft.nodes[ft.cursor]
if !node.isDir {
return
}
node.expanded = !node.expanded
if node.expanded && len(node.children) == 0 {
loadChildren(node)
}
ft.rebuildFlat()
}
// rebuildFlat пересобирает плоский список из корня
func (ft *FileTree) rebuildFlat() {
if len(ft.nodes) == 0 {
return
}
root := ft.nodes[0]
ft.nodes = flattenTree(root)
if ft.cursor >= len(ft.nodes) {
ft.cursor = len(ft.nodes) - 1
}
}
// moveUp перемещает курсор вверх
func (ft *FileTree) moveUp(n int) {
ft.cursor -= n
if ft.cursor < 0 {
ft.cursor = 0
}
ft.adjustScroll(0)
}
// moveDown перемещает курсор вниз
func (ft *FileTree) moveDown(n int) {
ft.cursor += n
if ft.cursor >= len(ft.nodes) {
ft.cursor = len(ft.nodes) - 1
}
ft.adjustScroll(0)
}
// adjustScroll корректирует scroll чтобы cursor был виден
func (ft *FileTree) adjustScroll(height int) {
if height <= 0 {
return
}
if ft.cursor < ft.scroll {
ft.scroll = ft.cursor
}
if ft.cursor >= ft.scroll+height {
ft.scroll = ft.cursor - height + 1
}
}
// selectedPath возвращает путь выбранного узла (или "" если курсор вне границ)
func (ft *FileTree) selectedPath() string {
if ft.cursor < 0 || ft.cursor >= len(ft.nodes) {
return ""
}
return ft.nodes[ft.cursor].path
}
// selectedIsDir возвращает true если выбранный узел — директория
func (ft *FileTree) selectedIsDir() bool {
if ft.cursor < 0 || ft.cursor >= len(ft.nodes) {
return false
}
return ft.nodes[ft.cursor].isDir
}
// renderFileTree рендерит панель файлового дерева
func renderFileTree(m *EditorModel) string {
ft := &m.fileTree
width := m.treeWidth
height := m.height - 3
var output strings.Builder
// Заголовок
title := filepath.Base(ft.root)
if len(title) > width-4 {
title = title[:width-4]
}
headerStyle := styleTreeHeader.Copy().Width(width).Padding(0, 1)
if ft.focused {
headerStyle = headerStyle.Background(lipgloss.Color("4"))
}
output.WriteString(headerStyle.Render(" 📁 " + title))
output.WriteString("\n")
ft.adjustScroll(height)
// Список файлов
for i := ft.scroll; i < ft.scroll+height && i < len(ft.nodes); i++ {
node := ft.nodes[i]
line := renderFileNode(node, m.filepath, width)
if i == ft.cursor && ft.focused {
line = styleTreeSelected.Width(width).Render(line)
} else if i == ft.cursor {
// Курсор без фокуса — подчёркиваем текущий элемент
line = lipgloss.NewStyle().Width(width).Foreground(lipgloss.Color("245")).Render(line)
} else {
line = lipgloss.NewStyle().Width(width).Render(line)
}
output.WriteString(line)
output.WriteString("\n")
}
// Заполнить пустые строки
rendered := min(height, max(0, len(ft.nodes)-ft.scroll))
for i := rendered; i < height; i++ {
output.WriteString(strings.Repeat(" ", width) + "\n")
}
// Нижняя строка с подсказкой
hint := " Enter:открыть Tab:фокус </>:ширина"
if len(hint) > width {
hint = hint[:width]
}
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width)
output.WriteString(hintStyle.Render(hint))
return output.String()
}
// renderFileNode рендерит одну строку узла
func renderFileNode(node *FileNode, currentFile string, width int) string {
// Отступ по глубине (корень не отступаем)
indent := ""
if node.depth > 0 {
indent = strings.Repeat(" ", node.depth-1)
}
var icon, name string
if node.isDir {
if node.expanded {
icon = "▾ "
} else {
icon = "▸ "
}
name = styleTreeDir.Render(node.name)
} else {
icon = " "
// Выделяем текущий открытый файл
if node.path == currentFile {
name = styleTreeCurrent.Render("● " + node.name)
} else {
name = styleTreeFile.Render(node.name)
}
}
line := indent + icon + name
// Обрезаем если не влезает (с учётом ANSI-кодов это приблизительно)
visibleLen := len(indent) + len(icon) + len(node.name) + 2
if visibleLen > width {
// Обрезаем имя
maxName := width - len(indent) - len(icon) - 1
if maxName < 3 {
maxName = 3
}
truncated := node.name
if len(truncated) > maxName {
truncated = truncated[:maxName-1] + "…"
}
if node.isDir {
name = styleTreeDir.Render(truncated)
} else if node.path == currentFile {
name = styleTreeCurrent.Render("● " + truncated)
} else {
name = styleTreeFile.Render(truncated)
}
line = indent + icon + name
}
return line
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

27
go.mod Normal file
View file

@ -0,0 +1,27 @@
module grimoir
go 1.21
require (
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/lipgloss v0.10.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

41
go.sum Normal file
View file

@ -0,0 +1,41 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

275
help.go Normal file
View file

@ -0,0 +1,275 @@
package main
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// === Help экран ===
var helpSections = []helpSection{
{
title: "РЕЖИМЫ",
items: []helpItem{
{"i / a / A", "INSERT: вставить / после курсора / в конец строки"},
{"I / o / O", "INSERT: в начало / новая строка ниже / выше"},
{"Esc", "вернуться в NORMAL"},
{":", "режим команд"},
{"/", "режим поиска"},
},
},
{
title: "НАВИГАЦИЯ (NORMAL)",
items: []helpItem{
{"h j k l", "← ↓ ↑ →"},
{"w / b / e", "слово вперёд / назад / конец слова"},
{"0 / ^ / $", "начало / первый символ / конец строки"},
{"gg / G", "начало / конец файла"},
{"5G / 42G", "перейти на строку 5 / 42"},
{"Ctrl+D / Ctrl+U", "страница вниз / вверх"},
},
},
{
title: "НАВИГАЦИЯ (INSERT)",
items: []helpItem{
{"↑ ↓ ← →", "перемещение курсора"},
{"Home / End", "начало / конец строки"},
{"Delete", "удалить символ под курсором"},
},
},
{
title: "ПОИСК И ЗАМЕНА",
items: []helpItem{
{"/pattern", "поиск по файлу"},
{"n / N", "следующее / предыдущее совпадение"},
{"f{c} / F{c}", "символ c вправо / влево в строке"},
{"t{c} / T{c}", "до символа c вправо / влево"},
{"; / ,", "повторить f/t / в обратном направлении"},
{"%", "парная скобка () [] {}"},
{":s/old/new/", "заменить первое вхождение"},
{":s/old/new/g", "заменить все вхождения в файле"},
},
},
{
title: "РЕДАКТИРОВАНИЕ",
items: []helpItem{
{"x", "удалить символ под курсором"},
{"dd / 3dd", "удалить строку / 3 строки"},
{"yy", "скопировать строку"},
{"p / P", "вставить ниже / выше"},
{"u", "отменить (undo)"},
{"Ctrl+R", "повторить (redo)"},
},
},
{
title: "ФАЙЛ",
items: []helpItem{
{":w", "сохранить"},
{":w path", "сохранить копию в path"},
{":saveas path", "сохранить как (переключиться на path)"},
{":q / :q!", "выйти / выйти без сохранения"},
{":wq / :x", "сохранить и выйти"},
{":e path", "открыть файл"},
{":e", "перезагрузить текущий файл"},
{":new", "новый пустой буфер"},
{":new path", "создать / открыть файл"},
{":N", "перейти на строку N"},
},
},
{
title: "ПАНЕЛИ",
items: []helpItem{
{"Ctrl+B", "показать / скрыть файловое дерево"},
{"Tab", "переключить фокус между панелями"},
{"< / >", "уменьшить / увеличить ширину панели"},
{"Пробел", "показать / скрыть preview (только .md)"},
{":treewidth N", "установить ширину дерева"},
{":previewwidth N", "установить ширину preview"},
},
},
{
title: "ФАЙЛОВОЕ ДЕРЕВО (при фокусе)",
items: []helpItem{
{"j / k", "вниз / вверх"},
{"l / Enter", "открыть файл / развернуть папку"},
{"h", "свернуть папку / к родителю"},
{"gg / G", "начало / конец списка"},
{"r", "обновить дерево"},
{"Esc", "вернуть фокус редактору"},
},
},
{
title: "ЭКСПОРТ И ЗАГРУЗКА",
items: []helpItem{
{":export pdf", "экспорт в PDF (рядом с файлом)"},
{":export pdf path", "экспорт в указанный путь"},
{":fetch URL", "скачать статью и открыть"},
{"grimoir URL", "запустить с URL (скачать сразу)"},
},
},
{
title: "НАСТРОЙКИ",
items: []helpItem{
{":set", "показать все настройки"},
{":set number / nonumber", "номера строк вкл / выкл"},
{":set tab_width=4", "ширина таба"},
{":set autosave=60", "автосейв каждые N секунд (0=выкл)"},
{":config show", "откуда загружен конфиг"},
{":config paths", "пути поиска конфига"},
{":config write", "записать дефолтный конфиг"},
{":config reload", "перезагрузить конфиг"},
},
},
{
title: "HELP",
items: []helpItem{
{":help / :h / ?", "этот экран"},
{"q / Esc", "закрыть help"},
},
},
}
type helpSection struct {
title string
items []helpItem
}
type helpItem struct {
key string
desc string
}
// renderHelp рендерит help экран
func renderHelp(m *EditorModel) string {
var out strings.Builder
// Стили
headerStyle := lipgloss.NewStyle().
Background(lipgloss.Color("4")).
Foreground(lipgloss.Color("15")).
Bold(true).
Width(m.width).
Padding(0, 2)
sectionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("11")).
Bold(true)
keyStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("81")).
Width(22)
descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("252"))
dimStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("8"))
footerStyle := lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Foreground(lipgloss.Color("8")).
Width(m.width).
Padding(0, 2)
// Заголовок
out.WriteString(headerStyle.Render(" grimoir — справка по командам"))
out.WriteString("\n")
// Считаем сколько строк доступно
available := m.height - 3 // заголовок + footer + отступ
lineCount := 0
// Две колонки
colWidth := m.width / 2
leftCol := &strings.Builder{}
rightCol := &strings.Builder{}
mid := len(helpSections) / 2
if len(helpSections)%2 != 0 {
mid++
}
renderColumn := func(sections []helpSection, col *strings.Builder) {
for _, sec := range sections {
col.WriteString(sectionStyle.Render(" "+sec.title) + "\n")
for _, item := range sec.items {
key := keyStyle.Render(" " + item.key)
desc := descStyle.Render(item.desc)
col.WriteString(key + dimStyle.Render(" ") + desc + "\n")
}
col.WriteString("\n")
}
}
renderColumn(helpSections[:mid], leftCol)
renderColumn(helpSections[mid:], rightCol)
// Объединяем колонки построчно
leftLines := strings.Split(leftCol.String(), "\n")
rightLines := strings.Split(rightCol.String(), "\n")
maxLines := len(leftLines)
if len(rightLines) > maxLines {
maxLines = len(rightLines)
}
for i := 0; i < maxLines && lineCount < available; i++ {
left := ""
right := ""
if i < len(leftLines) {
left = leftLines[i]
}
if i < len(rightLines) {
right = rightLines[i]
}
// Обрезаем левую колонку до colWidth видимых символов
leftVis := visibleLen(left)
if leftVis < colWidth {
left += strings.Repeat(" ", colWidth-leftVis)
} else if leftVis > colWidth {
left = truncateANSI(left, colWidth)
}
out.WriteString(left)
out.WriteString(right)
out.WriteString("\n")
lineCount++
}
// Заполняем оставшееся место
for lineCount < available {
out.WriteString("\n")
lineCount++
}
// Footer
out.WriteString(footerStyle.Render(fmt.Sprintf(
" q/Esc — закрыть │ grimoir v1.0 │ %d строк в файле",
len(m.lines),
)))
return out.String()
}
// visibleLen возвращает видимую длину строки (без ANSI кодов)
func visibleLen(s string) int {
visible := 0
inEscape := false
for _, b := range s {
if b == '\x1b' {
inEscape = true
continue
}
if inEscape {
if b == 'm' {
inEscape = false
}
continue
}
visible++
}
return visible
}

639
keybindings.go Normal file
View file

@ -0,0 +1,639 @@
package main
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// handleKey диспетчеризирует по фокусу и режиму
func (m *EditorModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
// === Help экран — обрабатываем первым ===
if m.showHelp {
if key == "q" || key == "esc" || key == "?" || key == ":help" {
m.showHelp = false
}
return m, nil
}
// === Глобальные клавиши ===
if key == m.config.KeyForAction("quit") || key == m.config.KeyForAction("quit_alt") {
return m, tea.Quit
}
if key == m.config.KeyForAction("toggle_tree") {
m.showFileTree = !m.showFileTree
if m.showFileTree {
m.fileTree.reload()
m.focus = FocusFileTree
m.fileTree.focused = true
m.SetStatusMsg("File tree ON")
} else {
m.focus = FocusEditor
m.fileTree.focused = false
m.SetStatusMsg("File tree OFF")
}
m.clampPanelWidths()
m.updatePreview()
return m, nil
}
if key == m.config.KeyForAction("focus_switch") {
if m.showFileTree {
if m.focus == FocusEditor {
m.focus = FocusFileTree
m.fileTree.focused = true
m.SetStatusMsg("Focus: file tree")
} else {
m.focus = FocusEditor
m.fileTree.focused = false
m.SetStatusMsg("")
}
}
return m, nil
}
if key == m.config.KeyForAction("panel_shrink") || key == m.config.KeyForAction("panel_grow") {
delta := 2
if key == m.config.KeyForAction("panel_shrink") {
delta = -2
}
if m.focus == FocusFileTree && m.showFileTree {
m.resizeTree(delta)
} else if m.showPreview && m.fileType == "md" {
m.resizePreview(delta)
}
m.updatePreview()
return m, nil
}
// === Фокус на дереве ===
if m.focus == FocusFileTree {
return m.handleFileTreeKey(msg)
}
// === Фокус на редакторе ===
switch m.mode {
case NORMAL:
return m.handleNormalMode(msg)
case INSERT:
return m.handleInsertMode(msg)
case COMMAND:
return m.handleCommandMode(msg)
case SEARCH:
return m.handleSearchMode(msg)
}
return m, nil
}
// handleFileTreeKey — клавиши при фокусе на дереве
func (m *EditorModel) handleFileTreeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
ft := &m.fileTree
switch key {
case "j", "down":
ft.moveDown(m.parseNumberPrefix())
case "k", "up":
ft.moveUp(m.parseNumberPrefix())
case "g":
if m.pendingKeys == "g" {
ft.cursor = 0
ft.scroll = 0
m.pendingKeys = ""
} else {
m.pendingKeys = "g"
}
case "G":
ft.cursor = len(ft.nodes) - 1
ft.adjustScroll(m.height - 3)
m.pendingKeys = ""
case "enter", "l":
if ft.selectedIsDir() {
ft.toggle()
} else if path := ft.selectedPath(); path != "" {
m.openFile(path)
m.focus = FocusEditor
m.fileTree.focused = false
}
case "h":
if ft.cursor < len(ft.nodes) {
node := ft.nodes[ft.cursor]
if node.isDir && node.expanded {
ft.toggle()
} else if node.depth > 0 {
for i := ft.cursor - 1; i >= 0; i-- {
if ft.nodes[i].depth < node.depth {
ft.cursor = i
break
}
}
}
}
case "r":
ft.reload()
m.SetStatusMsg("File tree refreshed")
case "esc":
m.focus = FocusEditor
m.fileTree.focused = false
m.SetStatusMsg("")
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
m.numPrefix += key
m.SetStatusMsg(m.numPrefix)
case "0":
if m.numPrefix != "" {
m.numPrefix += key
m.SetStatusMsg(m.numPrefix)
}
}
return m, nil
}
// handleNormalMode — NORMAL режим
func (m *EditorModel) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
// Числовые префиксы
if key >= "1" && key <= "9" && m.pendingKeys == "" {
m.numPrefix += key
m.SetStatusMsg(m.numPrefix)
return m, nil
}
if key == "0" && m.numPrefix != "" {
m.numPrefix += key
m.SetStatusMsg(m.numPrefix)
return m, nil
}
switch key {
case m.config.KeyForAction("redo"):
m.Redo()
case ":":
m.mode = COMMAND
m.commandBuffer = ""
m.SetStatusMsg(":")
case "/":
m.startSearch()
case "?":
m.showHelp = true
return m, nil
case m.config.KeyForAction("toggle_preview"):
if m.fileType == "md" {
m.showPreview = !m.showPreview
if m.showPreview {
m.SetStatusMsg("Preview ON")
} else {
m.SetStatusMsg("Preview OFF")
}
m.clampPanelWidths()
}
default:
if cmd := m.handleModeSwitch(key); cmd != nil {
m.updatePreview()
return m, cmd
}
m.handleMovement(key)
m.handleEditing(key)
m.handleSearchKeys(key)
}
m.updatePreview()
return m, nil
}
func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
switch key {
case "i":
m.mode = INSERT
m.SetStatusMsg("-- INSERT --")
case "a":
m.mode = INSERT
// 'a' — после курсора: сдвигаем на одну руну вправо
line := []rune(m.lines[m.cursorY])
if m.cursorX < len(line) {
m.cursorX++
}
m.SetStatusMsg("-- INSERT --")
case "A":
m.mode = INSERT
m.cursorX = len([]rune(m.lines[m.cursorY]))
m.SetStatusMsg("-- INSERT --")
case "I":
m.mode = INSERT
m.cursorX = 0
m.SetStatusMsg("-- INSERT --")
case "o":
m.saveUndoSnapshot()
m.insertLine(m.cursorY + 1)
m.cursorY++
m.cursorX = 0
m.mode = INSERT
m.MarkDirty()
m.SetStatusMsg("-- INSERT --")
case "O":
m.saveUndoSnapshot()
m.insertLine(m.cursorY)
m.cursorX = 0
m.mode = INSERT
m.MarkDirty()
m.SetStatusMsg("-- INSERT --")
default:
return nil
}
return tea.Batch()
}
func (m *EditorModel) handleMovement(key string) {
switch key {
case "h", "left":
count := m.parseNumberPrefix()
m.cursorX -= count
m.CursorWithinBounds()
case "j", "down":
m.moveByLines(m.parseNumberPrefix(), 1)
case "k", "up":
m.moveByLines(m.parseNumberPrefix(), -1)
case "l", "right":
count := m.parseNumberPrefix()
m.cursorX += count
m.CursorWithinBounds()
case "w":
m.moveByWords(m.parseNumberPrefix(), true)
case "b":
m.moveByWords(m.parseNumberPrefix(), false)
case "e":
count := m.parseNumberPrefix()
for i := 0; i < count; i++ {
m.moveNextWord()
if m.cursorX > 0 {
m.cursorX--
}
}
case "$":
m.numPrefix = ""
m.cursorX = len([]rune(m.lines[m.cursorY]))
case "^", "0":
m.numPrefix = ""
m.cursorX = 0
case "g":
if m.pendingKeys == "g" {
m.cursorY = 0
m.cursorX = 0
m.pendingKeys = ""
} else {
m.pendingKeys = "g"
}
case "G":
count := m.parseNumberPrefix()
if count == 1 && m.numPrefix == "" {
m.cursorY = len(m.lines) - 1
} else if count > 0 && count <= len(m.lines) {
m.cursorY = count - 1
}
m.cursorX = 0
m.pendingKeys = ""
case "%":
m.numPrefix = ""
m.findMatchingBracket()
case ";":
m.repeatLastFind(false)
case ",":
m.repeatLastFind(true)
case "n":
m.nextMatch()
case "N":
m.prevMatch()
case "f", "F", "t", "T":
if m.pendingKeys == "" {
m.pendingKeys = key
}
}
}
func (m *EditorModel) handleEditing(key string) {
switch key {
case "x":
m.saveUndoSnapshot()
m.deleteCharAt(m.cursorY, m.cursorX)
m.CursorWithinBounds()
m.MarkDirty()
case "d":
if m.pendingKeys == "d" {
count := m.parseNumberPrefix()
m.saveUndoSnapshot()
for i := 0; i < count; i++ {
m.deleteLine(m.cursorY)
}
m.CursorWithinBounds()
m.MarkDirty()
m.pendingKeys = ""
} else if m.pendingKeys == "" {
m.pendingKeys = "d"
}
case "y":
if m.pendingKeys == "y" {
m.clipboard = m.lines[m.cursorY]
m.SetStatusMsg("Line yanked")
m.pendingKeys = ""
} else if m.pendingKeys == "" {
m.pendingKeys = "y"
}
case "p":
if m.clipboard != "" {
m.saveUndoSnapshot()
m.insertLineAfter(m.cursorY, m.clipboard)
m.cursorY++
m.MarkDirty()
}
case "P":
if m.clipboard != "" {
m.saveUndoSnapshot()
m.insertLine(m.cursorY)
m.lines[m.cursorY] = m.clipboard
m.MarkDirty()
}
case "u":
m.Undo()
}
}
func (m *EditorModel) handleSearchKeys(key string) {
if m.pendingKeys == "f" || m.pendingKeys == "F" || m.pendingKeys == "t" || m.pendingKeys == "T" {
// Берём первую руну из нажатой клавиши
runes := []rune(key)
if len(runes) == 1 {
char := runes[0]
switch m.pendingKeys {
case "f":
m.findCharInLine(char, true)
case "F":
m.findCharInLine(char, false)
case "t":
m.findCharToLine(char, true)
case "T":
m.findCharToLine(char, false)
}
m.pendingKeys = ""
}
}
}
// handleInsertMode — INSERT режим с полной поддержкой Unicode и навигации
func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
switch key {
case "esc":
m.mode = NORMAL
// В NORMAL курсор не может стоять после последнего символа
line := []rune(m.lines[m.cursorY])
if m.cursorX > 0 && m.cursorX >= len(line) {
m.cursorX = len(line) - 1
}
if m.cursorX < 0 {
m.cursorX = 0
}
m.SetStatusMsg("")
case "up":
m.moveByLines(1, -1)
case "down":
m.moveByLines(1, 1)
case "left":
if m.cursorX > 0 {
m.cursorX--
} else if m.cursorY > 0 {
// Переходим на конец предыдущей строки
m.cursorY--
m.cursorX = len([]rune(m.lines[m.cursorY]))
}
case "right":
line := []rune(m.lines[m.cursorY])
if m.cursorX < len(line) {
m.cursorX++
} else if m.cursorY < len(m.lines)-1 {
// Переходим на начало следующей строки
m.cursorY++
m.cursorX = 0
}
case "home":
m.cursorX = 0
case "end":
m.cursorX = len([]rune(m.lines[m.cursorY]))
case "enter":
m.saveUndoSnapshot()
m.splitLineRune(m.cursorY, m.cursorX)
m.cursorY++
m.cursorX = 0
m.MarkDirty()
case "backspace":
m.saveUndoSnapshot()
if m.cursorX > 0 {
m.deleteCharAt(m.cursorY, m.cursorX-1)
m.cursorX--
m.MarkDirty()
} else if m.cursorY > 0 {
// Merge с предыдущей строкой
prevLen := len([]rune(m.lines[m.cursorY-1]))
m.lines[m.cursorY-1] += m.lines[m.cursorY]
m.lines = append(m.lines[:m.cursorY], m.lines[m.cursorY+1:]...)
m.cursorY--
m.cursorX = prevLen
m.MarkDirty()
}
case "delete":
m.saveUndoSnapshot()
line := []rune(m.lines[m.cursorY])
if m.cursorX < len(line) {
m.deleteCharAt(m.cursorY, m.cursorX)
m.MarkDirty()
} else if m.cursorY < len(m.lines)-1 {
// Merge со следующей строкой
m.lines[m.cursorY] += m.lines[m.cursorY+1]
m.lines = append(m.lines[:m.cursorY+1], m.lines[m.cursorY+2:]...)
m.MarkDirty()
}
case "tab":
m.saveUndoSnapshot()
m.insertRuneAt(m.cursorY, m.cursorX, []rune(strings.Repeat(" ", m.tabWidth)))
m.cursorX += m.tabWidth
m.MarkDirty()
default:
// Любой печатаемый символ — включая кириллицу и другие Unicode
// Пробел приходит как tea.KeySpace, остальные печатаемые — tea.KeyRunes
runes := []rune(msg.String())
isPrintable := msg.Type == tea.KeyRunes ||
msg.Type == tea.KeySpace
if len(runes) == 1 && runes[0] >= 32 && isPrintable {
m.saveUndoSnapshot()
m.insertRuneAt(m.cursorY, m.cursorX, runes)
m.cursorX++
m.MarkDirty()
}
}
m.CursorWithinBounds()
m.updatePreview()
return m, nil
}
// handleCommandMode — режим команд
func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = NORMAL
m.commandBuffer = ""
m.SetStatusMsg("")
case "enter":
cmd := m.executeCommand(m.commandBuffer)
m.mode = NORMAL
m.commandBuffer = ""
return m, cmd
case "backspace":
runes := []rune(m.commandBuffer)
if len(runes) > 0 {
m.commandBuffer = string(runes[:len(runes)-1])
}
m.SetStatusMsg(":" + m.commandBuffer)
default:
// Принимаем Unicode в командном буфере
runes := []rune(msg.String())
if len(runes) == 1 && (runes[0] >= 32 || msg.Type == tea.KeyRunes) {
m.commandBuffer += string(runes)
m.SetStatusMsg(":" + m.commandBuffer)
}
}
return m, nil
}
// handleSearchMode — режим поиска
func (m *EditorModel) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = NORMAL
m.searchBuffer = ""
m.SetStatusMsg("")
case "enter":
m.performSearch(m.searchBuffer)
m.mode = NORMAL
case "backspace":
runes := []rune(m.searchBuffer)
if len(runes) > 0 {
m.searchBuffer = string(runes[:len(runes)-1])
}
m.SetStatusMsg("/" + m.searchBuffer)
default:
runes := []rune(msg.String())
if len(runes) == 1 && (runes[0] >= 32 || msg.Type == tea.KeyRunes) {
m.searchBuffer += string(runes)
m.SetStatusMsg("/" + m.searchBuffer)
}
}
return m, nil
}
// === Навигация ===
func (m *EditorModel) moveNextWord() {
line := []rune(m.lines[m.cursorY])
for m.cursorX < len(line) && line[m.cursorX] == ' ' {
m.cursorX++
}
for m.cursorX < len(line) && line[m.cursorX] != ' ' {
m.cursorX++
}
}
func (m *EditorModel) movePrevWord() {
line := []rune(m.lines[m.cursorY])
if m.cursorX > 0 {
m.cursorX--
}
for m.cursorX > 0 && line[m.cursorX] == ' ' {
m.cursorX--
}
for m.cursorX > 0 && line[m.cursorX-1] != ' ' {
m.cursorX--
}
}
// === Редактирование (rune-safe) ===
// insertRuneAt вставляет руны в строку на позиции pos (в рунах)
func (m *EditorModel) insertRuneAt(lineIdx, pos int, runes []rune) {
line := []rune(m.lines[lineIdx])
if pos < 0 {
pos = 0
}
if pos > len(line) {
pos = len(line)
}
newLine := make([]rune, 0, len(line)+len(runes))
newLine = append(newLine, line[:pos]...)
newLine = append(newLine, runes...)
newLine = append(newLine, line[pos:]...)
m.lines[lineIdx] = string(newLine)
}
// deleteCharAt удаляет символ на позиции pos (в рунах)
func (m *EditorModel) deleteCharAt(lineIdx, pos int) {
line := []rune(m.lines[lineIdx])
if pos < 0 || pos >= len(line) {
return
}
m.lines[lineIdx] = string(append(line[:pos], line[pos+1:]...))
}
// splitLineRune разбивает строку на позиции pos (в рунах)
func (m *EditorModel) splitLineRune(lineIdx, pos int) {
line := []rune(m.lines[lineIdx])
if pos < 0 {
pos = 0
}
if pos > len(line) {
pos = len(line)
}
m.insertLine(lineIdx + 1)
m.lines[lineIdx] = string(line[:pos])
m.lines[lineIdx+1] = string(line[pos:])
}
func (m *EditorModel) insertChar(ch string) {
m.insertRuneAt(m.cursorY, m.cursorX, []rune(ch))
}
func (m *EditorModel) deleteChar() {
m.deleteCharAt(m.cursorY, m.cursorX)
}
func (m *EditorModel) splitLine(idx, pos int) {
m.splitLineRune(idx, pos)
}
func (m *EditorModel) insertLine(idx int) {
newLines := make([]string, 0, len(m.lines)+1)
newLines = append(newLines, m.lines[:idx]...)
newLines = append(newLines, "")
newLines = append(newLines, m.lines[idx:]...)
m.lines = newLines
}
func (m *EditorModel) insertLineAfter(idx int, content string) {
m.insertLine(idx + 1)
m.lines[idx+1] = content
}
func (m *EditorModel) deleteLine(idx int) {
if len(m.lines) > 1 {
m.lines = append(m.lines[:idx], m.lines[idx+1:]...)
} else {
m.lines = []string{""}
}
}

105
main.go Normal file
View file

@ -0,0 +1,105 @@
package main
import (
"flag"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
var filePath string
var configPath string
var writeConfig bool
flag.StringVar(&filePath, "f", "", "File to edit")
flag.StringVar(&configPath, "config", "", "Path to config file (overrides auto-search)")
flag.BoolVar(&writeConfig, "write-config", false, "Write default config to standard location and exit")
flag.Parse()
// Поддержка позиционного аргумента: grimoir file.txt или grimoir https://...
if filePath == "" && flag.NArg() > 0 {
filePath = flag.Arg(0)
}
// Загружаем конфиг
var cfg Config
if configPath != "" {
data, err := os.ReadFile(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading config %s: %v\n", configPath, err)
os.Exit(1)
}
cfg = DefaultConfig()
if err := parseConfig(data, &cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing config %s: %v\n", configPath, err)
os.Exit(1)
}
cfg.LoadedFrom = configPath
} else {
cfg = LoadConfig()
}
// --write-config: записать дефолтный конфиг и выйти
if writeConfig {
paths := ConfigPaths()
target := ""
for _, p := range paths {
if p != paths[0] {
target = p
break
}
}
if target == "" {
target = "md-editor.conf"
}
if err := WriteDefaultConfig(target); err != nil {
fmt.Fprintf(os.Stderr, "Error writing config: %v\n", err)
os.Exit(1)
}
fmt.Printf("Default config written to: %s\n", target)
os.Exit(0)
}
// === Если передан URL — скачиваем и конвертируем в Markdown ===
if IsURL(filePath) {
fmt.Fprintf(os.Stderr, "Fetching: %s\n", filePath)
result, err := FetchAndConvert(filePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching URL: %v\n", err)
os.Exit(1)
}
// Сохраняем рядом с текущей директорией
savedPath, err := SaveFetchResult(result, "")
if err != nil {
fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Saved as: %s\n", savedPath)
filePath = savedPath
}
// === Обычный режим — открываем файл ===
if filePath == "" {
filePath = "untitled.md"
}
content, err := LoadFile(filePath)
if err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error loading file: %v\n", err)
os.Exit(1)
}
fileType := DetectFileType(filePath)
model := NewEditor(filePath, fileType, content, cfg)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

457
model.go Normal file
View file

@ -0,0 +1,457 @@
package main
import (
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
type VimMode int
const (
NORMAL VimMode = iota
INSERT
VISUAL
COMMAND
SEARCH
REPLACE
)
// PanelFocus — какая панель принимает клавиши
type PanelFocus int
const (
FocusEditor PanelFocus = iota
FocusFileTree
)
// undoSnapshot — снимок состояния для undo/redo
type undoSnapshot struct {
lines []string
cursorX int
cursorY int
}
// Минимальные ширины панелей
const (
minEditorWidth = 20
minTreeWidth = 15
minPreviewWidth = 20
)
type EditorModel struct {
// Контент
lines []string
cursorX int
cursorY int
// Vim режимы
mode VimMode
pendingKeys string
selection struct {
startX, startY int
endX, endY int
}
// Файл
filepath string
fileType string
dirty bool
lastSave time.Time
statusMsg string
// UI — размеры
width int
height int
treeWidth int
previewWidth int
// Видимость панелей
showFileTree bool
showPreview bool
// Фокус
focus PanelFocus
// Настройки (из конфига или :set)
showLineNumbers bool
tabWidth int
autosaveInterval int // секунды
cfgShowPreview bool // дефолт для preview из конфига
// Viewport
viewport viewport.Model
previewBuf string
// Команда
commandBuffer string
// Поиск и замена
searchBuffer string
searchMatches []struct {
line, col int
}
searchIdx int
lastSearchTerm string
replaceBuffer string
inReplace bool
// Расширенная навигация
lastFindChar rune
lastFindIsForward bool
numPrefix string
// Clipboard
clipboard string
// Undo/Redo
undoStack []undoSnapshot
redoStack []undoSnapshot
// Файловое дерево
fileTree FileTree
// Help экран
showHelp bool
// Конфиг (хранится для :config show)
config Config
}
// Типы событий
type TickMsg time.Time
// NewEditor создаёт новый редактор с применением конфига
func NewEditor(path, fileType, content string, cfg Config) *EditorModel {
lines := strings.Split(content, "\n")
if len(lines) == 0 {
lines = []string{""}
}
rootDir := filepath.Dir(path)
if rootDir == "" || rootDir == "." {
rootDir, _ = filepath.Abs(".")
}
m := &EditorModel{
lines: lines,
filepath: path,
fileType: fileType,
dirty: false,
mode: NORMAL,
cursorX: 0,
cursorY: 0,
showLineNumbers: cfg.LineNumbers,
tabWidth: cfg.TabWidth,
autosaveInterval: cfg.AutosaveInterval,
cfgShowPreview: cfg.ShowPreview,
showPreview: cfg.ShowPreview && fileType == "md" || fileType == "md" && cfg.ShowPreview,
showFileTree: cfg.ShowTree,
focus: FocusEditor,
lastSave: time.Now(),
viewport: viewport.New(0, 0),
width: 120,
height: 30,
treeWidth: cfg.TreeWidth,
previewWidth: cfg.PreviewWidth,
config: cfg,
fileTree: NewFileTree(rootDir),
}
// Для markdown включаем preview если задано в конфиге или по умолчанию
if fileType == "md" && cfg.ShowPreview {
m.showPreview = true
}
if cfg.ShowTree {
m.fileTree.reload()
m.fileTree.focused = false
}
m.clampPanelWidths()
m.updatePreview()
return m
}
// Init инициализирует модель
func (m *EditorModel) Init() tea.Cmd {
return m.tickCmd()
}
func (m *EditorModel) tickCmd() tea.Cmd {
interval := time.Duration(m.autosaveInterval) * time.Second
if interval <= 0 {
interval = 60 * time.Second // fallback
}
return tea.Tick(interval, func(t time.Time) tea.Msg {
return TickMsg(t)
})
}
// Update обрабатывает сообщения
func (m *EditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.clampPanelWidths()
m.viewport.Width = m.editorWidth()
m.viewport.Height = m.height - 3
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
case TickMsg:
if m.autosaveInterval > 0 &&
time.Since(m.lastSave) >= time.Duration(m.autosaveInterval)*time.Second &&
m.dirty {
m.save() //nolint:errcheck
}
return m, m.tickCmd()
}
return m, nil
}
// View возвращает строку для вывода
func (m *EditorModel) View() string {
if m.showHelp {
return renderHelp(m)
}
switch {
case m.showFileTree && m.showPreview && m.fileType == "md":
return m.viewTreeEditorPreview()
case m.showFileTree:
return m.viewTreeEditor()
case m.showPreview && m.fileType == "md":
return m.viewEditorPreview()
default:
return m.viewEditorOnly()
}
}
func (m *EditorModel) viewEditorOnly() string { return renderEditor(m) }
func (m *EditorModel) viewEditorPreview() string { return renderSplit(m) }
func (m *EditorModel) viewTreeEditor() string { return renderTreeEditor(m) }
func (m *EditorModel) viewTreeEditorPreview() string { return renderTreeEditorPreview(m) }
// === Вычисление ширин ===
func (m *EditorModel) editorWidth() int {
w := m.width
if m.showFileTree {
w -= m.treeWidth
}
if m.showPreview && m.fileType == "md" {
w -= m.previewWidth
}
if w < minEditorWidth {
w = minEditorWidth
}
return w
}
func (m *EditorModel) clampPanelWidths() {
if m.treeWidth < minTreeWidth {
m.treeWidth = minTreeWidth
}
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
needed := minEditorWidth
if m.showFileTree {
needed += m.treeWidth
}
if m.showPreview && m.fileType == "md" {
needed += m.previewWidth
}
if needed > m.width && m.width > 0 {
available := m.width - minEditorWidth
if available < 0 {
available = 0
}
if m.showFileTree && m.showPreview && m.fileType == "md" {
total := m.treeWidth + m.previewWidth
if total > 0 {
m.treeWidth = available * m.treeWidth / total
m.previewWidth = available - m.treeWidth
}
if m.treeWidth < minTreeWidth {
m.treeWidth = minTreeWidth
}
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
} else if m.showFileTree {
m.treeWidth = available
if m.treeWidth < minTreeWidth {
m.treeWidth = minTreeWidth
}
} else if m.showPreview {
m.previewWidth = available
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
}
}
}
func (m *EditorModel) resizeTree(delta int) {
m.treeWidth += delta
if m.treeWidth < minTreeWidth {
m.treeWidth = minTreeWidth
}
maxTree := m.width - minEditorWidth
if m.showPreview && m.fileType == "md" {
maxTree -= m.previewWidth
}
if m.treeWidth > maxTree {
m.treeWidth = maxTree
}
}
func (m *EditorModel) resizePreview(delta int) {
m.previewWidth += delta
if m.previewWidth < minPreviewWidth {
m.previewWidth = minPreviewWidth
}
maxPreview := m.width - minEditorWidth
if m.showFileTree {
maxPreview -= m.treeWidth
}
if m.previewWidth > maxPreview {
m.previewWidth = maxPreview
}
}
// === Открытие файла из дерева ===
func (m *EditorModel) openFile(path string) {
content, err := LoadFile(path)
if err != nil {
m.SetStatusMsg("Error opening: " + err.Error())
return
}
fileType := DetectFileType(path)
lines := strings.Split(content, "\n")
if len(lines) == 0 {
lines = []string{""}
}
m.filepath = path
m.fileType = fileType
m.lines = lines
m.cursorX = 0
m.cursorY = 0
m.dirty = false
m.undoStack = nil
m.redoStack = nil
m.searchMatches = nil
m.numPrefix = ""
m.pendingKeys = ""
m.showPreview = fileType == "md" && m.cfgShowPreview
m.mode = NORMAL
m.clampPanelWidths()
m.SetStatusMsg("Opened: " + path)
m.updatePreview()
}
// === Вспомогательные методы ===
func (m *EditorModel) IsInsertMode() bool { return m.mode == INSERT }
func (m *EditorModel) SetStatusMsg(msg string) { m.statusMsg = msg }
func (m *EditorModel) MarkDirty() { m.dirty = true }
func (m *EditorModel) CursorWithinBounds() {
if m.cursorY < 0 {
m.cursorY = 0
}
if m.cursorY >= len(m.lines) {
m.cursorY = len(m.lines) - 1
}
maxX := len([]rune(m.lines[m.cursorY]))
// В NORMAL режиме курсор не может стоять за последним символом
if m.mode != INSERT && maxX > 0 {
maxX--
}
if m.cursorX > maxX {
m.cursorX = maxX
}
if m.cursorX < 0 {
m.cursorX = 0
}
}
// === Undo / Redo ===
func (m *EditorModel) saveUndoSnapshot() {
snapshot := undoSnapshot{
lines: make([]string, len(m.lines)),
cursorX: m.cursorX,
cursorY: m.cursorY,
}
copy(snapshot.lines, m.lines)
m.undoStack = append(m.undoStack, snapshot)
if len(m.undoStack) > 100 {
m.undoStack = m.undoStack[1:]
}
m.redoStack = nil
}
func (m *EditorModel) Undo() {
if len(m.undoStack) == 0 {
m.SetStatusMsg("Already at oldest change")
return
}
redoSnap := undoSnapshot{
lines: make([]string, len(m.lines)),
cursorX: m.cursorX,
cursorY: m.cursorY,
}
copy(redoSnap.lines, m.lines)
m.redoStack = append(m.redoStack, redoSnap)
snap := m.undoStack[len(m.undoStack)-1]
m.undoStack = m.undoStack[:len(m.undoStack)-1]
m.lines = make([]string, len(snap.lines))
copy(m.lines, snap.lines)
m.cursorX = snap.cursorX
m.cursorY = snap.cursorY
m.CursorWithinBounds()
m.dirty = true
m.SetStatusMsg("Undo")
}
func (m *EditorModel) Redo() {
if len(m.redoStack) == 0 {
m.SetStatusMsg("Already at newest change")
return
}
snap := m.redoStack[len(m.redoStack)-1]
m.redoStack = m.redoStack[:len(m.redoStack)-1]
undoSnap := undoSnapshot{
lines: make([]string, len(m.lines)),
cursorX: m.cursorX,
cursorY: m.cursorY,
}
copy(undoSnap.lines, m.lines)
m.undoStack = append(m.undoStack, undoSnap)
m.lines = make([]string, len(snap.lines))
copy(m.lines, snap.lines)
m.cursorX = snap.cursorX
m.cursorY = snap.cursorY
m.CursorWithinBounds()
m.dirty = true
m.SetStatusMsg("Redo")
}

905
pdf.go Normal file
View file

@ -0,0 +1,905 @@
package main
import (
"bytes"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// === PDF генератор ===
//
// Два режима:
// 1. TTF mode: найден системный шрифт с кириллицей → встраиваем subset,
// текст кодируем в UTF-16BE, используем Type0/CIDFont.
// 2. Fallback mode: шрифт не найден → встроенные PDF шрифты (Helvetica),
// кириллица транслитерируется.
// A4 в pt
const (
pageWidth = 595.28
pageHeight = 841.89
marginL = 60.0
marginR = 60.0
marginT = 60.0
marginB = 60.0
textWidth = pageWidth - marginL - marginR
)
// Размеры шрифтов
const (
fontSizeBody = 11.0
fontSizeH1 = 22.0
fontSizeH2 = 17.0
fontSizeH3 = 14.0
fontSizeH4 = 12.0
fontSizeCode = 9.5
lineHeightMul = 1.4
)
// pdfColor — RGB 0.01.0
type pdfColor struct{ r, g, b float64 }
var (
colorBlack = pdfColor{0, 0, 0}
colorDarkGray = pdfColor{0.2, 0.2, 0.2}
colorGray = pdfColor{0.5, 0.5, 0.5}
colorCodeBg = pdfColor{0.95, 0.95, 0.95}
colorH1Color = pdfColor{0.1, 0.1, 0.4}
colorH2Color = pdfColor{0.15, 0.15, 0.5}
colorH3Color = pdfColor{0.2, 0.2, 0.55}
colorLinkColor = pdfColor{0.1, 0.1, 0.8}
colorAccent = pdfColor{0.3, 0.3, 0.7}
)
// pdfFontStyle — стиль шрифта
type pdfFontStyle int
const (
styleRegular pdfFontStyle = iota
styleBold
styleItalic
styleBoldItalic
styleMono
styleMonoBold
)
// pdfFont — логический шрифт
type pdfFont struct {
style pdfFontStyle
size float64
}
var (
fontBody = pdfFont{styleRegular, fontSizeBody}
fontBold = pdfFont{styleBold, fontSizeBody}
fontItalic = pdfFont{styleItalic, fontSizeBody}
fontBoldItal = pdfFont{styleBoldItalic, fontSizeBody}
fontMono = pdfFont{styleMono, fontSizeCode}
fontH1 = pdfFont{styleBold, fontSizeH1}
fontH2 = pdfFont{styleBold, fontSizeH2}
fontH3 = pdfFont{styleBold, fontSizeH3}
fontH4 = pdfFont{styleBold, fontSizeH4}
)
// pdfSpan — фрагмент текста
type pdfSpan struct {
text string
font pdfFont
color pdfColor
isLink bool
linkURL string
}
// pdfBlock — блок контента
type pdfBlock struct {
kind string
spans []pdfSpan
rawText string
indent int
bullet string
}
// === Движок рендеринга ===
// pdfEngine — абстракция над двумя режимами вывода текста
type pdfEngine interface {
// textCmd возвращает PDF команду для вывода текста
textCmd(text string, font pdfFont, color pdfColor, x, y float64) string
// textWidth возвращает ширину строки в pt
textWidth(text string, font pdfFont) float64
// fontResources возвращает словарь /Font для страницы
fontResources() string
// writeObjects записывает объекты шрифтов в документ
writeObjects(doc *pdfDoc)
}
// === Fallback engine (встроенные PDF шрифты) ===
type fallbackEngine struct{}
func (e *fallbackEngine) textCmd(text string, font pdfFont, color pdfColor, x, y float64) string {
return fmt.Sprintf(
"BT /%s %.2f Tf %.3f %.3f %.3f rg %.2f %.2f Td (%s) Tj ET\n",
e.fontRef(font), font.size,
color.r, color.g, color.b,
x, y,
pdfEscapeLatin(text),
)
}
func (e *fallbackEngine) textWidth(text string, font pdfFont) float64 {
ratio := 0.55
if font.style == styleMono || font.style == styleMonoBold {
ratio = 0.6
}
return float64(len([]rune(text))) * font.size * ratio
}
func (e *fallbackEngine) fontResources() string {
return "/Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R /F4 6 0 R /F5 7 0 R /F6 8 0 R >>"
}
func (e *fallbackEngine) writeObjects(doc *pdfDoc) {
builtins := []string{
"Helvetica", "Helvetica-Bold", "Helvetica-Oblique",
"Helvetica-BoldOblique", "Courier", "Courier-Bold",
}
for _, name := range builtins {
doc.newObject()
doc.write(fmt.Sprintf(
"<< /Type /Font /Subtype /Type1 /BaseFont /%s /Encoding /WinAnsiEncoding >>\nendobj\n\n",
name,
))
}
}
func (e *fallbackEngine) fontRef(font pdfFont) string {
switch font.style {
case styleBold:
return "F2"
case styleItalic:
return "F3"
case styleBoldItalic:
return "F4"
case styleMono:
return "F5"
case styleMonoBold:
return "F6"
default:
return "F1"
}
}
// === TTF engine (системный шрифт с кириллицей) ===
type ttfEngine struct {
regular *EmbeddedFont
bold *EmbeddedFont
italic *EmbeddedFont
mono *EmbeddedFont // Courier — встроенный, без кириллицы
fontPath string
// ID объектов после записи
regularRef string
boldRef string
italicRef string
}
func newTTFEngine(fontPath string) (*ttfEngine, error) {
ttf, err := LoadTTF(fontPath)
if err != nil {
return nil, err
}
if !ttf.HasCyrillic() {
return nil, fmt.Errorf("font has no cyrillic glyphs")
}
return &ttfEngine{
regular: NewEmbeddedFont(ttf),
bold: NewEmbeddedFont(ttf), // используем тот же шрифт для bold (упрощение)
italic: NewEmbeddedFont(ttf),
fontPath: fontPath,
}, nil
}
func (e *ttfEngine) embeddedFor(font pdfFont) *EmbeddedFont {
switch font.style {
case styleBold, styleBoldItalic:
return e.bold
case styleItalic:
return e.italic
default:
return e.regular
}
}
func (e *ttfEngine) textCmd(text string, font pdfFont, color pdfColor, x, y float64) string {
ef := e.embeddedFor(font)
// Кодируем в UTF-16BE и регистрируем глифы
encoded := encodeUTF16BEHex(text, ef)
fontRef := e.fontRefFor(font)
return fmt.Sprintf(
"BT /%s %.2f Tf %.3f %.3f %.3f rg %.2f %.2f Td <%s> Tj ET\n",
fontRef, font.size,
color.r, color.g, color.b,
x, y,
encoded,
)
}
func (e *ttfEngine) fontRefFor(font pdfFont) string {
switch font.style {
case styleBold, styleBoldItalic:
return "FB"
case styleItalic:
return "FI"
case styleMono, styleMonoBold:
return "FM"
default:
return "FR"
}
}
func (e *ttfEngine) textWidth(text string, font pdfFont) float64 {
if font.style == styleMono || font.style == styleMonoBold {
return float64(len([]rune(text))) * font.size * 0.6
}
ef := e.embeddedFor(font)
total := 0.0
for _, r := range text {
w := ef.GlyphWidth(r)
total += float64(w) * font.size / 1000.0
}
return total
}
func (e *ttfEngine) fontResources() string {
return fmt.Sprintf(
"/Font << /FR %s /FB %s /FI %s /FM 3 0 R >>",
e.regularRef, e.boldRef, e.italicRef,
)
}
func (e *ttfEngine) writeObjects(doc *pdfDoc) {
// Courier для кода (встроенный)
doc.newObject()
doc.write("<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>\nendobj\n\n")
// Если bold и italic указывают на тот же TTF — можно встроить один раз.
// Для упрощения встраиваем все три отдельно (subset будет разный по использованным глифам).
e.regularRef = e.regular.WriteToPDF(doc, "GrimoirRegular")
e.boldRef = e.bold.WriteToPDF(doc, "GrimoirBold")
e.italicRef = e.italic.WriteToPDF(doc, "GrimoirItalic")
}
// encodeUTF16BEHex кодирует строку в UTF-16BE hex для PDF и регистрирует глифы
func encodeUTF16BEHex(text string, ef *EmbeddedFont) string {
var sb strings.Builder
for _, r := range text {
gid := ef.UseRune(r)
sb.WriteString(fmt.Sprintf("%04X", gid))
}
return sb.String()
}
// === Основной документ ===
type pdfDoc struct {
buf bytes.Buffer
objects []int
pageIDs []int
engine pdfEngine
curPage int
curY float64
pageBuf bytes.Buffer
}
// ExportToPDF экспортирует markdown в PDF
func ExportToPDF(lines []string, outputPath string) error {
// Пробуем найти системный шрифт
var engine pdfEngine
fontPath := FindCyrillicFont()
if fontPath != "" {
ttfe, err := newTTFEngine(fontPath)
if err == nil {
engine = ttfe
}
}
if engine == nil {
engine = &fallbackEngine{}
}
blocks := parseMarkdownToBlocks(lines)
doc := &pdfDoc{engine: engine}
doc.writePDFHeader()
doc.addPage()
for _, block := range blocks {
doc.renderBlock(block)
}
doc.finalizePage()
doc.writeCrossRef()
return os.WriteFile(outputPath, doc.buf.Bytes(), 0644)
}
// === Парсинг Markdown → блоки ===
func parseMarkdownToBlocks(lines []string) []pdfBlock {
var blocks []pdfBlock
inCode := false
codeLang := ""
var codeLines []string
for _, line := range lines {
if strings.HasPrefix(line, "```") {
if inCode {
blocks = append(blocks, pdfBlock{kind: "code", rawText: strings.Join(codeLines, "\n")})
inCode = false
codeLines = nil
codeLang = ""
} else {
inCode = true
codeLang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
_ = codeLang
}
continue
}
if inCode {
codeLines = append(codeLines, line)
continue
}
if regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})\s*$`).MatchString(line) {
blocks = append(blocks, pdfBlock{kind: "hr"})
continue
}
if m := regexp.MustCompile(`^(#{1,4})\s+(.+)`).FindStringSubmatch(line); m != nil {
level := len(m[1])
blocks = append(blocks, pdfBlock{
kind: fmt.Sprintf("h%d", level),
spans: parseInlineMarkdown(m[2], fontForHeading(level), colorForHeading(level)),
})
continue
}
if strings.HasPrefix(line, "> ") {
blocks = append(blocks, pdfBlock{
kind: "quote",
spans: parseInlineMarkdown(strings.TrimPrefix(line, "> "), fontItalic, colorDarkGray),
})
continue
}
if m := regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`).FindStringSubmatch(line); m != nil {
blocks = append(blocks, pdfBlock{
kind: "listitem",
bullet: "•",
indent: len(m[1]) / 2,
spans: parseInlineMarkdown(m[3], fontBody, colorBlack),
})
continue
}
if m := regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`).FindStringSubmatch(line); m != nil {
blocks = append(blocks, pdfBlock{
kind: "listitem",
bullet: m[2] + ".",
indent: len(m[1]) / 2,
spans: parseInlineMarkdown(m[3], fontBody, colorBlack),
})
continue
}
if strings.TrimSpace(line) == "" {
blocks = append(blocks, pdfBlock{kind: "space"})
continue
}
if strings.Contains(line, "|") && strings.HasPrefix(strings.TrimSpace(line), "|") {
if regexp.MustCompile(`^\|[\s\-:|]+\|`).MatchString(strings.TrimSpace(line)) {
blocks = append(blocks, pdfBlock{kind: "hr"})
continue
}
cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|")
var spans []pdfSpan
for i, c := range cells {
if i > 0 {
spans = append(spans, pdfSpan{text: " | ", font: fontBody, color: colorGray})
}
spans = append(spans, parseInlineMarkdown(strings.TrimSpace(c), fontBody, colorBlack)...)
}
blocks = append(blocks, pdfBlock{kind: "para", spans: spans})
continue
}
blocks = append(blocks, pdfBlock{
kind: "para",
spans: parseInlineMarkdown(line, fontBody, colorBlack),
})
}
return blocks
}
func parseInlineMarkdown(text string, defaultFont pdfFont, defaultColor pdfColor) []pdfSpan {
type fragment struct {
text string
bold, italic, isCode, isLink bool
linkURL string
start, end int
}
patterns := []struct {
re *regexp.Regexp
kind string
}{
{regexp.MustCompile(`\*\*([^*]+)\*\*`), "bold"},
{regexp.MustCompile(`\*([^*]+)\*`), "italic"},
{regexp.MustCompile("`([^`]+)`"), "code"},
{regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`), "link"},
}
frags := []fragment{}
used := make([]bool, len(text))
for _, p := range patterns {
for _, m := range p.re.FindAllStringSubmatchIndex(text, -1) {
overlap := false
for i := m[0]; i < m[1] && i < len(used); i++ {
if used[i] {
overlap = true
break
}
}
if overlap {
continue
}
for i := m[0]; i < m[1] && i < len(used); i++ {
used[i] = true
}
f := fragment{start: m[0], end: m[1]}
switch p.kind {
case "bold":
f.text = text[m[2]:m[3]]
f.bold = true
case "italic":
f.text = text[m[2]:m[3]]
f.italic = true
case "code":
f.text = text[m[2]:m[3]]
f.isCode = true
case "link":
f.text = text[m[2]:m[3]]
f.isLink = true
if len(m) > 5 && m[4] >= 0 {
f.linkURL = text[m[4]:m[5]]
}
}
frags = append(frags, f)
}
}
// Сортировка по позиции
for i := range frags {
for j := i + 1; j < len(frags); j++ {
if frags[j].start < frags[i].start {
frags[i], frags[j] = frags[j], frags[i]
}
}
}
var spans []pdfSpan
pos := 0
for _, f := range frags {
if f.start > pos {
spans = append(spans, pdfSpan{text: text[pos:f.start], font: defaultFont, color: defaultColor})
}
var font pdfFont
var color pdfColor
switch {
case f.isCode:
font = fontMono
color = pdfColor{0.1, 0.1, 0.5}
case f.isLink:
font = defaultFont
color = colorLinkColor
case f.bold && f.italic:
font = fontBoldItal
color = defaultColor
case f.bold:
font = fontBold
color = defaultColor
case f.italic:
font = fontItalic
color = defaultColor
default:
font = defaultFont
color = defaultColor
}
spans = append(spans, pdfSpan{text: f.text, font: font, color: color, isLink: f.isLink, linkURL: f.linkURL})
pos = f.end
}
if pos < len(text) && pos >= 0 {
spans = append(spans, pdfSpan{text: text[pos:], font: defaultFont, color: defaultColor})
}
if len(spans) == 0 {
spans = []pdfSpan{{text: text, font: defaultFont, color: defaultColor}}
}
return spans
}
func fontForHeading(level int) pdfFont {
switch level {
case 1:
return fontH1
case 2:
return fontH2
case 3:
return fontH3
default:
return fontH4
}
}
func colorForHeading(level int) pdfColor {
switch level {
case 1:
return colorH1Color
case 2:
return colorH2Color
case 3:
return colorH3Color
default:
return colorDarkGray
}
}
// === Генерация PDF ===
func (doc *pdfDoc) writePDFHeader() {
doc.write("%PDF-1.4\n")
doc.write("%\xe2\xe3\xcf\xd3\n")
}
func (doc *pdfDoc) addPage() {
doc.curPage++
doc.curY = marginT
doc.pageBuf.Reset()
}
func (doc *pdfDoc) finalizePage() {
if doc.pageBuf.Len() == 0 && doc.curPage == 0 {
return
}
stream := doc.pageBuf.String()
contentID := doc.newObject()
doc.write(fmt.Sprintf("<< /Length %d >>\nstream\n", len(stream)))
doc.write(stream)
doc.write("\nendstream\nendobj\n\n")
pageID := doc.newObject()
doc.write(fmt.Sprintf(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2f %.2f]\n"+
" /Contents %d 0 R\n"+
" /Resources << %s >>\n"+
">>\nendobj\n\n",
pageWidth, pageHeight, contentID,
doc.engine.fontResources(),
))
doc.pageIDs = append(doc.pageIDs, pageID)
}
func (doc *pdfDoc) renderBlock(block pdfBlock) {
switch block.kind {
case "space":
doc.advanceY(fontSizeBody * 0.5)
case "hr":
doc.advanceY(8)
doc.checkPageBreak(20)
y := pageHeight - doc.curY
doc.pageBuf.WriteString(fmt.Sprintf(
"q %.3f %.3f %.3f RG 0.5 w %.2f %.2f m %.2f %.2f l S Q\n",
colorGray.r, colorGray.g, colorGray.b,
marginL, y, pageWidth-marginR, y,
))
doc.advanceY(8)
case "h1":
doc.advanceY(fontSizeH1 * 0.6)
doc.checkPageBreak(fontSizeH1 * 2.5)
doc.renderHeading(block, fontH1, colorH1Color)
y := pageHeight - doc.curY + 2
doc.pageBuf.WriteString(fmt.Sprintf(
"q %.3f %.3f %.3f RG 1 w %.2f %.2f m %.2f %.2f l S Q\n",
colorAccent.r, colorAccent.g, colorAccent.b,
marginL, y, pageWidth-marginR, y,
))
doc.advanceY(6)
case "h2":
doc.advanceY(fontSizeH2 * 0.5)
doc.checkPageBreak(fontSizeH2 * 2.5)
doc.renderHeading(block, fontH2, colorH2Color)
doc.advanceY(4)
case "h3":
doc.advanceY(fontSizeH3 * 0.4)
doc.checkPageBreak(fontSizeH3 * 2)
doc.renderHeading(block, fontH3, colorH3Color)
doc.advanceY(3)
case "h4":
doc.advanceY(fontSizeH4 * 0.3)
doc.checkPageBreak(fontSizeH4 * 2)
doc.renderHeading(block, fontH4, colorDarkGray)
doc.advanceY(2)
case "para":
doc.checkPageBreak(fontSizeBody * lineHeightMul * 2)
doc.renderSpans(block.spans, marginL, fontSizeBody*lineHeightMul)
doc.advanceY(fontSizeBody * 0.3)
case "quote":
doc.advanceY(4)
doc.checkPageBreak(fontSizeBody * lineHeightMul * 2)
startY := doc.curY
doc.renderSpans(block.spans, marginL+16, fontSizeBody*lineHeightMul)
endY := doc.curY
barH := endY - startY + fontSizeBody
barY := pageHeight - startY
doc.pageBuf.WriteString(fmt.Sprintf(
"q %.3f %.3f %.3f rg %.2f %.2f %.2f %.2f re f Q\n",
colorAccent.r, colorAccent.g, colorAccent.b,
marginL, barY-barH, 3.0, barH,
))
doc.pageBuf.WriteString(fmt.Sprintf(
"q 0.96 0.96 0.98 rg %.2f %.2f %.2f %.2f re f Q\n",
marginL+4, barY-barH, textWidth-4, barH,
))
doc.advanceY(4)
case "listitem":
doc.checkPageBreak(fontSizeBody * lineHeightMul * 1.5)
bulletX := marginL + float64(block.indent)*20
textX := bulletX + 18
bulletY := pageHeight - doc.curY - fontSizeBody
doc.pageBuf.WriteString(doc.engine.textCmd(
block.bullet, fontBody, colorAccent, bulletX, bulletY,
))
doc.renderSpans(block.spans, textX, fontSizeBody*lineHeightMul)
doc.advanceY(2)
case "code":
doc.renderCodeBlock(block.rawText)
}
}
func (doc *pdfDoc) renderHeading(block pdfBlock, font pdfFont, color pdfColor) {
var sb strings.Builder
for _, s := range block.spans {
sb.WriteString(s.text)
}
y := pageHeight - doc.curY - font.size
doc.pageBuf.WriteString(doc.engine.textCmd(sb.String(), font, color, marginL, y))
doc.advanceY(font.size * lineHeightMul)
}
func (doc *pdfDoc) renderSpans(spans []pdfSpan, leftMargin, lineHeight float64) {
maxW := pageWidth - marginR - (leftMargin - marginL)
wrappedLines := wrapSpans(spans, maxW, doc.engine)
for _, line := range wrappedLines {
doc.checkPageBreak(lineHeight)
y := pageHeight - doc.curY - fontSizeBody
x := leftMargin
for _, span := range line {
if span.text == "" {
continue
}
doc.pageBuf.WriteString(doc.engine.textCmd(span.text, span.font, span.color, x, y))
x += doc.engine.textWidth(span.text, span.font)
}
doc.advanceY(lineHeight)
}
}
func (doc *pdfDoc) renderCodeBlock(code string) {
lines := strings.Split(code, "\n")
lineH := fontSizeCode * lineHeightMul
totalH := float64(len(lines))*lineH + 16
doc.advanceY(6)
doc.checkPageBreak(math.Min(totalH, pageHeight-marginT-marginB))
bgY := pageHeight - doc.curY
bgH := float64(len(lines))*lineH + 12
doc.pageBuf.WriteString(fmt.Sprintf(
"q %.3f %.3f %.3f rg %.2f %.2f %.2f %.2f re f Q\n",
colorCodeBg.r, colorCodeBg.g, colorCodeBg.b,
marginL, bgY-bgH, textWidth, bgH,
))
doc.pageBuf.WriteString(fmt.Sprintf(
"q %.3f %.3f %.3f rg %.2f %.2f %.2f %.2f re f Q\n",
colorAccent.r, colorAccent.g, colorAccent.b,
marginL, bgY-bgH, 3.0, bgH,
))
doc.advanceY(8)
for _, line := range lines {
doc.checkPageBreak(lineH)
y := pageHeight - doc.curY - fontSizeCode
doc.pageBuf.WriteString(doc.engine.textCmd(line, fontMono, colorDarkGray, marginL+8, y))
doc.advanceY(lineH)
}
doc.advanceY(6)
}
// wrapSpans разбивает spans на строки по maxWidth
func wrapSpans(spans []pdfSpan, maxWidth float64, eng pdfEngine) [][]pdfSpan {
var result [][]pdfSpan
var curLine []pdfSpan
curW := 0.0
for _, span := range spans {
words := strings.Fields(span.text)
if len(words) == 0 {
curLine = append(curLine, span)
continue
}
for i, word := range words {
ww := eng.textWidth(word, span.font)
sw := eng.textWidth(" ", span.font)
needSpace := i > 0 || (len(curLine) > 0 && curW > 0)
addW := ww
if needSpace {
addW += sw
}
if curW+addW > maxWidth && curW > 0 {
result = append(result, curLine)
curLine = nil
curW = 0
needSpace = false
addW = ww
}
txt := word
if needSpace {
txt = " " + word
}
curLine = append(curLine, pdfSpan{text: txt, font: span.font, color: span.color})
curW += addW
}
}
if len(curLine) > 0 {
result = append(result, curLine)
}
if len(result) == 0 {
result = [][]pdfSpan{{}}
}
return result
}
// === Финализация документа ===
func (doc *pdfDoc) writeCrossRef() {
// Catalog
doc.newObject()
doc.write("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\n")
// Pages
var pageRefs strings.Builder
for _, id := range doc.pageIDs {
pageRefs.WriteString(fmt.Sprintf("%d 0 R ", id))
}
doc.newObject()
doc.write(fmt.Sprintf(
"<< /Type /Pages /Kids [%s] /Count %d >>\nendobj\n\n",
pageRefs.String(), len(doc.pageIDs),
))
// Объекты шрифтов
doc.engine.writeObjects(doc)
// Info
now := time.Now().Format("20060102150405")
doc.newObject()
doc.write(fmt.Sprintf(
"<< /Creator (grimoir) /CreationDate (D:%s) >>\nendobj\n\n",
now,
))
// xref
xrefOff := doc.buf.Len()
total := len(doc.objects) + 1
doc.write(fmt.Sprintf("xref\n0 %d\n", total))
doc.write("0000000000 65535 f \n")
for _, off := range doc.objects {
doc.write(fmt.Sprintf("%010d 00000 n \n", off))
}
doc.write(fmt.Sprintf(
"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n",
total, xrefOff,
))
}
// === Утилиты ===
func (doc *pdfDoc) write(s string) { doc.buf.WriteString(s) }
func (doc *pdfDoc) newObject() int {
id := len(doc.objects) + 1
doc.objects = append(doc.objects, doc.buf.Len())
doc.write(fmt.Sprintf("%d 0 obj\n", id))
return id
}
func (doc *pdfDoc) advanceY(d float64) { doc.curY += d }
func (doc *pdfDoc) checkPageBreak(needed float64) {
if doc.curY+needed > pageHeight-marginB {
doc.finalizePage()
doc.addPage()
}
}
// pdfEscapeLatin экранирует строку для Latin-1 PDF (fallback режим)
func pdfEscapeLatin(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r == '(':
b.WriteString(`\(`)
case r == ')':
b.WriteString(`\)`)
case r == '\\':
b.WriteString(`\\`)
case r >= 0x20 && r <= 0x7E:
b.WriteRune(r)
default:
b.WriteString(translitRune(r))
}
}
return b.String()
}
// translitRune транслитерирует символ (fallback)
func translitRune(r rune) string {
m := map[rune]string{
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d",
'е': "e", 'ё': "yo", 'ж': "zh", 'з': "z", 'и': "i",
'й': "j", 'к': "k", 'л': "l", 'м': "m", 'н': "n",
'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t",
'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch",
'ш': "sh", 'щ': "shch", 'ъ': "", 'ы': "y", 'ь': "",
'э': "e", 'ю': "yu", 'я': "ya",
'А': "A", 'Б': "B", 'В': "V", 'Г': "G", 'Д': "D",
'Е': "E", 'Ё': "Yo", 'Ж': "Zh", 'З': "Z", 'И': "I",
'Й': "J", 'К': "K", 'Л': "L", 'М': "M", 'Н': "N",
'О': "O", 'П': "P", 'Р': "R", 'С': "S", 'Т': "T",
'У': "U", 'Ф': "F", 'Х': "Kh", 'Ц': "Ts", 'Ч': "Ch",
'Ш': "Sh", 'Щ': "Shch", 'Ъ': "", 'Ы': "Y", 'Ь': "",
'Э': "E", 'Ю': "Yu", 'Я': "Ya",
'—': "--", '': "-", '«': "\"", '»': "\"",
'\u00a0': " ", '…': "...", '•': "*",
'©': "(c)", '®': "(r)", '™': "(tm)",
}
if s, ok := m[r]; ok {
return s
}
if r <= 0xFF {
return string(r)
}
return "?"
}
// defaultPDFPath путь для PDF рядом с md файлом
func defaultPDFPath(mdPath string) string {
ext := filepath.Ext(mdPath)
return strings.TrimSuffix(mdPath, ext) + ".pdf"
}
// sanitizePDF — заглушка, реальная обработка в движке
func sanitizePDF(s string) string { return s }

1113
pdf_font.go Normal file

File diff suppressed because it is too large Load diff

503
render.go Normal file
View file

@ -0,0 +1,503 @@
package main
import (
"fmt"
"regexp"
"strings"
"github.com/charmbracelet/lipgloss"
)
// === UI стили ===
var (
colorBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
colorHeading = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
colorCode = lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("11"))
colorLink = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Underline(true)
colorDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
colorCursor = lipgloss.NewStyle().Reverse(true)
colorMode = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11"))
colorQuote = lipgloss.NewStyle().Foreground(lipgloss.Color("243")).Italic(true)
colorHR = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
)
// === Компоновки экрана ===
// renderEditor — только редактор, на весь экран
func renderEditor(m *EditorModel) string {
var out strings.Builder
w := m.width
out.WriteString(renderHeader(m, w))
out.WriteString(renderEditorLines(m, w, m.height-3))
out.WriteString(renderStatusBar(m))
return out.String()
}
// renderSplit — редактор + preview (без дерева)
func renderSplit(m *EditorModel) string {
edW := m.editorWidth()
prW := m.previewWidth
return lipgloss.JoinHorizontal(lipgloss.Top,
renderEditorBlock(m, edW),
renderPreviewBlock(m, prW),
)
}
// renderTreeEditor — дерево + редактор
func renderTreeEditor(m *EditorModel) string {
edW := m.editorWidth()
return lipgloss.JoinHorizontal(lipgloss.Top,
renderFileTree(m),
renderEditorBlock(m, edW),
)
}
// renderTreeEditorPreview — дерево + редактор + preview
func renderTreeEditorPreview(m *EditorModel) string {
edW := m.editorWidth()
prW := m.previewWidth
return lipgloss.JoinHorizontal(lipgloss.Top,
renderFileTree(m),
renderEditorBlock(m, edW),
renderPreviewBlock(m, prW),
)
}
// === Блоки ===
// renderEditorBlock рендерит редактор заданной ширины (для split/tree компоновок)
func renderEditorBlock(m *EditorModel, width int) string {
var out strings.Builder
out.WriteString(renderHeader(m, width))
out.WriteString(renderEditorLines(m, width, m.height-3))
out.WriteString(renderEditorStatusLine(m, width))
return out.String()
}
// renderPreviewBlock рендерит панель preview заданной ширины
func renderPreviewBlock(m *EditorModel, width int) string {
var out strings.Builder
height := m.height - 3
headerStyle := lipgloss.NewStyle().
Background(lipgloss.Color("8")).
Foreground(lipgloss.Color("15")).
Width(width).Padding(0, 1)
out.WriteString(headerStyle.Render(" PREVIEW "))
out.WriteString("\n")
lines := strings.Split(m.previewBuf, "\n")
rendered := 0
for i := 0; i < height && i < len(lines); i++ {
line := truncateANSI(lines[i], width)
out.WriteString(line)
out.WriteString("\n")
rendered++
}
for i := rendered; i < height; i++ {
out.WriteString("\n")
}
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width)
hint := " </>: ширина preview"
out.WriteString(hintStyle.Render(hint))
return out.String()
}
// === Внутренние части редактора ===
const grimVersion = "grimoir v0.1"
func renderHeader(m *EditorModel, width int) string {
name := m.filepath
if m.dirty {
name += " [+]"
}
left := fmt.Sprintf(" %s [%s]", name, m.fileType)
right := fmt.Sprintf(" %s ", grimVersion)
// Заполняем пространство между левой и правой частью
pad := width - len(left) - len(right)
if pad < 0 {
pad = 0
}
header := left + strings.Repeat(" ", pad) + right
style := lipgloss.NewStyle().
Background(lipgloss.Color("8")).
Foreground(lipgloss.Color("15")).
Width(width)
return style.Render(header) + "\n"
}
func renderEditorLines(m *EditorModel, width, height int) string {
var out strings.Builder
start, end := visibleRange(m.cursorY, height, len(m.lines))
// Ширина под номера строк
numW := 0
if m.showLineNumbers {
numW = 5 // "1234 "
}
contentW := width - numW
if contentW < 1 {
contentW = 1
}
for lineNum := start; lineNum < end; lineNum++ {
if m.showLineNumbers {
out.WriteString(colorDim.Render(fmt.Sprintf("%4d ", lineNum+1)))
}
line := m.lines[lineNum]
var rendered string
if lineNum == m.cursorY {
rendered = renderLineWithCursor(line, m.cursorX, m.fileType)
} else {
rendered = renderLine(line, m.fileType)
}
out.WriteString(truncateANSI(rendered, contentW))
out.WriteString("\n")
}
// Пустые строки (~)
for i := end - start; i < height; i++ {
out.WriteString(colorDim.Render("~") + "\n")
}
return out.String()
}
// renderEditorStatusLine — нижняя строка блока редактора (в split режимах)
func renderEditorStatusLine(m *EditorModel, width int) string {
style := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width)
msg := m.statusMsg
if msg == "" {
msg = fmt.Sprintf("Ln %d/%d Col %d", m.cursorY+1, len(m.lines), m.cursorX+1)
}
return style.Render(" " + msg)
}
// === Строки текста ===
func renderLine(line, fileType string) string {
if fileType == "md" {
return colorizeMarkdown(line)
}
return HighlightLine(line, fileType)
}
func renderLineWithCursor(line string, cursorX int, fileType string) string {
runes := []rune(line)
if cursorX < 0 {
cursorX = 0
}
if cursorX > len(runes) {
cursorX = len(runes)
}
before := string(runes[:cursorX])
cursorChar := " "
after := ""
if cursorX < len(runes) {
cursorChar = string(runes[cursorX])
after = string(runes[cursorX+1:])
}
if fileType == "md" {
before = colorizeMarkdown(before)
after = colorizeMarkdown(after)
} else {
before = HighlightLine(before, fileType)
after = HighlightLine(after, fileType)
}
return before + colorCursor.Render(cursorChar) + after
}
// === Markdown colorize (построчно, для редактора) ===
func colorizeMarkdown(line string) string {
headingRe := regexp.MustCompile(`^(#+)\s+(.*)`)
if m := headingRe.FindStringSubmatch(line); m != nil {
level := len(m[1])
text := m[2]
switch level {
case 1:
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")).Render(m[1] + " " + text)
case 2:
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render(m[1] + " " + text)
default:
return lipgloss.NewStyle().Foreground(lipgloss.Color("45")).Render(m[1] + " " + text)
}
}
result := line
result = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllStringFunc(result, func(s string) string {
return colorBold.Render(s[2 : len(s)-2])
})
result = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllStringFunc(result, func(s string) string {
return lipgloss.NewStyle().Italic(true).Render(s[1 : len(s)-1])
})
result = regexp.MustCompile("`([^`]+)`").ReplaceAllStringFunc(result, func(s string) string {
return colorCode.Render(s[1 : len(s)-1])
})
result = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllStringFunc(result, func(s string) string {
m := regexp.MustCompile(`\[([^\]]+)\]`).FindStringSubmatch(s)
if m != nil {
return colorLink.Render(m[1])
}
return s
})
return result
}
// === Markdown preview (полный рендер для правой панели) ===
func (m *EditorModel) updatePreview() {
if m.fileType != "md" {
m.previewBuf = ""
return
}
m.previewBuf = renderMarkdown(m.lines)
}
func renderMarkdown(lines []string) string {
var out strings.Builder
inCodeBlock := false
codeBlockLang := ""
var codeLines []string
for _, line := range lines {
if strings.HasPrefix(line, "```") {
if inCodeBlock {
out.WriteString(renderCodeBlock(codeLines, codeBlockLang))
inCodeBlock = false
codeLines = nil
codeBlockLang = ""
} else {
inCodeBlock = true
codeBlockLang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
}
continue
}
if inCodeBlock {
codeLines = append(codeLines, line)
continue
}
out.WriteString(renderMarkdownLine(line))
out.WriteString("\n")
}
if inCodeBlock && len(codeLines) > 0 {
out.WriteString(renderCodeBlock(codeLines, codeBlockLang))
}
return out.String()
}
func renderMarkdownLine(line string) string {
// Горизонтальная линия
if regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})$`).MatchString(strings.TrimSpace(line)) {
return colorHR.Render(strings.Repeat("─", 60))
}
// Заголовки
if m := regexp.MustCompile(`^(#+)\s+(.+)`).FindStringSubmatch(line); m != nil {
level := len(m[1])
text := applyInlineMarkdown(m[2])
switch level {
case 1:
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")).Render("▌ " + text)
case 2:
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render("▌ " + text)
case 3:
return lipgloss.NewStyle().Foreground(lipgloss.Color("45")).Render("▌ " + text)
default:
return lipgloss.NewStyle().Foreground(lipgloss.Color("51")).Render(text)
}
}
// Blockquote
if strings.HasPrefix(line, "> ") {
return colorQuote.Render("┃ " + applyInlineMarkdown(strings.TrimPrefix(line, "> ")))
}
if line == ">" {
return colorQuote.Render("┃")
}
// Маркированные списки
if m := regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`).FindStringSubmatch(line); m != nil {
return m[1] + colorListBul.Render("•") + " " + applyInlineMarkdown(m[3])
}
// Нумерованные списки
if m := regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`).FindStringSubmatch(line); m != nil {
return m[1] + colorListBul.Render(m[2]+".") + " " + applyInlineMarkdown(m[3])
}
// Таблицы
if strings.Contains(line, "|") && strings.HasPrefix(strings.TrimSpace(line), "|") {
if regexp.MustCompile(`^\|[\s\-:|]+\|`).MatchString(strings.TrimSpace(line)) {
return colorHR.Render(strings.Repeat("─", 40))
}
return renderTableRow(line)
}
if strings.TrimSpace(line) == "" {
return ""
}
return applyInlineMarkdown(line)
}
func applyInlineMarkdown(text string) string {
result := text
result = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllStringFunc(result, func(s string) string {
return colorBold.Render(s[2 : len(s)-2])
})
result = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllStringFunc(result, func(s string) string {
return lipgloss.NewStyle().Italic(true).Render(s[1 : len(s)-1])
})
result = regexp.MustCompile("`([^`]+)`").ReplaceAllStringFunc(result, func(s string) string {
return colorCode.Render(s[1 : len(s)-1])
})
result = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllStringFunc(result, func(s string) string {
m := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).FindStringSubmatch(s)
if m != nil {
return colorLink.Render(m[1]) + lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(" ("+m[2]+")")
}
return s
})
return result
}
func renderTableRow(line string) string {
cells := strings.Split(strings.Trim(line, "|"), "|")
rendered := make([]string, len(cells))
for i, c := range cells {
rendered[i] = applyInlineMarkdown(strings.TrimSpace(c))
}
return "│ " + strings.Join(rendered, " │ ") + " │"
}
func renderCodeBlock(codeLines []string, lang string) string {
var out strings.Builder
lang = strings.ToLower(strings.TrimSpace(lang))
label := lang
if label == "" {
label = "code"
}
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true)
blockStyle := lipgloss.NewStyle().Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252"))
out.WriteString(labelStyle.Render(" ┌─ "+label) + "\n")
for _, line := range codeLines {
out.WriteString(blockStyle.Render(" │ ") + HighlightLine(line, lang) + "\n")
}
out.WriteString(labelStyle.Render(" └─") + "\n")
return out.String()
}
// === Статус бар (полноэкранный) ===
func renderStatusBar(m *EditorModel) string {
modeStr := ""
switch m.mode {
case INSERT:
modeStr = colorMode.Render("INSERT") + " "
case VISUAL:
modeStr = colorMode.Render("VISUAL") + " "
}
status := m.statusMsg
if status == "" {
status = fmt.Sprintf("Ln %d/%d Col %d", m.cursorY+1, len(m.lines), m.cursorX+1)
if len(m.undoStack) > 0 {
status += fmt.Sprintf(" u:%d", len(m.undoStack))
}
}
// Правая часть: тип файла + подсказки по панелям
hints := []string{m.fileType}
if m.showFileTree {
hints = append(hints, fmt.Sprintf("tree:%d", m.treeWidth))
}
if m.showPreview && m.fileType == "md" {
hints = append(hints, fmt.Sprintf("prev:%d", m.previewWidth))
}
right := " " + strings.Join(hints, " ") + " "
left := modeStr + status
pad := m.width - len(left) - len(right)
if pad < 0 {
pad = 0
}
bar := left + strings.Repeat(" ", pad) + lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Render(right)
return lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Foreground(lipgloss.Color("8")).
Width(m.width).
Render(bar)
}
// === Утилиты ===
// visibleRange вычисляет диапазон видимых строк
func visibleRange(cursorY, height, totalLines int) (start, end int) {
start = 0
if cursorY > height/2 {
start = cursorY - height/2
}
end = start + height
if end > totalLines {
end = totalLines
}
return
}
// truncateANSI обрезает строку до maxWidth видимых символов,
// игнорируя ANSI escape-последовательности
func truncateANSI(s string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
visible := 0
inEscape := false
var result strings.Builder
for i := 0; i < len(s); {
b := s[i]
if b == '\x1b' {
inEscape = true
result.WriteByte(b)
i++
continue
}
if inEscape {
result.WriteByte(b)
if b == 'm' {
inEscape = false
}
i++
continue
}
if visible >= maxWidth {
break
}
result.WriteByte(b)
visible++
i++
}
// Сбрасываем ANSI если оборвали посередине стиля
if inEscape || visible >= maxWidth {
result.WriteString("\x1b[0m")
}
return result.String()
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

284
search.go Normal file
View file

@ -0,0 +1,284 @@
package main
import (
"strings"
)
// === ПОИСК В СТРОКЕ (f/t команды) ===
func (m *EditorModel) findCharInLine(char rune, forward bool) {
line := []rune(m.lines[m.cursorY])
if forward {
for i := m.cursorX + 1; i < len(line); i++ {
if line[i] == char {
m.cursorX = i
m.lastFindChar = char
m.lastFindIsForward = true
return
}
}
} else {
for i := m.cursorX - 1; i >= 0; i-- {
if line[i] == char {
m.cursorX = i
m.lastFindChar = char
m.lastFindIsForward = false
return
}
}
}
m.SetStatusMsg("Character not found")
}
func (m *EditorModel) findCharToLine(char rune, forward bool) {
line := []rune(m.lines[m.cursorY])
if forward {
for i := m.cursorX + 1; i < len(line); i++ {
if line[i] == char {
if i > 0 {
m.cursorX = i - 1
}
m.lastFindChar = char
m.lastFindIsForward = true
return
}
}
} else {
for i := m.cursorX - 1; i >= 0; i-- {
if line[i] == char {
if i < len(line)-1 {
m.cursorX = i + 1
}
m.lastFindChar = char
m.lastFindIsForward = false
return
}
}
}
m.SetStatusMsg("Character not found")
}
func (m *EditorModel) repeatLastFind(reverse bool) {
if m.lastFindChar == 0 {
m.SetStatusMsg("No previous search")
return
}
forward := m.lastFindIsForward
if reverse {
forward = !forward
}
m.findCharInLine(m.lastFindChar, forward)
}
// === ГЛОБАЛЬНЫЙ ПОИСК (/pattern) ===
func (m *EditorModel) startSearch() {
m.mode = SEARCH
m.searchBuffer = ""
m.searchMatches = nil
m.searchIdx = 0
m.SetStatusMsg("/")
}
func (m *EditorModel) performSearch(pattern string) {
m.lastSearchTerm = pattern
m.searchMatches = nil
m.searchIdx = 0
if pattern == "" {
return
}
for lineNum, line := range m.lines {
// Ищем в рунах чтобы позиции были корректными
lineRunes := []rune(line)
patRunes := []rune(pattern)
for i := 0; i <= len(lineRunes)-len(patRunes); i++ {
match := true
for j, r := range patRunes {
if lineRunes[i+j] != r {
match = false
break
}
}
if match {
m.searchMatches = append(m.searchMatches, struct{ line, col int }{lineNum, i})
}
}
}
if len(m.searchMatches) > 0 {
match := m.searchMatches[0]
m.cursorY = match.line
m.cursorX = match.col
m.searchIdx = 0
m.SetStatusMsg("Found: " + pattern)
} else {
m.SetStatusMsg("Pattern not found: " + pattern)
}
}
func (m *EditorModel) nextMatch() {
if len(m.searchMatches) == 0 {
return
}
m.searchIdx = (m.searchIdx + 1) % len(m.searchMatches)
match := m.searchMatches[m.searchIdx]
m.cursorY = match.line
m.cursorX = match.col
}
func (m *EditorModel) prevMatch() {
if len(m.searchMatches) == 0 {
return
}
m.searchIdx--
if m.searchIdx < 0 {
m.searchIdx = len(m.searchMatches) - 1
}
match := m.searchMatches[m.searchIdx]
m.cursorY = match.line
m.cursorX = match.col
}
// === ЗАМЕНА ===
func (m *EditorModel) replaceAll(old, new string) int {
if old == "" {
return 0
}
count := 0
for i, line := range m.lines {
newLine := strings.ReplaceAll(line, old, new)
if newLine != line {
count += strings.Count(line, old)
m.lines[i] = newLine
m.MarkDirty()
}
}
return count
}
func (m *EditorModel) replaceInLine(old, new string) int {
if old == "" || m.cursorY >= len(m.lines) {
return 0
}
line := m.lines[m.cursorY]
newLine := strings.Replace(line, old, new, 1)
if newLine != line {
m.lines[m.cursorY] = newLine
m.MarkDirty()
return 1
}
return 0
}
// === ЧИСЛОВЫЕ ПРЕФИКСЫ ===
func (m *EditorModel) parseNumberPrefix() int {
if m.numPrefix == "" {
return 1
}
result := 0
for _, ch := range m.numPrefix {
result = result*10 + int(ch-'0')
}
m.numPrefix = ""
return result
}
// === НАВИГАЦИЯ ===
func (m *EditorModel) moveByWords(count int, forward bool) {
for i := 0; i < count; i++ {
if forward {
m.moveNextWord()
} else {
m.movePrevWord()
}
}
}
func (m *EditorModel) moveByLines(count int, direction int) {
m.cursorY += count * direction
m.CursorWithinBounds()
}
// findMatchingBracket — поиск парной скобки (%)
func (m *EditorModel) findMatchingBracket() {
if m.cursorY >= len(m.lines) {
return
}
line := []rune(m.lines[m.cursorY])
if m.cursorX >= len(line) {
return
}
char := line[m.cursorX]
var opening, closing rune
var isOpening bool
switch char {
case '(':
opening, closing, isOpening = '(', ')', true
case ')':
opening, closing, isOpening = '(', ')', false
case '[':
opening, closing, isOpening = '[', ']', true
case ']':
opening, closing, isOpening = '[', ']', false
case '{':
opening, closing, isOpening = '{', '}', true
case '}':
opening, closing, isOpening = '{', '}', false
default:
return
}
if isOpening {
count := 1
for y := m.cursorY; y < len(m.lines); y++ {
lr := []rune(m.lines[y])
startX := 0
if y == m.cursorY {
startX = m.cursorX + 1
}
for x := startX; x < len(lr); x++ {
if lr[x] == opening {
count++
} else if lr[x] == closing {
count--
if count == 0 {
m.cursorY = y
m.cursorX = x
return
}
}
}
}
} else {
count := 1
for y := m.cursorY; y >= 0; y-- {
lr := []rune(m.lines[y])
endX := len(lr)
if y == m.cursorY {
endX = m.cursorX
}
for x := endX - 1; x >= 0; x-- {
if lr[x] == closing {
count++
} else if lr[x] == opening {
count--
if count == 0 {
m.cursorY = y
m.cursorX = x
return
}
}
}
}
}
m.SetStatusMsg("No matching bracket")
}

174
syntax.go Normal file
View file

@ -0,0 +1,174 @@
package main
import (
"regexp"
"strings"
"github.com/charmbracelet/lipgloss"
)
// === Цвета подсветки синтаксиса ===
var (
colorKeyword = lipgloss.NewStyle().Foreground(lipgloss.Color("207")).Bold(true)
colorString = lipgloss.NewStyle().Foreground(lipgloss.Color("106"))
colorComment = lipgloss.NewStyle().Foreground(lipgloss.Color("59")).Italic(true)
colorNumber = lipgloss.NewStyle().Foreground(lipgloss.Color("215"))
colorFunc = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Bold(true)
)
// LanguageConfig описывает правила подсветки для одного языка
type LanguageConfig struct {
Keywords []string
LineCommentPrefix string // "//" или "#" или "--"
StringDelimiters []string // `"`, `'`, `` ` ``
HasNumbers bool
CaseInsensitive bool // для SQL
VarPrefix string // для Bash: "$"
}
// languageConfigs — конфиг для каждого языка
var languageConfigs = map[string]LanguageConfig{
"go": {
Keywords: []string{"func", "package", "import", "var", "const", "type", "struct", "interface", "if", "else", "for", "switch", "case", "default", "return", "break", "continue", "defer", "go", "chan", "select", "map", "range", "nil", "true", "false"},
LineCommentPrefix: "//",
StringDelimiters: []string{`"`, "`"},
HasNumbers: true,
},
"py": {
Keywords: []string{"def", "class", "if", "else", "elif", "for", "while", "import", "from", "return", "pass", "break", "continue", "lambda", "try", "except", "finally", "with", "as", "True", "False", "None", "and", "or", "not", "in", "is", "raise", "yield", "global", "nonlocal", "del", "assert"},
LineCommentPrefix: "#",
StringDelimiters: []string{`"`, `'`},
HasNumbers: true,
},
"js": {
Keywords: []string{"function", "const", "let", "var", "if", "else", "for", "while", "switch", "case", "default", "return", "break", "continue", "async", "await", "import", "export", "class", "extends", "new", "this", "super", "true", "false", "null", "undefined", "typeof", "instanceof", "void", "delete", "throw", "try", "catch", "finally"},
LineCommentPrefix: "//",
StringDelimiters: []string{`"`, `'`, "`"},
HasNumbers: true,
},
"ts": {
Keywords: []string{"function", "const", "let", "var", "if", "else", "for", "while", "switch", "case", "default", "return", "break", "continue", "async", "await", "import", "export", "class", "extends", "new", "this", "super", "true", "false", "null", "undefined", "type", "interface", "enum", "implements", "namespace", "abstract", "readonly", "public", "private", "protected"},
LineCommentPrefix: "//",
StringDelimiters: []string{`"`, `'`, "`"},
HasNumbers: true,
},
"rs": {
Keywords: []string{"fn", "let", "mut", "const", "static", "if", "else", "match", "for", "while", "loop", "break", "continue", "return", "pub", "mod", "use", "struct", "enum", "trait", "impl", "unsafe", "async", "await", "move", "ref", "self", "Self", "super", "crate", "true", "false"},
LineCommentPrefix: "//",
StringDelimiters: []string{`"`},
HasNumbers: true,
},
"c": {
Keywords: []string{"int", "char", "void", "float", "double", "long", "short", "unsigned", "signed", "if", "else", "for", "while", "do", "switch", "case", "default", "return", "break", "continue", "struct", "union", "enum", "typedef", "static", "const", "extern", "volatile", "sizeof", "NULL"},
LineCommentPrefix: "//",
StringDelimiters: []string{`"`},
HasNumbers: true,
},
"cpp": {
Keywords: []string{"int", "char", "void", "float", "double", "long", "short", "unsigned", "signed", "if", "else", "for", "while", "do", "switch", "case", "default", "return", "break", "continue", "struct", "union", "enum", "typedef", "static", "const", "extern", "volatile", "class", "public", "private", "protected", "virtual", "override", "new", "delete", "nullptr", "true", "false", "auto", "template", "typename", "namespace", "using", "this"},
LineCommentPrefix: "//",
StringDelimiters: []string{`"`},
HasNumbers: true,
},
"java": {
Keywords: []string{"class", "interface", "extends", "implements", "public", "private", "protected", "static", "final", "void", "int", "boolean", "char", "double", "float", "long", "short", "byte", "if", "else", "for", "while", "do", "switch", "case", "default", "return", "break", "continue", "new", "this", "super", "null", "true", "false", "try", "catch", "finally", "throw", "throws", "import", "package", "abstract", "synchronized"},
LineCommentPrefix: "//",
StringDelimiters: []string{`"`},
HasNumbers: true,
},
"sql": {
Keywords: []string{"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE", "TABLE", "DROP", "ALTER", "ADD", "COLUMN", "INDEX", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "AS", "DISTINCT", "COUNT", "SUM", "AVG", "MAX", "MIN", "NULL", "IS", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "CONSTRAINT", "UNIQUE", "DEFAULT"},
LineCommentPrefix: "--",
StringDelimiters: []string{`'`},
HasNumbers: true,
CaseInsensitive: true,
},
"sh": {
Keywords: []string{"if", "then", "else", "elif", "fi", "for", "do", "done", "while", "case", "esac", "function", "return", "export", "local", "readonly", "unset", "shift", "echo", "exit", "break", "continue", "true", "false"},
LineCommentPrefix: "#",
StringDelimiters: []string{`"`, `'`},
HasNumbers: false,
VarPrefix: "$",
},
}
// HighlightLine подсвечивает одну строку кода для заданного типа файла
func HighlightLine(line, fileType string) string {
cfg, ok := languageConfigs[fileType]
if !ok {
return line
}
return highlightWithConfig(line, cfg)
}
// highlightWithConfig применяет правила подсветки из LanguageConfig
func highlightWithConfig(line string, cfg LanguageConfig) string {
result := line
// 1. Комментарии — применяем первыми, остальное внутри комментария не трогаем
if cfg.LineCommentPrefix != "" {
if idx := strings.Index(result, cfg.LineCommentPrefix); idx != -1 {
before := result[:idx]
comment := result[idx:]
result = highlightTokens(before, cfg) + colorComment.Render(comment)
return result
}
}
result = highlightTokens(result, cfg)
return result
}
// highlightTokens применяет подсветку строк, ключевых слов, чисел, переменных
func highlightTokens(text string, cfg LanguageConfig) string {
result := text
// Строки — выделяем до ключевых слов, чтобы не подсвечивать kw внутри строк
for _, delim := range cfg.StringDelimiters {
if delim == "`" {
result = regexp.MustCompile("`[^`]*`").ReplaceAllStringFunc(result, func(s string) string {
return colorString.Render(s)
})
} else if delim == `"` {
result = regexp.MustCompile(`"[^"]*"`).ReplaceAllStringFunc(result, func(s string) string {
return colorString.Render(s)
})
} else if delim == `'` {
result = regexp.MustCompile(`'[^']*'`).ReplaceAllStringFunc(result, func(s string) string {
return colorString.Render(s)
})
}
}
// Ключевые слова
for _, kw := range cfg.Keywords {
var pattern *regexp.Regexp
if cfg.CaseInsensitive {
pattern = regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(kw) + `\b`)
} else {
pattern = regexp.MustCompile(`\b` + regexp.QuoteMeta(kw) + `\b`)
}
rendered := colorKeyword.Render(kw)
if cfg.CaseInsensitive {
rendered = colorKeyword.Render(strings.ToUpper(kw))
}
result = pattern.ReplaceAllString(result, rendered)
}
// Числа
if cfg.HasNumbers {
result = regexp.MustCompile(`\b\d+(\.\d+)?\b`).ReplaceAllStringFunc(result, func(s string) string {
return colorNumber.Render(s)
})
}
// Переменные (Bash: $VAR)
if cfg.VarPrefix != "" {
escaped := regexp.QuoteMeta(cfg.VarPrefix)
result = regexp.MustCompile(escaped+`\w+`).ReplaceAllStringFunc(result, func(s string) string {
return colorFunc.Render(s)
})
}
return result
}

4
untitled.md Normal file
View file

@ -0,0 +1,4 @@
```bash
deqwdq wdaw
SDW
```