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