Version 0.1
This commit is contained in:
parent
0a8b4e804a
commit
b4350ce59f
21 changed files with 7189 additions and 0 deletions
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal 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
514
ARCHITECTURE.md
Normal 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
|
||||
|
||||
Вместо всего шрифта (~300–500 KB) в PDF встраивается только подмножество реально использованных глифов. Для типичного русского текста: 200–400 глифов вместо 3000+. Composite glyphs обрабатываются рекурсивно.
|
||||
|
||||
### Что рендерится в PDF
|
||||
|
||||
Заголовки H1–H4 с цветами и линией под H1, параграфы с переносом строк, **bold**/*italic*/`code`, блоки кода с фоном и полоской, цитаты blockquote, маркированные и нумерованные списки, таблицы, горизонтальные линии, многостраничность.
|
||||
|
||||
---
|
||||
|
||||
## Загрузка статей (fetch.go)
|
||||
|
||||
```
|
||||
FetchAndConvert(url)
|
||||
├── fetchHTML()
|
||||
│ ├── net/http с таймаутом 30с, лимитом 5 MB
|
||||
│ └── User-Agent чтобы не получить 403
|
||||
│
|
||||
├── extractTitle() — og:title → <title> → <h1>
|
||||
│
|
||||
├── extractMainContent()
|
||||
│ ├── Удаляем: <script>, <style>, <nav>, <header>, <footer>, <aside>
|
||||
│ ├── Ищем: <article>, <main>, div[role=main], div.content/post/article
|
||||
│ └── Fallback: блок с максимальной длиной текста
|
||||
│
|
||||
└── htmlToMarkdown()
|
||||
├── <h1>–<h6> → # заголовки
|
||||
├── <p> → параграфы
|
||||
├── <ul>/<ol>/<li> → - / 1. списки
|
||||
├── <pre>/<code> → ``` блоки
|
||||
├── <blockquote> → > цитаты
|
||||
├── <strong>/<em> → **bold**/*italic*
|
||||
├── <img> → 
|
||||
├── <table>/<tr>/<td> → | таблицы |
|
||||
└── HTML entities → нормальные символы
|
||||
```
|
||||
|
||||
**Имя файла** генерируется из заголовка через `slugify()` — транслитерация + латиница + дефисы, максимум 60 символов.
|
||||
|
||||
---
|
||||
|
||||
## Undo / Redo (model.go)
|
||||
|
||||
```
|
||||
saveUndoSnapshot() ← перед КАЖДОЙ мутацией
|
||||
│ копирует lines[], cursorX, cursorY → undoStack
|
||||
│ очищает redoStack
|
||||
│ ограничивает стек 100 записями
|
||||
|
||||
u → Undo(): undoStack → текущее, текущее → redoStack
|
||||
Ctrl+R → Redo(): redoStack → текущее, текущее → undoStack
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Поиск (search.go)
|
||||
|
||||
### Глобальный `/pattern`
|
||||
|
||||
Поиск по рунам — позиции корректны для любого Unicode. `n`/`N` циклически обходят все совпадения.
|
||||
|
||||
### f/t навигация
|
||||
|
||||
| Команда | Действие |
|
||||
|---------|----------|
|
||||
| `f{c}` / `F{c}` | символ вправо/влево |
|
||||
| `t{c}` / `T{c}` | позиция до символа вправо/влево |
|
||||
| `;` / `,` | повторить в том же / обратном направлении |
|
||||
| `%` | парная скобка `()[]{}` через строки |
|
||||
|
||||
---
|
||||
|
||||
## Автосейв (model.go)
|
||||
|
||||
```go
|
||||
tea.Tick(interval, ...) // интервал из конфига
|
||||
// В Update():
|
||||
if autosaveInterval > 0 && time.Since(lastSave) >= interval && dirty {
|
||||
save()
|
||||
}
|
||||
```
|
||||
|
||||
`:set autosave=30` — изменить интервал на лету. `:set autosave=0` — выключить.
|
||||
|
||||
---
|
||||
|
||||
## Команды (:)
|
||||
|
||||
| Команда | Действие |
|
||||
|---------|----------|
|
||||
| `:w` / `:w path` | сохранить / сохранить копию |
|
||||
| `:saveas path` | сохранить как (переключиться на новый файл) |
|
||||
| `:q` / `:q!` / `:wq` | выйти |
|
||||
| `:e path` / `:e` | открыть файл / перезагрузить текущий |
|
||||
| `:new` / `:new path` | новый буфер / новый файл |
|
||||
| `:fetch URL` | скачать статью и открыть |
|
||||
| `:export pdf` | экспорт в PDF |
|
||||
| `:export pdf path` | экспорт в указанный путь |
|
||||
| `:tree` / `:notree` | показать/скрыть дерево |
|
||||
| `:N` | перейти на строку N |
|
||||
| `:s/old/new/` | заменить первое вхождение |
|
||||
| `:s/old/new/g` | заменить все вхождения |
|
||||
| `:set` | показать все настройки |
|
||||
| `:set number` / `:set nonumber` | номера строк |
|
||||
| `:set tab_width=2` | ширина таба |
|
||||
| `:set autosave=30` | интервал автосейва |
|
||||
| `:u` | undo |
|
||||
| `:config show/paths/write/reload` | управление конфигом |
|
||||
|
||||
---
|
||||
|
||||
## Зависимости
|
||||
|
||||
| Пакет | Назначение |
|
||||
|-------|------------|
|
||||
| `charmbracelet/bubbletea` | event loop, Model-Update-View |
|
||||
| `charmbracelet/lipgloss` | стилизация (цвет, bold, width) |
|
||||
| `charmbracelet/bubbles/viewport` | скроллируемая область |
|
||||
| `net/http` | загрузка URL (стандартная библиотека) |
|
||||
|
||||
Подсветка синтаксиса, PDF генерация, TTF парсинг, INI парсер, HTML→Markdown конвертер — всё реализовано внутри без внешних зависимостей.
|
||||
|
||||
---
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
go build -ldflags="-s -w" -o grimoir
|
||||
|
||||
# Записать дефолтный конфиг:
|
||||
./grimoir --write-config
|
||||
|
||||
# Запуск:
|
||||
./grimoir README.md
|
||||
./grimoir main.go
|
||||
./grimoir https://habr.com/ru/articles/123456/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шпаргалка
|
||||
|
||||
```
|
||||
НАВИГАЦИЯ РЕДАКТИРОВАНИЕ ПОИСК
|
||||
h j k l i / a / A /pattern поиск
|
||||
w b e I / o / O n / N следующий/предыдущий
|
||||
gg / G x удалить f{c} символ в строке
|
||||
0 / ^ / $ dd строку ; , повторить f/t
|
||||
5j / 3dd yy скопировать % парная скобка
|
||||
p / P вставить
|
||||
u undo
|
||||
Ctrl+R redo
|
||||
|
||||
ПАНЕЛИ ФАЙЛ INSERT режим
|
||||
Ctrl+B дерево :w сохранить стрелки навигация
|
||||
Tab фокус :wq сохранить+выйти home/end начало/конец строки
|
||||
< / > ширина :e path открыть любой Unicode включая кириллицу
|
||||
Пробел preview :fetch URL скачать
|
||||
```
|
||||
205
README.md
Normal file
205
README.md
Normal 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
525
commands.go
Normal 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 1–16")
|
||||
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
432
config.go
Normal 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 1–16", 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
|
||||
|
||||
# Ширина табуляции (1–16)
|
||||
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
551
fetch.go
Normal 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("", 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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
""", "\"",
|
||||
"'", "'",
|
||||
"'", "'",
|
||||
" ", " ",
|
||||
"—", "—",
|
||||
"–", "–",
|
||||
"«", "«",
|
||||
"»", "»",
|
||||
"…", "…",
|
||||
"©", "©",
|
||||
"®", "®",
|
||||
"™", "™",
|
||||
"•", "•",
|
||||
"·", "·",
|
||||
)
|
||||
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
28
file.go
Normal 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
54
file_io.go
Normal 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
307
filetree.go
Normal 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
27
go.mod
Normal 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
41
go.sum
Normal 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
275
help.go
Normal 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
639
keybindings.go
Normal 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
105
main.go
Normal 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
457
model.go
Normal 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
905
pdf.go
Normal 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.0–1.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
1113
pdf_font.go
Normal file
File diff suppressed because it is too large
Load diff
503
render.go
Normal file
503
render.go
Normal 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
284
search.go
Normal 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
174
syntax.go
Normal 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
4
untitled.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
```bash
|
||||
deqwdq wdaw
|
||||
SDW
|
||||
```
|
||||
Loading…
Add table
Reference in a new issue