refactoring

This commit is contained in:
2026-10-31 15:33:00 +03:00
parent e8e2d25600
commit 838627fb33
27 changed files with 2715 additions and 1259 deletions
+44 -15
View File
@@ -11,8 +11,8 @@
| `src/main.rs` | Точка входа, окно, аргументы командной строки |
| `src/constants.rs` | Константы окна, CLI и каталог сценариев |
| `src/editor.rs` | Доменная модель: документы, вкладки, языки |
| `src/api.rs` | `AppApi` / `EditorApi` / `PanelApi` / `TreeApi` / `ToolbarApi` — единственная поверхность управления |
| `src/script.rs` | Lua-движок, `app`, `include`, `require` |
| `src/api.rs` | `AppApi` / `EditorApi` / `PanelsApi` / `PanelApi` / `ToolbarApi` / `TreeApi` / `PathApi` / `WindowApi` / `KeymapApi` — единственная поверхность управления |
| `src/script.rs` | Lua-движок, `api`, `include`, `require` |
| `src/prompt.rs` | Плавающее поле ввода: запрос из Lua и открытое окно |
| `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики |
| `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение |
@@ -23,18 +23,41 @@
| `src/gutter.rs` | Иконки на полях слева от редактора |
| `src/icons.rs` | Каталог иконок Lucide |
| `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер |
| `DOCS.md`, `init.lua` | Пользовательская документация и пример конфигурации |
| `docs/*.md`, `init.lua` | Пользовательская документация и пример конфигурации |
## Главная идея: конструктор IDE
qode — не готовый редактор с фиксированным набором функций, а конструктор
своей IDE. Rust/GPUI даёт универсальные примитивы, Lua задаёт поведение:
- **Rust** отвечает за окно, вкладки, текстовый редактор, подсветку, gutter,
автодополнение, панели, абстрактное дерево, плавающее поле ввода, доступ к
файловой системе, клавиатуру и LSP-клиент.
- **Lua** отвечает за всё, что делает редактор именно этой IDE: файловое
дерево, дерево диагностики, команды, раскладки, статусы, проектные
сценарии, оформление панелей и последовательности действий.
Правило для изменений:
- В Rust добавляется только универсальный примитив, которым могут
пользоваться разные сценарии, или то, что нельзя выразить через уже
существующее API (доступ к GPUI, ОС, протоколу LSP).
- Если поведение выражается через API из Lua, оно должно жить в Lua, а не в
Rust: никаких проектных правил, готовых панелей с бизнес-данными,
демонстрационных виджетов и команд под конкретный workflow.
- Новый примитив сразу получает Lua-метод и описание в `docs/`.
## Главные принципы
### 1. `AppApi` — центр приложения
- Документы, вкладки, панели, дерево, окно, подсветка, LSP и привязки клавиш
меняются только через `AppApi`, `EditorApi` или `PanelApi` / `TreeApi` /
`ToolbarApi`.
меняются только через `AppApi`, `EditorApi`, `PanelsApi`, `PanelApi`,
`ToolbarApi`, `TreeApi`, `PathApi`, `WindowApi` или `KeymapApi`.
- Всё, что относится к редактору (вкладки, текст, gutter, сохранение, LSP и
привязки редактора), живёт в `app.editor`; окно, панели и глобальные
привязки — в `app`.
привязки редактора), живёт в `api.editor`; панели — в `api.panels`, файловая
система — в `api.path`, окно — в `api.window`, глобальные привязки — в
`api.keymap`.
- Состояние живёт в общих `Rc<RefCell<_>>` / `Arc`, чтобы UI, API и Lua
видели одни и те же объекты. Второй копии состояния быть не должно.
- Команды окна складываются в очередь и применяются в начале кадра
@@ -47,15 +70,21 @@
### 2. Lua повторяет возможности API
- Публичные методы `impl AppApi` / `impl EditorApi` с `#[mlua::userdata_impl]`
доступны как `app:method(...)` / `app.editor:method(...)`. Добавляя
возможность, сразу добавляйте и Lua-метод.
- Публичные методы `impl AppApi` / `impl EditorApi` / `impl PathApi` и других
`*Api` с `#[mlua::userdata_impl]` доступны из Lua через объект `api`:
`api.editor:method(...)`, `api.path:method(...)`, `api.panels.left:method(...)`.
Добавляя возможность, сразу добавляйте и Lua-метод.
- Имена — `snake_case`; вкладки и индексы нумеруются с нуля; панели —
`app.left_panel`, `app.right_panel`, `app.bottom_panel`; блоки панелей
получают свои объекты (`tree`).
`api.panels.left`, `api.panels.right`, `api.panels.bottom`,
`api.panels.toolbar`; блоки панелей получают свои объекты (`tree`).
- `AppApi` — только группа-навигатор: методы живут в `EditorApi`,
`PanelsApi`, `PathApi`, `WindowApi` или `KeymapApi`. Не добавляйте новый
метод прямо в `api`, если для него есть подходящая группа.
- Все методы `PathApi` принимают путь либо строкой `"foo/bar"`, либо
массивом сегментов `{ "foo", "bar" }`.
- Для методов, которые не должны попадать в Lua, используется `#[lua(skip)]`.
Для generic-методов добавляется тонкая не-generic Lua-обёртка.
- `DOCS.md` и комментарии в `init.lua` обновляются в том же изменении, что и
- `docs/*.md` и комментарии в `init.lua` обновляются в том же изменении, что и
публичный API. Документация описывает фактическое поведение, без
«запланированных» методов.
@@ -63,8 +92,8 @@
- Встроенные привязки (`Ctrl/Cmd+S`, `Ctrl/Cmd+Space`, навигация по списку
автодополнения) регистрируются в `KeyMap` и могут быть заменены или удалены
из Lua через `app:map_key` / `app.editor:map_key` /
`app:map_completion_key`.
из Lua через `api.keymap:map_key` / `api.editor:map_key` /
`api.keymap:map_completion_key`.
- У приложения, редактора и каждого блока панели своя `KeyMap` и свой текущий
режим (один символ; `-` — режим по умолчанию). Привязка хранит строку
режимов, в которых она действует: `""` — все, `"-iv"` — по умолчанию и
Generated
+137 -11
View File
@@ -172,7 +172,7 @@ checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176"
dependencies = [
"anstyle",
"memchr",
"unicode-width 0.2.3",
"unicode-width",
]
[[package]]
@@ -820,9 +820,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.10.0"
version = "1.2.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -916,7 +916,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width 0.1.14",
"unicode-width",
]
[[package]]
@@ -2154,13 +2154,23 @@ dependencies = [
"smol",
"tracing",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-c",
"tree-sitter-cpp",
"tree-sitter-css",
"tree-sitter-go",
"tree-sitter-html",
"tree-sitter-javascript",
"tree-sitter-json",
"tree-sitter-lua",
"tree-sitter-make",
"tree-sitter-md",
"tree-sitter-python",
"tree-sitter-rust",
"tree-sitter-sequel",
"tree-sitter-yaml",
"unicode-segmentation",
"unicode-width 0.2.3",
"unicode-width",
"uuid",
"windows 0.58.0",
]
@@ -5305,6 +5315,8 @@ dependencies = [
"notify",
"parking_lot",
"serde_json",
"tree-sitter-containerfile",
"tree-sitter-xml",
]
[[package]]
@@ -7099,6 +7111,56 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-bash"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-c"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-containerfile"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4811d55a5a2c32bb024b441c32b7417c3ec1af1a080a25fa20d321627b65a2b"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-cpp"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-css"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-go"
version = "0.23.4"
@@ -7109,6 +7171,16 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-html"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-javascript"
version = "0.23.1"
@@ -7135,6 +7207,36 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a"
[[package]]
name = "tree-sitter-lua"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea992f4164d83f371ef1239ae178c4d4596c296c09055e9a48bb02a2760403af"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-make"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-md"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed661bfe3c264961425f77cd89ad2998bf5d629e71ce8bab1e5d7e940d6246e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-python"
version = "0.23.6"
@@ -7155,6 +7257,36 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-sequel"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-xml"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e670041f591d994f54d597ddcd8f4ebc930e282c4c76a42268743b71f0c8b6b3"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-yaml"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "triomphe"
version = "0.1.17"
@@ -7270,12 +7402,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.3"
+14
View File
@@ -10,7 +10,21 @@ gpui-kit = { version = "0.8.1", features = [
"tree-sitter-go",
"tree-sitter-javascript",
"tree-sitter-python",
"tree-sitter-lua",
"tree-sitter-bash",
"tree-sitter-yaml",
"tree-sitter-make",
"tree-sitter-sql",
"tree-sitter-markdown",
"tree-sitter-css",
"tree-sitter-c",
"tree-sitter-cpp",
"tree-sitter-html",
] }
# Грамматик Dockerfile и XML в gpui-kit нет, поэтому они подключаются
# отдельными крейтами и регистрируются в `src/syntax.rs`.
tree-sitter-containerfile = "0.9"
tree-sitter-xml = "0.7"
mlua = { version = "0.12", features = ["lua54", "vendored", "macros"] }
# LSP-клиент: JSON-RPC поверх stdio, отслеживание файлов и типы протокола.
-713
View File
@@ -1,713 +0,0 @@
# Lua API
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект
`app` для управления окном, панелями и глобальными привязками клавиш.
Вкладки, текст, gutter, подсветка, сохранение и LSP-серверы живут в `app.editor`,
а блоки панелей (деревья) — в своих объектах.
Сам редактор не содержит демонстрационных документов: при запуске открыта
одна пустая вкладка `untitled-1.txt`. Всё остальное — файлы, деревья, серверы —
настраивается сценарием.
## Запуск и каталог сценариев
Каталог поиска `init.lua` задаётся константой `SCRIPT_DIR` в `src/constants.rs`
(по умолчанию — текущий каталог). Его можно переопределить аргументом командной строки:
```sh
qode --config /path/to/project main.go
cargo run -- --config ./config
cargo run -- main.go
```
- В каталоге `--config` должны лежать `init.lua` и подключаемые им модули.
- Первый позиционный аргумент открывается после выполнения `init.lua`
через `app.editor:open(path)` — как `vim main.go` или `nano main.go`.
- `--config` меняет только каталог сценариев. Рабочий каталог процесса, а
вместе с ним пути `app.editor:open`, `app.editor:save` и `root` у LSP не меняются.
## Объект `app`
Объект `app` управляет окном и панелями. Всё, что относится к самому
редактору — вкладки, текст, gutter, сохранение, LSP и привязки клавиш
редактора, — живёт в `app.editor`:
```lua
app.editor:open("src/main.rs")
app.editor:save()
app.editor:map_key("n", "<C-s>", "save")
```
Номера вкладок во всех методах нумеруются с нуля. Массив, который возвращает
`app.editor:tabs()`, остаётся обычной Lua-таблицей с индексами от 1, поэтому поле
`index` — единственный надёжный способ сослаться на вкладку.
### Вкладки
| Метод | Описание |
| --- | --- |
| `app.editor:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs", icon = "file" }, ...}` |
| `app.editor:active_tab()` | Индекс активной вкладки |
| `app.editor:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует |
| `app.editor:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
| `app.editor:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс |
| `app.editor:open_tab()` | Открыть пустую вкладку, вернуть индекс |
| `app.editor:open(path, position)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем. Без второго аргумента фокус не меняется; `{}` — перевести фокус в редактор, оставив курсор; `{line, column}` — ещё и поставить курсор (с нуля) |
| `app.editor:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта |
| `app.editor:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна |
| `app.editor:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) |
| `app.editor:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет |
```lua
app.editor:open("src/main.rs") -- открыть, не трогая фокус
app.editor:open("src/main.rs", {}) -- фокус в редактор, курсор на месте
app.editor:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор на строку 4, столбец 2
```
Строка и столбец нумеруются с нуля, как в диагностике LSP; столбец считается
в UTF-16.
### Иконки вкладок
Слева от названия вкладки показывается иконка; по умолчанию это `file`.
Имя — название иконки [Lucide](https://lucide.dev/icons/), регистр не важен.
Например:
```lua
app.editor:set_tab_icon(0, "file-code")
app.editor:set_tab_icon(1, "book-open")
app.editor:clear_tab_icon(1) -- снова "file"
```
Подчёркивания и пробелы считаются дефисом, а `icons/` и `.svg` можно
не писать: `file_code`, `file-code` и `icons/file-code.svg` равнозначны.
Неизвестное имя не назначается — метод вернёт `false`.
### Иконки на полях редактора (gutter)
Слева от редактора есть узкая колонка с иконками. Методы действуют на
активную вкладку, а номера строк нумеруются с нуля, как в LSP. На одной строке
живёт одна иконка; повторный вызов заменяет прежнюю.
| Метод | Описание |
| --- | --- |
| `app.editor:set_gutter_icon(line, icon, color)` | Поставить иконку на строку; `color` необязателен (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`). Без цвета иконка рисуется цветом темы. `false` — иконка или цвет неизвестны |
| `app.editor:gutter_icon(line)` | Имя иконки на строке или `nil` |
| `app.editor:gutter_color(line)` | Цвет иконки или `nil`, если цвет не задан |
| `app.editor:clear_gutter_icon(line)` | Убрать иконку со строки; `false`, если её не было |
| `app.editor:clear_gutter()` | Убрать все иконки активной вкладки |
```lua
app.editor:set_gutter_icon(4, "circle", "#e5484d") -- красная точка на 5-й строке
app.editor:set_gutter_icon(9, "play") -- цвет темы
app.editor:clear_gutter_icon(9)
```
Иконки привязаны к номерам строк и автоматически не сдвигаются при правке
текста: сценарий сам решает, когда их переставить. При прокрутке колонка
двигается вместе с редактором.
### Текст
| Метод | Описание |
| --- | --- |
| `app.editor:active_text()` | Текст активной вкладки |
| `app.editor:set_active_text(text)` | Заменить текст активной вкладки |
### Подсветка синтаксиса
Подсветка по умолчанию выключена. Поддерживаемые языки: `rust`, `go`, `js`, `json`,
`python`.
| Метод | Описание |
| --- | --- |
| `app.editor:set_language(index, "rust")` | Включить подсветку для вкладки; `false`, если вкладки нет или язык не поддерживается |
| `app.editor:language(index)` | Язык подсветки вкладки или `nil` |
| `app.editor:clear_language(index)` | Выключить подсветку для вкладки |
```lua
app.editor:set_language(0, "rust")
```
### Сохранение
| Метод | Описание |
| --- | --- |
| `app.editor:save()` | Сохранить активную вкладку в её файл |
### Файловая система
| Метод | Описание |
| --- | --- |
| `app:list_dir(path)` | Содержимое каталога: `{{ id = "src/main.rs", name = "main.rs", is_dir = false }, ...}` |
Путь отсчитывается от текущего рабочего каталога процесса. Пустая строка или
`"."` означают сам текущий каталог. Поле `id` — путь элемента относительно
текущего каталога: его можно передать в `app.editor:open(id)` и использовать как
идентификатор узла дерева. Элементы уже отсортированы по имени без учёта
регистра.
```lua
for _, entry in ipairs(app:list_dir("src")) do
print(entry.name, entry.is_dir)
end
```
### Привязка клавиш
Привязки живут у трёх видов объектов: у приложения (`app`), у редактора
(`app.editor`) и у каждого блока панели (`tree`). Встроенные сочетания можно
заменить или удалить: обработчиком принимается Lua-функция, имя встроенной
команды или `nil`.
```lua
app:map_key(modes, keys, handler) -- глобальные
app.editor:map_key(modes, keys, handler) -- только когда фокус в редакторе
app:map_completion_key(keys, handler) -- список автодополнения
```
Слой автодополнения режимами не управляется: `map_completion_key` принимает
только клавишу и обработчик.
Первым аргументом `map_key` идёт строка режимов. Порядок обработки всегда
такой: сначала глобальные привязки, затем — если фокус в редакторе — привязки
редактора, а если фокус на блоке панели — привязки этого блока. Привязки
неактивного объекта не срабатывают.
Запись клавиши в духе nvim:
| Запись | Значение |
| --- | --- |
| `"a"` | нажатие A |
| `"1"` | нажатие 1 |
| `"!a"` | отпускание A |
| `"<S-c>"` | C при зажатом Shift |
| `"<C-z>"` | Z при зажатом Ctrl |
| `"<A-!r>"` | отпускание R при зажатом Alt |
| `"<D-s>"` | S при зажатом Cmd на macOS или Super на Linux |
Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux
и Windows буквы берутся по аппаратному keycode, поэтому `<C-q>` работает
и на русской раскладке.
#### Режимы
У каждого объекта своя текущая режимная метка: один символ (`n`, `i`, `v`, `r`
или любой другой) либо `-` — режим по умолчанию. Режимы динамические: заранее
создавать их не нужно, достаточно переключить метку.
| Значение `modes` | Когда срабатывает |
| --- | --- |
| `""` | во всех режимах |
| `"-"` | только в режиме по умолчанию |
| `"niv"` | в режимах `n`, `i`, `v` |
| `"-iv"` | в режиме по умолчанию, а также `i` и `v` |
Текущий режим меняется методами `set_mode(mode)` и читается методом `mode()`.
Режимы у приложения, редактора и каждого блока независимы.
```lua
app:set_mode("n")
app.editor:set_mode("i")
app:map_key("n", "<C-n>", "next_tab") -- только в режиме n
app.editor:map_key("i", "<C-s>", "save") -- только в режиме i редактора
app.editor:map_key("", "<C-space>", function() end) -- в любом режиме
```
Встроенные команды:
| Имя | Действие |
| --- | --- |
| `"save"` | Сохранить активную вкладку |
| `"next_tab"` | Следующая вкладка |
| `"previous_tab"` | Предыдущая вкладка |
| `"open_tab"` | Новая пустая вкладка |
| `"close_tab"` | Закрыть активную вкладку |
| `"toggle_left_panel"` | Переключить левую панель |
| `"toggle_right_panel"` | Переключить правую панель |
| `"toggle_bottom_panel"` | Переключить нижнюю панель |
Встроенные команды списка автодополнения:
| Имя | Действие |
| --- | --- |
| `"completion_trigger"` | Открыть список |
| `"completion_next"` / `"completion_previous"` | Сдвинуть выделение |
| `"completion_page_down"` / `"completion_page_up"` | Пролистать список |
| `"completion_accept"` | Вставить выбранный элемент |
| `"completion_cancel"` | Закрыть список |
| `"completion_dismiss"` | Закрыть список и передать клавишу редактору |
При запуске уже назначены:
- `Ctrl/Cmd+S` → `save` во всех режимах;
- `Ctrl/Cmd+Space` → `completion_trigger`;
- `Enter`/`Tab`, `Esc`, `↑`/`↓`, `PageUp`/`PageDown`, `←`/`→`, `Home`/`End`
→ команды слоя автодополнения.
```lua
-- Переопределить сохранение своей функцией во всех режимах.
app:map_key("", "<C-s>", function() app.editor:save() end)
-- Убрать встроенную привязку.
app:map_key("", "<C-s>", nil)
-- Назначить встроенную команду на другую клавишу.
app:map_key("", "<C-n>", "next_tab")
app:map_key("", "<C-b>", "toggle_left_panel")
-- Навигация по списку автодополнения на Ctrl+N / Ctrl+P.
app:map_completion_key("<C-n>", "completion_next")
app:map_completion_key("<C-p>", "completion_previous")
-- Привязка, которая срабатывает только при фокусе в редакторе.
app.editor:map_key("", "<A-r>", function()
app.editor:set_active_text("editor mode")
end)
-- Привязка блока дерева: работает, когда фокус на этом дереве.
tree:map_key("v", "<A-f>", function() print("tree v") end)
```
Пока список открыт, его слой проверяется раньше действий редактора. Сочетания
с модификаторами и горизонтальные стрелки закрывают список, даже если для них
нет своей привязки, — так `Shift+↑` продолжает выделять текст.
### Окно
| Метод | Описание |
| --- | --- |
| `app:close()` | Закрыть окно |
| `app:toggle_maximize()` | Развернуть окно / вернуть прежний размер |
| `app:set_maximized(true/false)` | Развернуть (`true`) или восстановить (`false`) окно |
| `app:minimize()` | Свернуть окно |
| `app:is_maximized()` | Развёрнуто ли окно прямо сейчас |
### Плавающее поле ввода
`app:open_input(options)` открывает однострочное поле ввода чуть выше
центра окна. Параметры передаются таблицей:
| Поле | Описание |
| --- | --- |
| `title` | Текст над полем; если не задан, окно состоит из одной строки ввода |
| `placeholder` | Подсказка внутри пустого поля ввода |
| `callback` | `function(text)` — вызывается после Enter, когда окно уже закрыто |
Пока окно открыто, сочетания клавиш приложения не работают. Текст набирается
в поле; `Enter` подтверждает ввод и запускает `callback`, `Esc` и клик мышью
вне окна закрывают его без вызова колбэка.
```lua
app:open_input({
title = "Имя файла",
placeholder = "src/main.rs",
callback = function(text)
app.editor:open(text, {})
end,
})
```
## LSP-серверы
Запуск серверов целиком настраивается Lua-сценарием.
```lua
app.editor:run_lsp({
name = "gopls", -- условное имя для stop/restart
cmd = "gopls", -- путь к бинарнику или имя в PATH
args = { "serve" }, -- аргументы процесса
env = { GOFLAGS = "-mod=mod" }, -- переменные окружения
languages = { "go" }, -- какие languageId обслуживает
watcher = "**/*.go", -- шаблон файлового вотчера
root = ".", -- корень workspace (по умолчанию cwd)
initialization_options = {}, -- initializationOptions сервера
})
```
| Метод | Описание |
| --- | --- |
| `app.editor:run_lsp(config)` | Запустить сервер по описанию |
| `app.editor:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` |
| `app.editor:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен |
| `app.editor:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками |
| `app.editor:on_lsp_diagnostics(delay_ms, fn)` | Обработчик диагностики всех серверов; `nil` вместо `fn` удаляет его |
`cmd` обязателен. Если `root` не задан или относителен, он отсчитывается от
текущего рабочего каталога процесса. Поле `watcher` принимает строку, таблицу
или boolean:
```lua
watcher = "**/*.go"
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
watcher = true -- все файлы, игнорируя только стандартные каталоги сборки
```
Автодополнение включается само при вводе идентификатора; `Ctrl/Cmd+Space`
открывает список вручную. Поведение клавиш описано в разделе «Привязка
клавиш» и может быть переопределено через `app:map_completion_key`.
Документация выбранного элемента показывается второй всплывающей панелью
справа от списка.
Диагностику, которую сервер присылает после правок, редактор подчёркивает
волнистой линией: ошибки — красной, предупреждения и остальные сообщения —
жёлтой. Подчёркивание работает и при выключенной подсветке синтаксиса.
### Обработчик диагностики
`app.editor:on_lsp_diagnostics(delay_ms, fn)` регистрирует один обработчик на все
LSP-серверы. `delay_ms` — пауза тишины: если сообщения приходят чаще, вызовы
пропускаются, а обработчик получает последний снимок после того, как
обновления прекратились. Если зарегистрировать обработчик, когда диагностика
уже накоплена, он сработает сразу после этой паузы.
```lua
app.editor:on_lsp_diagnostics(1000, function(servers)
for _, entry in ipairs(servers) do
print(entry.server, entry.path, #entry.diagnostics)
end
end)
```
`servers` — массив записей по одной на пару «документ, сервер»:
```lua
{
server = "gopls", -- имя сервера, приславшего диагностику
uri = "file:///project/main.go", -- URI документа
path = "/project/main.go", -- путь файла; nil для не-file URI
diagnostics = {
{
line = 0, character = 2, -- начало (с нуля, как в LSP)
end_line = 0, end_character = 8,
severity = 1, -- 1 ошибка, 2 предупреждение, 3 info, 4 hint
severity_name = "error", -- error | warning | information | hint
message = "undefined: foo",
source = "gopls",
code = "UndeclaredName",
tags = {}, -- unnecessary, deprecated
},
},
}
```
Позиции нумеруются с нуля; для отображения прибавляйте единицу к `line` и
`character`. Остановка сервера удаляет его сообщения из следующих снимков.
Примеры запуска:
```lua
app.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" },
languages = { "go" }, watcher = "**/*.go" })
app.editor:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer",
languages = { "rust" }, watcher = "**/*.rs" })
```
Пример из `init.lua` с защитой от ошибки запуска:
```lua
local gopls_ok, gopls_err = pcall(function()
app.editor:run_lsp({
name = "gopls",
cmd = "gopls",
args = { "serve" },
root = ".",
languages = { "go" },
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } },
initialization_options = {
usePlaceholders = true,
completeUnimported = true,
},
})
end)
if not gopls_ok then
print("[lsp] gopls не запущен: " .. tostring(gopls_err))
end
```
> `--config` меняет только каталог `init.lua`, поэтому `root = "."` указывает
> на текущий каталог запуска — проект должен быть текущим рабочим каталогом.
## Верхняя панель редактора
Полоса между вкладками и редактором. У панели две области: элементы левой
выстраиваются от левого края вправо, элементы правой — от правого края влево.
| Метод | Описание |
| --- | --- |
| `app.toolbar:add_label(text, icon, background, id, side)` | Добавить лейбл |
| `app.toolbar:add_button(text, icon, background, id, side, fn)` | Добавить кнопку |
| `app.toolbar:hide(id)` | Скрыть элемент; `false`, если элемента с таким `id` нет |
| `app.toolbar:show(id)` | Снова показать скрытый элемент |
| `app.toolbar:clear()` | Удалить все элементы панели |
- `icon` — имя иконки Lucide (`play`, `settings`, `file`, ...) или `nil`.
- `background` — `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` или `nil` (прозрачный
фон). Цвет текста подбирается автоматически: тёмный на светлом фоне, светлый
на тёмном.
- `id` — идентификатор элемента для `hide` и `show`. Повторное добавление с тем
же `id` заменяет элемент, а не создаёт дубликат.
- `side` — `"left"` или `"right"`.
- `fn` — обработчик нажатия; вызывается без аргументов.
```lua
app.toolbar:add_label("Готово", nil, nil, "status", "left")
app.toolbar:add_label("UTF-8", nil, "#3f3f46", "encoding", "right")
app.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
print("Запуск")
end)
app.toolbar:hide("run")
app.toolbar:show("run")
```
## Панели окна
По умолчанию панели закрыты, размер меняется перетаскиванием.
Для `app.left_panel`, `app.right_panel` и `app.bottom_panel` доступны одинаковые методы:
| Метод | Описание |
| --- | --- |
| `app.left_panel:open()` | Открыть панель |
| `app.left_panel:close()` | Закрыть панель |
| `app.left_panel:toggle()` | Переключить панель; вернуть `true`, если панель открыта |
| `app.left_panel:is_open()` | Открыта ли панель |
```lua
app.left_panel:open()
app.right_panel:toggle()
app.bottom_panel:close()
```
Если у правой панели нет блоков, она показывает состояние LSP-серверов.
Блоки нижней панели показываются вкладками, как вкладки редактора; боковые
панели рисуют все свои блоки подряд и заголовки не показывают.
### Блоки панелей
Панель может содержать блоки. Блок создаётся через API панели и живёт вместе
с ней; сейчас поддерживается один вид блока — абстрактное дерево.
```lua
local tree = app.left_panel:add_tree()
local diagnostics = app.bottom_panel:add_tree("Диагностика")
```
Необязательный аргумент `add_tree` — заголовок вкладки. В нижней панели он
показывается на вкладке; в боковых панелях заголовок сохраняется, но не
рисуется. Без заголовка используется «Дерево». Новый блок становится
активной вкладкой нижней панели.
Редактор отвечает только за отрисовку дерева. Что означает каждый узел,
решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно.
Файловое дерево проекта в `init.lua` собирается из `app:list_dir`, но сам
редактор о файловой системе в блоке ничего не знает.
| Метод | Описание |
| --- | --- |
| `tree:append(nodes, parent)` | Добавить узлы к `parent`; `parent = ""` — корень |
| `tree:clear()` | Удалить все узлы и сбросить выбор |
| `tree:remove(id)` | Удалить узел и всех потомков рекурсивно; `false`, если узла нет |
| `tree:on_toggle(fn)` | Обработчик раскрытия: `fn(expanded, id, loaded)` |
| `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` |
| `tree:selected()` | Идентификатор выбранной строки или `nil` |
| `tree:focus(id)` | Раскрыть всех родителей, выбрать узел и передать фокус дереву |
| `tree:map_key(modes, keys, handler)` | Привязка, которая действует, когда фокус на этом блоке |
| `tree:set_mode(mode)` | Переключить режим привязок блока |
| `tree:mode()` | Текущий режим привязок блока |
Узел — таблица:
```lua
{
id = "src/main.rs", -- строковый идентификатор (путь файла)
label = "main.rs", -- отображаемый текст
icon = "file-code", -- имя иконки Lucide (необязательно)
expandable = false, -- раскрываемая ли нода (папка)
expanded = false, -- раскрыта ли она сейчас
}
```
`append` добавляет узлы, а не заменяет дерево. Повторная передача того же `id`
обновляет описание узла, но не создаёт копию. `clear` удаляет всё дерево;
обработчики `on_toggle` и `on_click` продолжают действовать, поэтому дерево
удобно строить заново на каждое обновление данных.
Дети каждого уровня сортируются по `label` без учёта регистра. Сначала идут
раскрываемые узлы (`expandable = true`), затем конечные; внутри каждой группы —
по алфавиту.
В обработчик `on_toggle` приходит `loaded = true`, если дочерние узлы уже
добавляли в эту ноду; при первом раскрытии — `false`.
Клик по строке выбирает её. Выбранную строку можно перемещать стрелками
`↑`/`↓`; `↑`/`↓` двигают выбор, `→` раскрывает папку или переходит к первому
ребёнку, `←` сворачивает папку или выбирает родителя, `Enter` раскрывает папку
или вызывает `on_click` для конечной ноды. Выбор сохраняется, пока панель
закрыта; при повторном открытии панели фокус возвращается на выбранную строку.
Привязки блока (`tree:map_key`) обрабатываются дополнительно к глобальным и
только пока фокус находится на этом дереве; у каждого блока свой режим.
Пример отложенной загрузки дерева:
```lua
local tree = app.left_panel:add_tree()
local function load_dir(path)
local nodes = {}
for _, entry in ipairs(app:list_dir(path)) do
nodes[#nodes + 1] = {
id = entry.id,
label = entry.name,
icon = entry.is_dir and "folder" or "file",
expandable = entry.is_dir,
expanded = false,
}
end
tree:append(nodes, path)
end
tree:on_toggle(function(expanded, id, loaded)
if expanded and not loaded then
load_dir(id)
end
end)
tree:on_click(function(id)
-- Фокус в редактор, курсор остаётся на прежнем месте.
app.editor:open(id, {})
end)
app.left_panel:open()
load_dir("")
-- При открытии панели вернуть фокус на последнюю выбранную строку.
app:map_key("", "<C-b>", function()
if app.left_panel:toggle() then
local selected = tree:selected()
if selected then
tree:focus(selected)
end
end
end)
```
Пример дерева диагностики в нижней панели. На первом уровне — файлы с
полным путём, на втором — сообщения с иконкой уровня. Сообщения с одинаковыми
началом и концом диапазона объединяются в одну запись; клик открывает файл
на нужной позиции.
```lua
local diagnostics = app.bottom_panel:add_tree("Диагностика")
local icons = {
error = "circle-x",
warning = "triangle-alert",
information = "circle-alert",
hint = "lightbulb",
}
app.editor:on_lsp_diagnostics(1000, function(servers)
diagnostics:clear()
local files = {}
local order = {}
for _, entry in ipairs(servers) do
local path = entry.path or entry.uri
if not files[path] then
files[path] = {}
order[#order + 1] = path
end
for _, item in ipairs(entry.diagnostics) do
table.insert(files[path], item)
end
end
for _, path in ipairs(order) do
local groups = {}
local keys = {}
for _, item in ipairs(files[path]) do
local key = string.format("%d:%d:%d:%d", item.line, item.character,
item.end_line, item.end_character)
if not groups[key] then
groups[key] = { item = item, messages = {}, seen = {} }
keys[#keys + 1] = key
end
local group = groups[key]
if not group.seen[item.message] then
group.seen[item.message] = true
group.messages[#group.messages + 1] = item.message
end
end
diagnostics:append({
{ id = path, label = path, icon = "file",
expandable = true, expanded = true },
}, "")
local errors = {}
for index, key in ipairs(keys) do
local group = groups[key]
local item = group.item
local severity = item.severity_name or "error"
errors[#errors + 1] = {
id = string.format("diag:%s:%d:%d:%d", path, item.line,
item.character, index),
label = string.format("%d:%d %s", item.line + 1,
item.character + 1,
table.concat(group.messages, "; ")),
icon = icons[severity] or "circle-alert",
}
end
diagnostics:append(errors, path)
end
end)
diagnostics:on_click(function(id)
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
if path then
app.editor:open(path, { tonumber(line), tonumber(character) })
else
app.editor:open(id, {})
end
end)
app.bottom_panel:open()
```
## Подключение других Lua-файлов
Файлы подключаются из каталога сценариев:
| Способ | Описание |
| --- | --- |
| `include("tools.lua")` | Выполнить файл сразу |
| `require("tools")` | Выполнить один раз (как обычный Lua-модуль) |
## Добавление новых методов
Методы добавляются в `src/api.rs`: всё, что есть в `impl AppApi` или
`impl EditorApi`, помеченном `#[mlua::userdata_impl]`, автоматически доступно
из Lua как `app:method(...)` или `app.editor:method(...)`.
Чтобы не экспортировать метод, его помечают `#[lua(skip)]`.
Новый вид блока панели должен добавить методы в свой `*Api` и отдавать свою
`KeyMap`, как это делает `TreeApi`.
## Полный пример `init.lua`
```lua
print("[qode] init.lua выполнен")
app:map_key("", "<C-q>", function() app:close() end)
app:map_key("", "<C-e>", "toggle_left_panel")
app:map_key("", "<C-t>", "toggle_bottom_panel")
app.editor:run_lsp({
name = "rust-analyzer",
cmd = "rust-analyzer",
languages = { "rust" },
watcher = "**/*.rs",
})
```
+99
View File
@@ -0,0 +1,99 @@
# qode — конструктор IDE на Lua
`qode` — настольный редактор кода на GPUI (`gpui-kit`), который расширяется
Lua-сценариями. При запуске приложение выполняет `init.lua` и предоставляет
сценарию объект `api`.
## Идея
Rust-часть даёт только базовые универсальные примитивы:
- окно и его элементы;
- вкладки, текстовый редактор, подсветка, gutter, автодополнение;
- панели и абстрактное дерево;
- плавающее поле ввода;
- файловая система;
- привязки клавиш;
- LSP-клиент по stdio.
Вся бизнес-логика IDE — файловое дерево, дерево диагностики, команды,
раскладки, проектные сценарии, оформление и поведение — живёт в Lua.
Если поведение зависит от проекта или рабочего процесса, его место в
`init.lua` или подключаемых модулях, а не в Rust.
Новую возможность в Rust стоит добавлять, только если это универсальный
примитив, нужный разным сценариям, или если её нельзя выразить через уже
существующее API (доступ к GPUI, ОС, протоколу LSP). Бизнес-правила
(какие файлы показывать, как создавать документы, что писать в статус,
какие команды запускать) остаются в Lua.
## Запуск и каталог сценариев
Каталог поиска `init.lua` задаётся константой `SCRIPT_DIR` в
`src/constants.rs` (по умолчанию — текущий каталог). Его можно переопределить
аргументом командной строки:
```sh
qode --config /path/to/project main.go
cargo run -- --config ./config
cargo run -- main.go
```
- В каталоге `--config` должны лежать `init.lua` и подключаемые им модули.
- Первый позиционный аргумент открывается после выполнения `init.lua`
через `api.editor:open(path)` — как `vim main.go` или `nano main.go`.
- `--config` меняет только каталог сценариев. Рабочий каталог процесса, а
вместе с ним пути `api.editor:open`, `api.editor:save`, `api.path:*` и
`root` у LSP не меняются.
## Объект `api`
Объект `api` разбит на группы. Методы вызываются через двоеточие:
```lua
api.editor:open("src/main.rs")
api.editor:save()
api.panels.left:open()
api.path:list("src")
api.window:close()
api.keymap:map_key("", "<C-s>", "save")
```
| Группа | Назначение | Документация |
| --- | --- | --- |
| `api.editor` | Вкладки, текст, gutter, подсветка, сохранение, LSP, привязки редактора | [editor.md](editor.md) |
| `api.panels` | Левая, правая и нижняя панели, верхняя панель редактора, блоки-деревья | [panels.md](panels.md) |
| `api.path` | Файловая система: чтение, запись, создание и удаление | [path.md](path.md) |
| `api.window` | Окно и плавающее поле ввода | [window.md](window.md) |
| `api.keymap` | Глобальные привязки клавиш и режимы | [keymap.md](keymap.md) |
| `api.editor` (LSP) | Запуск серверов и обработка диагностики | [lsp.md](lsp.md) |
Номера вкладок во всех методах нумеруются с нуля. Массив, который возвращает
`api.editor:tabs()`, остаётся обычной Lua-таблицей с индексами от 1, поэтому
поле `index` — единственный надёжный способ сослаться на вкладку.
## Подключение других Lua-файлов
Файлы подключаются из каталога сценариев:
| Способ | Описание |
| --- | --- |
| `include("tools.lua")` | Выполнить файл сразу |
| `require("tools")` | Выполнить один раз (как обычный Lua-модуль) |
## Добавление новых методов
Методы добавляются в `src/api.rs`. Публичные методы `impl`-блока с
`#[mlua::userdata_impl]` автоматически доступны из Lua:
- методы `AppApi` — у объекта `api`;
- методы `EditorApi` — у `api.editor`;
- методы `PanelApi` — у `api.panels.left`, `.right`, `.bottom`;
- методы `ToolbarApi` — у `api.panels.toolbar`;
- методы `PathApi` — у `api.path`;
- методы `WindowApi` — у `api.window`;
- методы `KeymapApi` — у `api.keymap`.
Чтобы не экспортировать метод, его помечают `#[lua(skip)]`.
Новый вид блока панели должен добавить методы в свой `*Api` и отдавать свою
`KeyMap`, как это делает `TreeApi`.
+132
View File
@@ -0,0 +1,132 @@
# Редактор — `api.editor`
Группа `api.editor` управляет вкладками, текстом, gutter, подсветкой и
сохранением. LSP-серверы описаны отдельно в [lsp.md](lsp.md), привязки
клавиш редактора — в [keymap.md](keymap.md).
Номера вкладок нумеруются с нуля.
## Вкладки
| Метод | Описание |
| --- | --- |
| `api.editor:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs", icon = "file" }, ...}` |
| `api.editor:active_tab()` | Индекс активной вкладки |
| `api.editor:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует |
| `api.editor:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
| `api.editor:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс |
| `api.editor:open_tab()` | Открыть пустую вкладку, вернуть индекс |
| `api.editor:open(path, position)` | Открыть файл с диска, вернуть индекс вкладки |
| `api.editor:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта |
| `api.editor:focus()` | Перевести фокус в редактор, не меняя вкладку и курсор |
Если файла нет, `open` создаёт пустой буфер с таким именем — как в vim.
Язык подсветки определяется по расширению, если для него есть грамматика.
Первый аргумент принимает и строку `"src/main.rs"`, и массив сегментов
`{ "src", "main.rs" }`.
Второй аргумент `open` необязателен:
| Значение | Поведение |
| --- | --- |
| нет аргумента / `nil` | Открыть вкладку, не трогая фокус |
| `{}` | Перевести фокус в редактор, курсор оставить на месте |
| `{line, column}` | Перевести фокус и поставить курсор (с нуля) |
```lua
api.editor:open("src/main.rs") -- открыть, не трогая фокус
api.editor:open("src/main.rs", {}) -- фокус в редактор, курсор на месте
api.editor:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор на строку 4, столбец 2
```
Строка и столбец нумеруются с нуля, как в диагностике LSP; столбец считается
в UTF-16.
## Иконки вкладок
Слева от названия вкладки показывается иконка; по умолчанию это `file`.
Имя — название иконки [Lucide](https://lucide.dev/icons/), регистр не важен.
Подчёркивания и пробелы считаются дефисом, а `icons/` и `.svg` можно не
писать: `file_code`, `file-code` и `icons/file-code.svg` равнозначны.
| Метод | Описание |
| --- | --- |
| `api.editor:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна |
| `api.editor:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) |
| `api.editor:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет |
```lua
api.editor:set_tab_icon(0, "file-code")
api.editor:set_tab_icon(1, "book-open")
api.editor:clear_tab_icon(1) -- снова "file"
```
## Иконки на полях редактора (gutter)
Слева от редактора есть узкая колонка с иконками. Методы действуют на
активную вкладку, а номера строк нумеруются с нуля, как в LSP. На одной строке
живёт одна иконка; повторный вызов заменяет прежнюю.
| Метод | Описание |
| --- | --- |
| `api.editor:set_gutter_icon(line, icon, color)` | Поставить иконку на строку; `color` необязателен (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`). Без цвета иконка рисуется цветом темы. `false` — иконка или цвет неизвестны |
| `api.editor:gutter_icon(line)` | Имя иконки на строке или `nil` |
| `api.editor:gutter_color(line)` | Цвет иконки или `nil`, если цвет не задан |
| `api.editor:clear_gutter_icon(line)` | Убрать иконку со строки; `false`, если её не было |
| `api.editor:clear_gutter()` | Убрать все иконки активной вкладки |
```lua
api.editor:set_gutter_icon(4, "circle", "#e5484d") -- красная точка на 5-й строке
api.editor:set_gutter_icon(9, "play") -- цвет темы
api.editor:clear_gutter_icon(9)
```
Иконки привязаны к номерам строк и автоматически не сдвигаются при правке
текста: сценарий сам решает, когда их переставить. При прокрутке колонка
двигается вместе с редактором.
## Текст
| Метод | Описание |
| --- | --- |
| `api.editor:active_text()` | Текст активной вкладки |
| `api.editor:set_active_text(text)` | Заменить текст активной вкладки |
## Сохранение
| Метод | Описание |
| --- | --- |
| `api.editor:save()` | Сохранить активную вкладку в её файл |
Путь считается относительно текущего рабочего каталога процесса. При успешной
записи с вкладки снимается маркер несохранённых изменений.
## Подсветка синтаксиса
Подсветка по умолчанию выключена. Поддерживаемые языки: `rust`, `go`, `js`,
`json`, `python`, `lua`, `bash`, `yaml`, `make`, `dockerfile`, `sql`,
`markdown`, `css`, `c`, `cpp`, `html`, `xml`. Для `set_language` принимаются
и короткие имена (`rs`, `golang`, `javascript`, `py`, `sh`, `yml`, `makefile`,
`md`, `c++`…).
| Метод | Описание |
| --- | --- |
| `api.editor:set_language(index, "rust")` | Включить подсветку для вкладки; `false`, если вкладки нет или язык не поддерживается |
| `api.editor:language(index)` | Язык подсветки вкладки или `nil` |
| `api.editor:clear_language(index)` | Выключить подсветку для вкладки |
```lua
api.editor:set_language(0, "rust")
```
## Привязки редактора
`api.editor:map_key`, `api.editor:set_mode` и `api.editor:mode` работают так
же, как глобальные привязки из [keymap.md](keymap.md), но срабатывают
дополнительно к глобальным и только когда фокус находится в редакторе.
```lua
api.editor:map_key("", "<A-r>", function()
api.editor:set_active_text("editor mode")
end)
```
+126
View File
@@ -0,0 +1,126 @@
# Привязки клавиш — `api.keymap`
Привязки живут у трёх видов объектов:
- у приложения — `api.keymap` (глобальные привязки);
- у редактора — `api.editor` (срабатывают, когда фокус в редакторе);
- у каждого блока панели — `tree` (срабатывают, когда фокус на этом блоке).
Встроенные сочетания можно заменить или удалить: обработчиком принимается
Lua-функция, имя встроенной команды или `nil`.
```lua
api.keymap:map_key(modes, keys, handler) -- глобальные
api.editor:map_key(modes, keys, handler) -- только когда фокус в редакторе
api.keymap:map_completion_key(keys, handler) -- список автодополнения
```
Слой автодополнения режимами не управляется: `map_completion_key` принимает
только клавишу и обработчик.
Порядок обработки всегда такой: сначала глобальные привязки, затем — если
фокус в редакторе — привязки редактора, а если фокус на блоке панели —
привязки этого блока. Привязки неактивного объекта не срабатывают.
## Запись клавиши
| Запись | Значение |
| --- | --- |
| `"a"` | нажатие A |
| `"1"` | нажатие 1 |
| `"!a"` | отпускание A |
| `"<S-c>"` | C при зажатом Shift |
| `"<C-z>"` | Z при зажатом Ctrl |
| `"<A-!r>"` | отпускание R при зажатом Alt |
| `"<D-s>"` | S при зажатом Cmd на macOS или Super на Linux |
Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux
и Windows буквы берутся по аппаратному keycode, поэтому `<C-q>` работает
и на русской раскладке.
## Режимы
У каждого объекта своя текущая режимная метка: один символ (`n`, `i`, `v`, `r`
или любой другой) либо `-` — режим по умолчанию. Режимы динамические: заранее
создавать их не нужно, достаточно переключить метку.
| Значение `modes` | Когда срабатывает |
| --- | --- |
| `""` | во всех режимах |
| `"-"` | только в режиме по умолчанию |
| `"niv"` | в режимах `n`, `i`, `v` |
| `"-iv"` | в режиме по умолчанию, а также `i` и `v` |
Текущий режим меняется методами `set_mode(mode)` и читается методом `mode()`.
Режимы у приложения, редактора и каждого блока независимы.
```lua
api.keymap:set_mode("n")
api.editor:set_mode("i")
api.keymap:map_key("n", "<C-n>", "next_tab") -- только в режиме n
api.editor:map_key("i", "<C-s>", "save") -- только в режиме i редактора
api.editor:map_key("", "<C-space>", function() end) -- в любом режиме
```
## Встроенные команды
| Имя | Действие |
| --- | --- |
| `"save"` | Сохранить активную вкладку |
| `"next_tab"` | Следующая вкладка |
| `"previous_tab"` | Предыдущая вкладка |
| `"open_tab"` | Новая пустая вкладка |
| `"close_tab"` | Закрыть активную вкладку |
| `"toggle_left_panel"` | Переключить левую панель |
| `"toggle_right_panel"` | Переключить правую панель |
| `"toggle_bottom_panel"` | Переключить нижнюю панель |
Встроенные команды списка автодополнения:
| Имя | Действие |
| --- | --- |
| `"completion_trigger"` | Открыть список |
| `"completion_next"` / `"completion_previous"` | Сдвинуть выделение |
| `"completion_page_down"` / `"completion_page_up"` | Пролистать список |
| `"completion_accept"` | Вставить выбранный элемент |
| `"completion_cancel"` | Закрыть список |
| `"completion_dismiss"` | Закрыть список и передать клавишу редактору |
При запуске уже назначены:
- `Ctrl/Cmd+S` → `save` во всех режимах;
- `Ctrl/Cmd+Space` → `completion_trigger`;
- `Enter`/`Tab`, `Esc`, `↑`/`↓`, `PageUp`/`PageDown`, `←`/`→`, `Home`/`End`
→ команды слоя автодополнения.
## Примеры
```lua
-- Переопределить сохранение своей функцией во всех режимах.
api.keymap:map_key("", "<C-s>", function() api.editor:save() end)
-- Убрать встроенную привязку.
api.keymap:map_key("", "<C-s>", nil)
-- Назначить встроенную команду на другую клавишу.
api.keymap:map_key("", "<C-n>", "next_tab")
api.keymap:map_key("", "<C-b>", "toggle_left_panel")
-- Навигация по списку автодополнения на Ctrl+N / Ctrl+P.
api.keymap:map_completion_key("<C-n>", "completion_next")
api.keymap:map_completion_key("<C-p>", "completion_previous")
-- Привязка, которая срабатывает только при фокусе в редакторе.
api.editor:map_key("", "<A-r>", function()
api.editor:set_active_text("editor mode")
end)
-- Привязка блока дерева: работает, когда фокус на этом дереве.
tree:map_key("v", "<A-f>", function() print("tree v") end)
```
Пока список автодополнения открыт, его слой проверяется раньше действий
редактора. Сочетания с модификаторами и горизонтальные стрелки закрывают
список, даже если для них нет своей привязки, — так `Shift+↑` продолжает
выделять текст.
+115
View File
@@ -0,0 +1,115 @@
# LSP-серверы — `api.editor`
Запуск серверов целиком настраивается Lua-сценарием. Редактор предоставляет
только клиент по stdio, синхронизацию документов и доставку диагностики.
## Запуск сервера
```lua
api.editor:run_lsp({
name = "gopls", -- условное имя для stop/restart
cmd = "gopls", -- путь к бинарнику или имя в PATH
args = { "serve" }, -- аргументы процесса
env = { GOFLAGS = "-mod=mod" }, -- переменные окружения
languages = { "go" }, -- какие languageId обслуживает
watcher = "**/*.go", -- шаблон файлового вотчера
root = ".", -- корень workspace (по умолчанию cwd)
initialization_options = {}, -- initializationOptions сервера
})
```
| Метод | Описание |
| --- | --- |
| `api.editor:run_lsp(config)` | Запустить сервер по описанию |
| `api.editor:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` |
| `api.editor:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен |
| `api.editor:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками |
| `api.editor:on_lsp_diagnostics(delay_ms, fn)` | Обработчик диагностики всех серверов; `nil` вместо `fn` удаляет его |
`cmd` обязателен. Если `root` не задан или относителен, он отсчитывается от
текущего рабочего каталога процесса. Поле `watcher` принимает строку, таблицу
или boolean:
```lua
watcher = "**/*.go"
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
watcher = true -- все файлы, игнорируя только стандартные каталоги сборки
```
> `--config` меняет только каталог `init.lua`, поэтому `root = "."` указывает
> на текущий каталог запуска — проект должен быть текущим рабочим каталогом.
## Автодополнение и подсказки
Автодополнение включается само при вводе идентификатора; `Ctrl/Cmd+Space`
открывает список вручную. Поведение клавиш описано в [keymap.md](keymap.md) и
может быть переопределено через `api.keymap:map_completion_key`.
Документация выбранного элемента показывается второй всплывающей панелью
справа от списка. Наведение мыши показывает Markdown-документацию символа.
Диагностику, которую сервер присылает после правок, редактор подчёркивает
волнистой линией: ошибки — красной, предупреждения и остальные сообщения —
жёлтой. Подчёркивание работает и при выключенной подсветке синтаксиса.
## Обработчик диагностики
`api.editor:on_lsp_diagnostics(delay_ms, fn)` регистрирует один обработчик на
все LSP-серверы. `delay_ms` — пауза тишины: если сообщения приходят чаще,
вызовы пропускаются, а обработчик получает последний снимок после того, как
обновления прекратились. Если зарегистрировать обработчик, когда диагностика
уже накоплена, он сработает сразу после этой паузы.
```lua
api.editor:on_lsp_diagnostics(1000, function(servers)
for _, entry in ipairs(servers) do
print(entry.server, entry.path, #entry.diagnostics)
end
end)
```
`servers` — массив записей по одной на пару «документ, сервер»:
```lua
{
server = "gopls", -- имя сервера, приславшего диагностику
uri = "file:///project/main.go", -- URI документа
path = "/project/main.go", -- путь файла; nil для не-file URI
diagnostics = {
{
line = 0, character = 2, -- начало (с нуля, как в LSP)
end_line = 0, end_character = 8,
severity = 1, -- 1 ошибка, 2 предупреждение, 3 info, 4 hint
severity_name = "error", -- error | warning | information | hint
message = "undefined: foo",
source = "gopls",
code = "UndeclaredName",
tags = {}, -- unnecessary, deprecated
},
},
}
```
Позиции нумеруются с нуля; для отображения прибавляйте единицу к `line` и
`character`. Остановка сервера удаляет его сообщения из следующих снимков.
Пример из `init.lua` с защитой от ошибки запуска:
```lua
local gopls_ok, gopls_err = pcall(function()
api.editor:run_lsp({
name = "gopls",
cmd = "gopls",
args = { "serve" },
root = ".",
languages = { "go" },
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } },
initialization_options = {
usePlaceholders = true,
completeUnimported = true,
},
})
end)
if not gopls_ok then
print("[lsp] gopls не запущен: " .. tostring(gopls_err))
end
```
+265
View File
@@ -0,0 +1,265 @@
# Панели — `api.panels`
Группа `api.panels` управляет панелями окна и верхней панелью редактора:
| Объект | Назначение |
| --- | --- |
| `api.panels.left` | Левая панель |
| `api.panels.right` | Правая панель |
| `api.panels.bottom` | Нижняя панель |
| `api.panels.toolbar` | Верхняя панель редактора: лейблы и кнопки |
По умолчанию панели закрыты, размер меняется перетаскиванием разделителя.
Боковые панели рисуют все свои блоки подряд и не показывают заголовки; блоки
нижней панели показываются вкладками.
Если у правой панели нет блоков, она показывает состояние LSP-серверов.
## Панели
Для `api.panels.left`, `api.panels.right` и `api.panels.bottom` доступны
одинаковые методы:
| Метод | Описание |
| --- | --- |
| `api.panels.left:open()` | Открыть панель |
| `api.panels.left:close()` | Закрыть панель |
| `api.panels.left:toggle()` | Переключить панель; вернуть `true`, если панель открыта |
| `api.panels.left:is_open()` | Открыта ли панель |
| `api.panels.left:add_tree(title)` | Добавить блок-дерево и вернуть его API |
```lua
api.panels.left:open()
api.panels.right:toggle()
api.panels.bottom:close()
```
## Верхняя панель редактора
Полоса между вкладками и редактором. У панели две области: элементы левой
выстраиваются от левого края вправо, элементы правой — от правого края влево.
| Метод | Описание |
| --- | --- |
| `api.panels.toolbar:add_label(text, icon, background, id, side)` | Добавить лейбл |
| `api.panels.toolbar:add_button(text, icon, background, id, side, fn)` | Добавить кнопку |
| `api.panels.toolbar:hide(id)` | Скрыть элемент; `false`, если элемента с таким `id` нет |
| `api.panels.toolbar:show(id)` | Снова показать скрытый элемент |
| `api.panels.toolbar:clear()` | Удалить все элементы панели |
- `icon` — имя иконки Lucide (`play`, `settings`, `file`, ...) или `nil`.
- `background` — `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` или `nil` (прозрачный
фон). Цвет текста подбирается автоматически: тёмный на светлом фоне, светлый
на тёмном.
- `id` — идентификатор элемента для `hide` и `show`. Повторное добавление с тем
же `id` заменяет элемент, а не создаёт дубликат.
- `side` — `"left"` или `"right"`.
- `fn` — обработчик нажатия; вызывается без аргументов.
```lua
api.panels.toolbar:add_label("Готово", nil, nil, "status", "left")
api.panels.toolbar:add_label("UTF-8", nil, "#3f3f46", "encoding", "right")
api.panels.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
print("Запуск")
end)
api.panels.toolbar:hide("run")
api.panels.toolbar:show("run")
```
## Блоки панелей
Панель может содержать блоки. Блок создаётся через API панели и живёт вместе
с ней; сейчас поддерживается один вид блока — абстрактное дерево.
```lua
local tree = api.panels.left:add_tree()
local diagnostics = api.panels.bottom:add_tree("Диагностика")
```
Необязательный аргумент `add_tree` — заголовок вкладки. В нижней панели он
показывается на вкладке; в боковых панелях заголовок сохраняется, но не
рисуется. Без заголовка используется «Дерево». Новый блок становится
активной вкладкой нижней панели.
Редактор отвечает только за отрисовку дерева. Что означает каждый узел,
решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно.
Файловое дерево проекта в `init.lua` собирается из `api.path:list`, но сам
редактор о файловой системе в блоке ничего не знает.
| Метод | Описание |
| --- | --- |
| `tree:append(nodes, parent)` | Добавить узлы к `parent`; `parent = ""` — корень |
| `tree:clear()` | Удалить все узлы и сбросить выбор |
| `tree:remove(id)` | Удалить узел и всех потомков рекурсивно; `false`, если узла нет |
| `tree:on_toggle(fn)` | Обработчик раскрытия: `fn(expanded, id, loaded)` |
| `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` |
| `tree:selected()` | Идентификатор выбранной строки или `nil` |
| `tree:focus(id)` | Раскрыть всех родителей, выбрать узел и передать фокус дереву |
| `tree:focus()` | Передать фокус дереву, не меняя выбранную строку |
| `tree:has_focus()` | Владеет ли дерево клавиатурным фокусом прямо сейчас |
| `tree:map_key(modes, keys, handler)` | Привязка, которая действует, когда фокус на этом блоке |
| `tree:set_mode(mode)` | Переключить режим привязок блока |
| `tree:mode()` | Текущий режим привязок блока |
Узел — таблица:
```lua
{
id = "src/main.rs", -- строковый идентификатор (путь файла)
label = "main.rs", -- отображаемый текст
icon = "file-code", -- имя иконки Lucide (необязательно)
expandable = false, -- раскрываемая ли нода (папка)
expanded = false, -- раскрыта ли она сейчас
}
```
`append` добавляет узлы, а не заменяет дерево. Повторная передача того же `id`
обновляет описание узла, но не создаёт копию. `clear` удаляет всё дерево;
обработчики `on_toggle` и `on_click` продолжают действовать, поэтому дерево
удобно строить заново на каждое обновление данных.
Дети каждого уровня сортируются по `label` без учёта регистра. Сначала идут
раскрываемые узлы (`expandable = true`), затем конечные; внутри каждой группы —
по алфавиту.
В обработчик `on_toggle` приходит `loaded = true`, если дочерние узлы уже
добавляли в эту ноду; при первом раскрытии — `false`.
Клик по строке выбирает её. Выбранную строку можно перемещать стрелками
`↑`/`↓`; `→` раскрывает папку или переходит к первому ребёнку, `←` сворачивает
папку или выбирает родителя, `Enter` раскрывает папку или вызывает `on_click`
для конечной ноды. Выбор сохраняется, пока панель закрыта; при повторном
открытии панели фокус возвращается на выбранную строку. `tree:focus()` без
аргумента передаёт фокус дереву, даже если ни одна строка ещё не выбрана.
`tree:has_focus()` возвращает состояние фокуса на момент последнего события
клавиатуры — его удобно проверять в глобальных обработчиках.
## Пример отложенной загрузки дерева
```lua
local tree = api.panels.left:add_tree()
local function load_dir(path)
local nodes = {}
for _, entry in ipairs(api.path:list(path)) do
nodes[#nodes + 1] = {
id = entry.id,
label = entry.name,
icon = entry.is_dir and "folder" or "file",
expandable = entry.is_dir,
expanded = false,
}
end
tree:append(nodes, path)
end
tree:on_toggle(function(expanded, id, loaded)
if expanded and not loaded then
load_dir(id)
end
end)
tree:on_click(function(id)
-- Фокус в редактор, курсор остаётся на прежнем месте.
api.editor:open(id, {})
end)
api.panels.left:open()
load_dir("")
-- При открытии панели вернуть фокус на последнюю выбранную строку.
api.keymap:map_key("", "<C-b>", function()
if api.panels.left:toggle() then
local selected = tree:selected()
if selected then
tree:focus(selected)
end
end
end)
```
## Пример дерева диагностики
На первом уровне — файлы с полным путём, на втором — сообщения с иконкой
уровня. Сообщения с одинаковыми началом и концом диапазона объединяются в одну
запись; клик открывает файл на нужной позиции. Источник данных — обработчик
`api.editor:on_lsp_diagnostics`, описанный в [lsp.md](lsp.md).
```lua
local diagnostics = api.panels.bottom:add_tree("Диагностика")
local icons = {
error = "circle-x",
warning = "triangle-alert",
information = "circle-alert",
hint = "lightbulb",
}
api.editor:on_lsp_diagnostics(1000, function(servers)
diagnostics:clear()
local files = {}
local order = {}
for _, entry in ipairs(servers) do
local path = entry.path or entry.uri
if not files[path] then
files[path] = {}
order[#order + 1] = path
end
for _, item in ipairs(entry.diagnostics) do
table.insert(files[path], item)
end
end
for _, path in ipairs(order) do
local groups = {}
local keys = {}
for _, item in ipairs(files[path]) do
local key = string.format("%d:%d:%d:%d", item.line, item.character,
item.end_line, item.end_character)
if not groups[key] then
groups[key] = { item = item, messages = {}, seen = {} }
keys[#keys + 1] = key
end
local group = groups[key]
if not group.seen[item.message] then
group.seen[item.message] = true
group.messages[#group.messages + 1] = item.message
end
end
diagnostics:append({
{ id = path, label = path, icon = "file",
expandable = true, expanded = true },
}, "")
local errors = {}
for index, key in ipairs(keys) do
local group = groups[key]
local item = group.item
local severity = item.severity_name or "error"
errors[#errors + 1] = {
id = string.format("diag:%s:%d:%d:%d", path, item.line,
item.character, index),
label = string.format("%d:%d %s", item.line + 1,
item.character + 1,
table.concat(group.messages, "; ")),
icon = icons[severity] or "circle-alert",
}
end
diagnostics:append(errors, path)
end
end)
diagnostics:on_click(function(id)
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
if path then
api.editor:open(path, { tonumber(line), tonumber(character) })
else
api.editor:open(id, {})
end
end)
api.panels.bottom:open()
```
+125
View File
@@ -0,0 +1,125 @@
# Файловая система — `api.path`
Группа `api.path` даёт базовые операции с файлами и каталогами. Все методы,
принимающие путь, понимают две формы:
```lua
api.path:list("src/main") -- строка с разделителями
api.path:list({ "src", "main" }) -- массив сегментов
```
Путь отсчитывается от текущего рабочего каталога процесса. Пустой путь,
`nil` и `{ }` означают сам текущий каталог (для `list` и `mkdir`).
## Методы
| Метод | Описание |
| --- | --- |
| `api.path:list(path)` | Содержимое каталога |
| `api.path:mkdir(path)` | Создать каталоги, включая родительские; вернуть созданный путь |
| `api.path:remove(path)` | Удалить файл или каталог рекурсивно |
| `api.path:touch(path)` | Создать пустой файл, если его нет, и обновить время изменения |
| `api.path:read(path)` | Прочитать файл в строку |
| `api.path:write(path, text, append)` | Записать строку в файл и вернуть путь |
### `list`
Возвращает массив элементов каталога:
```lua
{
{ id = "src/main.rs", name = "main.rs", is_dir = false },
{ id = "src/tools", name = "tools", is_dir = true },
}
```
- `id` — путь элемента относительно текущего каталога, если переданный путь
тоже относительный. Его можно сразу передать в `api.editor:open(id)` и
использовать как идентификатор узла дерева.
- `name` — имя без пути.
- `is_dir` — является ли элемент каталогом.
Элементы отсортированы по имени без учёта регистра. Ошибка чтения каталога
становится ошибкой Lua.
```lua
for _, entry in ipairs(api.path:list("src")) do
print(entry.name, entry.is_dir)
end
```
### `mkdir`
Создаёт каталоги последовательно, вместе со всеми отсутствующими родителями,
и возвращает созданный путь с разделителями `/`. Пустые сегменты
пропускаются. Для пустого пути ничего не создаёт и возвращает пустую строку.
```lua
api.path:mkdir({ "foo", "bar" }) -- foo, затем foo/bar; вернёт "foo/bar"
api.path:mkdir("foo/bar") -- то же самое
```
### `remove`
Удаляет файл, символическую ссылку или каталог вместе со всем содержимым.
Для каталога используется рекурсивное удаление.
Возвращает `true`, если что-то было удалено, и `false`, если пути не
существовало. Пустой путь удалять нельзя — это ошибка Lua.
```lua
api.path:remove("build")
api.path:remove({ "src", "generated" })
```
### `touch`
Создаёт пустой файл, если его нет. Содержимое существующего файла не
меняется, обновляется только время изменения. Родительский каталог должен
существовать — при необходимости создайте его через `mkdir`.
Возвращает путь с разделителями `/`.
```lua
api.path:touch({ "src", "notes.txt" })
```
### `read`
Читает файл целиком и возвращает строку. Ошибка чтения становится ошибкой Lua.
```lua
local text = api.path:read("src/main.rs")
```
### `write`
Записывает строку в файл:
```lua
api.path:write(path, text, append)
```
- `append = true` — дописать содержимое в конец;
- `append = false` или `nil` — перезаписать файл с нуля.
Файл создаётся, если его нет. Родительский каталог должен существовать.
Ошибка записи становится ошибкой Lua. Метод возвращает путь с разделителями `/`.
```lua
api.path:write("notes.txt", "first\n")
api.path:write("notes.txt", "second\n", true) -- "first\nsecond\n"
api.path:write("notes.txt", "reset\n") -- "reset\n"
```
## Пример: создание файла из Lua
```lua
local function create_file(parent, name, content)
api.path:mkdir(parent)
local path = api.path:write({ parent, name }, content or "")
api.editor:open(path, {})
end
create_file("src", "new.lua", "-- новый файл\n")
```
+41
View File
@@ -0,0 +1,41 @@
# Окно и ввод — `api.window`
Группа `api.window` управляет окном приложения и плавающим полем ввода.
## Окно
| Метод | Описание |
| --- | --- |
| `api.window:close()` | Закрыть окно |
| `api.window:toggle_maximize()` | Развернуть окно / вернуть прежний размер |
| `api.window:set_maximized(true/false)` | Развернуть (`true`) или восстановить (`false`) окно |
| `api.window:minimize()` | Свернуть окно |
| `api.window:is_maximized()` | Развёрнуто ли окно прямо сейчас |
Команды окна не выполняются в момент вызова из Lua: они складываются в
очередь и применяются видом в начале следующего кадра.
## Плавающее поле ввода
`api.window:open_input(options)` открывает однострочное поле ввода чуть выше
центра окна. Параметры передаются таблицей:
| Поле | Описание |
| --- | --- |
| `title` | Текст над полем; если не задан, окно состоит из одной строки ввода |
| `placeholder` | Подсказка внутри пустого поля ввода |
| `callback` | `function(text)` — вызывается после Enter, когда окно уже закрыто |
Пока окно открыто, сочетания клавиш приложения не работают. Текст набирается
в поле; `Enter` подтверждает ввод и запускает `callback`, `Esc` и клик мышью
вне окна закрывают его без вызова колбэка.
```lua
api.window:open_input({
title = "Имя файла",
placeholder = "src/main.rs",
callback = function(text)
api.editor:open(text, {})
end,
})
```
+276 -118
View File
@@ -10,15 +10,31 @@
--
-- В каталоге --config должны лежать init.lua и подключаемые им модули.
-- Первый позиционный аргумент открывается после выполнения этого файла
-- через app.editor:open(path) — как `vim main.go` или `nano main.go`.
-- через api.editor:open(path) — как `vim main.go` или `nano main.go`.
print("[lua] init.lua выполнен")
-- Объект app: окно, панели и глобальные привязки клавиш:
-- app:list_dir(path) -- {{ id = "src/main.rs", name = "main.rs",
-- qode — конструктор IDE: Rust даёт универсальные примитивы (окно, вкладки,
-- редактор, панели, дерево, LSP, файловая система, клавиатура), а вся
-- бизнес-логика IDE живёт в Lua. Подробная документация — в docs/.
--
-- Объект api разбит на группы: api.editor (редактор), api.panels (панели),
-- api.path (файловая система), api.window (окно и поле ввода) и
-- api.keymap (глобальные привязки). Все методы api.path принимают путь
-- строкой "foo/bar" или массивом сегментов { "foo", "bar" }.
--
-- api.path:list(path) -- {{ id = "src/main.rs", name = "main.rs",
-- -- is_dir = false }, ...}; пустой путь —
-- -- текущий каталог, id — путь от него
-- app:map_key(modes, keys, handler)
-- api.path:mkdir(path) -- создать каталоги, вернуть созданный путь
-- api.path:remove(path) -- удалить файл или каталог рекурсивно;
-- -- false, если пути не было
-- api.path:touch(path) -- создать пустой файл, вернуть путь
-- api.path:read(path) -- содержимое файла строкой
-- api.path:write(path, text, append)
-- -- записать текст; append = true — дописать,
-- -- false/nil — перезаписать с нуля
-- api.keymap:map_key(modes, keys, handler)
-- -- глобальный обработчик клавиши/сочетания:
-- -- функция, имя команды или nil (удалить)
-- -- modes — строка режимов:
@@ -40,20 +56,20 @@ print("[lua] init.lua выполнен")
-- -- Caps Lock не важен; на Linux и Windows буквы
-- -- берутся по аппаратному keycode, поэтому
-- -- <C-q> работает и на русской раскладке
-- app:map_completion_key(keys, handler)
-- api.keymap:map_completion_key(keys, handler)
-- -- то же для списка автодополнения; команды:
-- -- completion_trigger, completion_next,
-- -- completion_previous, completion_page_up,
-- -- completion_page_down, completion_accept,
-- -- completion_cancel, completion_dismiss
-- app:set_mode(mode) -- режим глобальных привязок (один символ)
-- app:mode() -- текущий режим глобальных привязок
-- app:close() -- закрыть окно
-- app:toggle_maximize()
-- app:set_maximized(true/false)
-- app:minimize()
-- app:is_maximized()
-- app:open_input(options) -- плавающее поле ввода чуть выше центра;
-- api.keymap:set_mode(mode) -- режим глобальных привязок (один символ)
-- api.keymap:mode() -- текущий режим глобальных привязок
-- api.window:close() -- закрыть окно
-- api.window:toggle_maximize()
-- api.window:set_maximized(true/false)
-- api.window:minimize()
-- api.window:is_maximized()
-- api.window:open_input(options) -- плавающее поле ввода чуть выше центра;
-- -- options = { title = "...",
-- -- placeholder = "...",
-- -- callback = function(text) ... end };
@@ -61,52 +77,56 @@ print("[lua] init.lua выполнен")
-- -- Esc или клик вне окна закрывают без него;
-- -- пока окно открыто, привязки клавиш молчат
--
-- Редактор app.editor (номера вкладок — с нуля):
-- app.editor:tabs() -- {{ index = 0, title = "main.rs",
-- Редактор api.editor (номера вкладок — с нуля):
-- api.editor:tabs() -- {{ index = 0, title = "main.rs",
-- -- icon = "file" }, ...}
-- app.editor:active_tab() -- индекс активной вкладки
-- app.editor:set_active_tab(index) -- true, если вкладка существует
-- app.editor:next_tab() -- переключить на следующую, вернуть индекс
-- app.editor:previous_tab() -- переключить на предыдущую, вернуть индекс
-- app.editor:open_tab() -- открыть пустую вкладку, вернуть индекс
-- app.editor:open(path) -- открыть файл с диска, вернуть индекс вкладки;
-- api.editor:active_tab() -- индекс активной вкладки
-- api.editor:set_active_tab(index) -- true, если вкладка существует
-- api.editor:next_tab() -- переключить на следующую, вернуть индекс
-- api.editor:previous_tab() -- переключить на предыдущую, вернуть индекс
-- api.editor:open_tab() -- открыть пустую вкладку, вернуть индекс
-- api.editor:open(path) -- открыть файл с диска, вернуть индекс вкладки;
-- -- если файла нет — пустой буфер с таким именем
-- app.editor:open(path, {}) -- открыть и перевести фокус в редактор,
-- api.editor:open(path, {}) -- открыть и перевести фокус в редактор,
-- -- курсор оставить на месте
-- app.editor:open(path, {m, n}) -- открыть, перевести фокус и поставить
-- api.editor:open(path, {m, n}) -- открыть, перевести фокус и поставить
-- -- курсор на строку m и столбец n (с нуля)
-- app.editor:close_tab(index) -- true, если вкладка закрыта
-- app.editor:set_tab_icon(i, name) -- иконка вкладки из каталога Lucide
-- api.editor:close_tab(index) -- true, если вкладка закрыта
-- api.editor:set_tab_icon(i, name) -- иконка вкладки из каталога Lucide
-- -- (file, file-code, book-open, ...);
-- -- false — нет вкладки или иконки
-- app.editor:clear_tab_icon(i) -- вернуть иконку по умолчанию (file)
-- app.editor:tab_icon(i) -- имя иконки вкладки или nil
-- app.editor:set_gutter_icon(line, name[, color])
-- api.editor:clear_tab_icon(i) -- вернуть иконку по умолчанию (file)
-- api.editor:tab_icon(i) -- имя иконки вкладки или nil
-- api.editor:set_gutter_icon(line, name[, color])
-- -- иконка на поле слева от редактора
-- -- (активная вкладка, строка с нуля);
-- -- color — #RGB/#RGBA/#RRGGBB/#RRGGBBAA;
-- -- false — неизвестная иконка или цвет
-- app.editor:gutter_icon(line) -- имя иконки gutter или nil
-- app.editor:gutter_color(line) -- цвет иконки gutter или nil
-- app.editor:clear_gutter_icon(line) -- убрать иконку со строки
-- app.editor:clear_gutter() -- убрать все иконки активной вкладки
-- app.editor:active_text() -- текст активной вкладки
-- app.editor:set_active_text(text) -- заменить текст активной вкладки
-- app.editor:set_language(i, "rust") -- включить подсветку
-- -- (rust|go|js|json|python) для вкладки i;
-- api.editor:gutter_icon(line) -- имя иконки gutter или nil
-- api.editor:gutter_color(line) -- цвет иконки gutter или nil
-- api.editor:clear_gutter_icon(line) -- убрать иконку со строки
-- api.editor:clear_gutter() -- убрать все иконки активной вкладки
-- api.editor:active_text() -- текст активной вкладки
-- api.editor:set_active_text(text) -- заменить текст активной вкладки
-- api.editor:set_language(i, "rust") -- включить подсветку
-- -- (rust|go|js|json|python|lua|bash|yaml|
-- -- make|dockerfile|sql|markdown|css|c|
-- -- cpp|html|xml) для вкладки i;
-- -- false — нет вкладки/языка
-- app.editor:language(i) -- язык подсветки вкладки или nil
-- app.editor:clear_language(i) -- выключить подсветку для вкладки
-- app.editor:save() -- сохранить активную вкладку в её файл
-- app.editor:map_key(modes, keys, handler)
-- api.editor:language(i) -- язык подсветки вкладки или nil
-- api.editor:clear_language(i) -- выключить подсветку для вкладки
-- api.editor:save() -- сохранить активную вкладку в её файл
-- api.editor:map_key(modes, keys, handler)
-- -- привязка, которая работает дополнительно
-- -- к глобальной, когда фокус в редакторе;
-- -- синтаксис modes/keys/handler как у app:map_key
-- app.editor:set_mode(mode) -- режим привязок редактора
-- app.editor:mode() -- текущий режим привязок редактора
-- -- синтаксис modes/keys/handler как у api.keymap:map_key
-- api.editor:set_mode(mode) -- режим привязок редактора
-- api.editor:mode() -- текущий режим привязок редактора
-- api.editor:focus() -- перевести фокус в редактор, не меняя
-- -- активную вкладку и курсор
--
-- LSP-серверы (запуск целиком настраивается этим сценарием):
-- app.editor:run_lsp({
-- api.editor:run_lsp({
-- name = "gopls", -- условное имя для stop/restart
-- cmd = "gopls", -- путь к бинарнику или имя в PATH
-- args = { "serve" }, -- аргументы процесса
@@ -116,10 +136,10 @@ print("[lua] init.lua выполнен")
-- root = ".", -- корень workspace (по умолчанию cwd)
-- initialization_options = {}, -- initializationOptions сервера
-- })
-- app.editor:lsp_servers() -- { "gopls", ... }
-- app.editor:stop_lsp("gopls") -- true, если сервер был запущен
-- app.editor:restart_lsp("gopls") -- перезапуск с прежними настройками
-- app.editor:on_lsp_diagnostics(delay_ms, fn)
-- api.editor:lsp_servers() -- { "gopls", ... }
-- api.editor:stop_lsp("gopls") -- true, если сервер был запущен
-- api.editor:restart_lsp("gopls") -- перезапуск с прежними настройками
-- api.editor:on_lsp_diagnostics(delay_ms, fn)
-- -- fn(servers) вызывается один раз
-- -- после затишья delay_ms; nil вместо
-- -- функции удаляет обработчик.
@@ -150,42 +170,42 @@ print("[lua] init.lua выполнен")
-- список; горизонтальные стрелки закрывают его и двигают курсор.
-- Документация выбранного элемента показывается второй всплывающей панелью
-- справа от списка. Примеры запуска:
-- app.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" },
-- api.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" },
-- languages = { "go" }, watcher = "**/*.go" })
-- app.editor:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer",
-- api.editor:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer",
-- languages = { "rust" }, watcher = "**/*.rs" })
--
-- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием):
-- app.left_panel:open()
-- app.left_panel:close()
-- app.left_panel:toggle() -- вернуть true, если панель открыта
-- app.left_panel:is_open()
-- app.right_panel:open() -- те же методы, что у левой панели
-- app.right_panel:close()
-- app.right_panel:toggle()
-- app.right_panel:is_open()
-- app.bottom_panel:open() -- те же методы, что у левой панели
-- app.bottom_panel:close()
-- app.bottom_panel:toggle()
-- app.bottom_panel:is_open()
-- api.panels.left:open()
-- api.panels.left:close()
-- api.panels.left:toggle() -- вернуть true, если панель открыта
-- api.panels.left:is_open()
-- api.panels.right:open() -- те же методы, что у левой панели
-- api.panels.right:close()
-- api.panels.right:toggle()
-- api.panels.right:is_open()
-- api.panels.bottom:open() -- те же методы, что у левой панели
-- api.panels.bottom:close()
-- api.panels.bottom:toggle()
-- api.panels.bottom:is_open()
--
-- Верхняя панель редактора (полоса между вкладками и редактором).
-- Элементы левой области идут от левого края вправо, правой — от правого
-- края влево. id нужен, чтобы скрыть или снова показать элемент.
-- app.toolbar:add_label(text, icon, background, id, side)
-- app.toolbar:add_button(text, icon, background, id, side, fn)
-- api.panels.toolbar:add_label(text, icon, background, id, side)
-- api.panels.toolbar:add_button(text, icon, background, id, side, fn)
-- -- fn() вызывается при нажатии;
-- -- background — "#RRGGBB", "#RRGGBBAA" или nil;
-- -- icon — имя иконки Lucide или nil;
-- -- side — "left" или "right"
-- app.toolbar:hide(id) -- true, если элемент найден
-- app.toolbar:show(id) -- снова показать скрытый элемент
-- app.toolbar:clear() -- удалить все элементы
-- api.panels.toolbar:hide(id) -- true, если элемент найден
-- api.panels.toolbar:show(id) -- снова показать скрытый элемент
-- api.panels.toolbar:clear() -- удалить все элементы
--
-- Блоки панелей. Сценарий сам решает, что показывает блок: редактор умеет
-- рисовать только абстрактное дерево.
-- local tree = app.left_panel:add_tree()
-- local tab = app.bottom_panel:add_tree("Диагностика")
-- local tree = api.panels.left:add_tree()
-- local tab = api.panels.bottom:add_tree("Диагностика")
-- -- title показывается вкладкой в нижней
-- -- панели; боковые панели его не рисуют
-- tree:append(nodes, parent_id) -- nodes = { { id=..., label=..., icon=...,
@@ -197,28 +217,32 @@ print("[lua] init.lua выполнен")
-- tree:on_click(fn) -- fn(id) — клик по конечной ноде
-- tree:selected() -- id выбранной строки или nil
-- tree:focus(id) -- раскрыть родителей, выбрать и дать фокус
-- tree:focus() -- только дать фокус, не меняя выбор
-- tree:has_focus() -- владеет ли дерево фокусом сейчас
-- tree:map_key(modes, keys, handler)
-- -- привязка, которая работает дополнительно
-- -- к глобальной, когда фокус на этом дереве
-- tree:set_mode(mode) -- режим привязок блока (один символ)
-- tree:mode() -- текущий режим привязок блока
--
-- Методы добавляются в src/api.rs: всё, что есть в impl AppApi или
-- impl EditorApi, помеченном #[mlua::userdata_impl], автоматически доступно
-- из Lua как app:method(...) или app.editor:method(...).
-- Методы добавляются в src/api.rs. Публичные методы impl-блока с
-- #[mlua::userdata_impl] автоматически доступны из Lua: методы AppApi — у api,
-- EditorApi — у api.editor, PanelApi — у api.panels.left/.right/.bottom,
-- ToolbarApi — у api.panels.toolbar, PathApi — у api.path.
-- Чтобы не экспортировать метод, его помечают #[lua(skip)].
--
-- Другие Lua-файлы можно подключать из каталога сценариев:
-- include("tools.lua") -- выполнить файл сразу
-- require("tools") -- выполнить один раз (как обычный Lua-модуль)
--
-- Подсветка синтаксиса по умолчанию выключена. Включить её можно так:
-- app.editor:set_language(0, "rust")
-- api.editor:set_language(0, "rust")
-- gopls запускается автоматически при старте редактора.
-- Сервер обслуживает только документы с languageId "go" и получает изменения
-- всех .go-файлов проекта (кроме vendor/) через файловый вотчер.
local gopls_ok, gopls_err = pcall(function()
app.editor:run_lsp({
api.editor:run_lsp({
name = "gopls",
cmd = "gopls",
args = { "serve" },
@@ -242,75 +266,209 @@ if not gopls_ok then
end
-- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно.
-- app:map_key("", "<C-s>", function() app.editor:save() end)
-- app:map_key("", "<!Esc>", function() app:close() end)
-- api.keymap:map_key("", "<C-s>", function() api.editor:save() end)
-- api.keymap:map_key("", "<!Esc>", function() api.window:close() end)
-- Убрать встроенную привязку можно так:
-- app:map_key("", "<C-s>", nil)
-- app:map_completion_key("<C-n>", "completion_next")
-- app:map_completion_key("<C-p>", "completion_previous")
-- api.keymap:map_key("", "<C-s>", nil)
-- api.keymap:map_completion_key("<C-n>", "completion_next")
-- api.keymap:map_completion_key("<C-p>", "completion_previous")
-- Файловое дерево проекта.
--
-- Редактор рисует абстрактное дерево; смысл узлов (файлы, папки, что угодно)
-- задаёт этот сценарий. Здесь корень — текущий каталог, а каждая папка
-- дозагружается при первом раскрытии.
local project_tree = app.left_panel:add_tree()
local project_tree = api.panels.left:add_tree()
-- Какие узлы являются каталогами. Нужно при создании пути: новый файл
-- появляется внутри выбранной папки или рядом с выбранным файлом.
local node_is_dir = {}
-- Какие папки раскрыты. `load_dir` обновляет узлы, и без этого списка
-- повторная загрузка каталога сворачивала бы уже открытые папки.
local expanded_nodes = {}
local function split_path(path)
local parts = {}
for part in path:gmatch("[^/]+") do
parts[#parts + 1] = part
end
return parts
end
local function parent_path(path)
return path:match("^(.*)/[^/]*$") or ""
end
local function merge_path(base, parts)
local segments = {}
for _, part in ipairs(base) do
segments[#segments + 1] = part
end
for _, part in ipairs(parts) do
segments[#segments + 1] = part
end
return segments
end
local function join_path(segments)
return table.concat(segments, "/")
end
local function load_dir(tree, path)
local nodes = {}
for _, entry in ipairs(app:list_dir(path)) do
for _, entry in ipairs(api.path:list(path)) do
node_is_dir[entry.id] = entry.is_dir
nodes[#nodes + 1] = {
id = entry.id,
label = entry.name,
icon = entry.is_dir and "folder" or "file",
expandable = entry.is_dir,
expanded = false,
expanded = entry.is_dir and expanded_nodes[entry.id] == true or false,
}
end
tree:append(nodes, path)
end
project_tree:on_toggle(function(expanded, id, loaded)
expanded_nodes[id] = expanded or nil
-- loaded == false только при первом раскрытии: позже дети уже в дереве.
if expanded and not loaded then
load_dir(project_tree, id)
end
end)
-- Раскрывает папку и сохраняет это в `expanded_nodes`, чтобы последующая
-- загрузка соседних каталогов не свернула её обратно.
local function expand_dir(path, label)
expanded_nodes[path] = true
project_tree:append({
{
id = path,
label = label,
icon = "folder",
expandable = true,
expanded = true,
},
}, parent_path(path))
end
project_tree:on_click(function(id)
-- Пустая таблица: фокус уходит в редактор, курсор остаётся на месте.
app.editor:open(id, {})
api.editor:open(id, {})
end)
app.left_panel:open()
api.panels.left:open()
load_dir(project_tree, "")
-- Открытие/закрытие панели с деревом. При открытии возвращаем фокус на
-- последнюю выбранную строку, чтобы можно было сразу продолжить навигацию.
app:map_key("", "<C-b>", function()
local opened = app.left_panel:toggle()
if opened then
local selected = project_tree:selected()
if selected then
project_tree:focus(selected)
end
-- Каталог, от которого отсчитывается новый путь: выбранная папка или
-- родитель выбранного файла.
local function creation_base()
local id = project_tree:selected()
if not id or id == "" then
return {}
end
end)
if node_is_dir[id] then
return split_path(id)
end
return split_path(parent_path(id))
end
app:map_key("", "<C-q>", function() app:close() end)
-- Создаёт путь, введённый в поле. Последний сегмент — файл, если строка не
-- заканчивается на "/"; иначе создаётся папка. Все промежуточные сегменты —
-- папки. Путь отсчитывается от текущей позиции в дереве.
local function create_path(text)
text = text:match("^%s*(.-)%s*$")
if text == "" then
return
end
-- Плавающее поле ввода. Пока оно открыто, сочетания клавиш приложения
-- заблокированы; Enter вызывает callback, Esc и клик вне окна — отменяют.
app:map_key("", "<C-g>", function()
app:open_input({
-- title = "Открыть файл",
placeholder = "src/main.rs",
callback = function(text) app.editor:open(text, {}) end,
local base = creation_base()
local parts = split_path(text)
if #parts == 0 then
return
end
local is_directory = text:sub(-1) == "/"
local parent_parts = {}
local last_index = is_directory and #parts or #parts - 1
for index = 1, last_index do
parent_parts[#parent_parts + 1] = parts[index]
end
local parent_segments = merge_path(base, parent_parts)
if #parent_segments > 0 then
api.path:mkdir(parent_segments)
end
local path = join_path(merge_path(base, parts))
if not is_directory then
-- Создаём файл на диске, затем открываем его вкладкой. `open` без
-- второго аргумента не забирает фокус у дерева.
api.path:touch(path)
api.editor:open(path)
end
-- Раскрываем всё созданное поддерево и загружаем содержимое каждого
-- каталога, чтобы конечный элемент был виден в дереве.
local current = {}
for _, part in ipairs(base) do
current[#current + 1] = part
end
for _, part in ipairs(parent_parts) do
load_dir(project_tree, join_path(current))
current[#current + 1] = part
local dir_path = join_path(current)
expand_dir(dir_path, part)
load_dir(project_tree, dir_path)
end
-- Загружаем каталог, в котором появился конечный элемент (для пути без
-- промежуточных папок это базовый каталог).
load_dir(project_tree, join_path(current))
project_tree:focus(path)
if not is_directory then
-- Созданный файл уже открыт вкладкой; переводим туда фокус, выбор
-- в дереве при этом остаётся на нём.
api.editor:focus()
end
end
project_tree:map_key("", "a", function()
api.window:open_input({
title = "Новый файл или папка",
placeholder = "foo/bar/omg.txt",
callback = create_path,
})
end)
app:map_key("", "<C-e>", "toggle_left_panel")
app:map_key("", "<C-t>", "toggle_bottom_panel")
-- Ctrl+E открывает панель и переводит фокус на дерево, а если панель уже
-- открыта — только переводит фокус. На дереве Ctrl+Q закрывает панель,
-- в остальных местах — по-прежнему закрывает приложение.
api.keymap:map_key("", "<C-e>", function()
api.panels.left:open()
project_tree:focus()
end)
api.keymap:map_key("", "<C-q>", function()
if project_tree:has_focus() then
api.panels.left:close()
api.editor:focus()
else
api.window:close()
end
end)
-- Плавающее поле ввода. Пока оно открыто, сочетания клавиш приложения
-- заблокированы; Enter вызывает callback, Esc и клик вне окна — отменяют.
api.keymap:map_key("", "<C-g>", function()
api.window:open_input({
-- title = "Открыть файл",
placeholder = "src/main.rs",
callback = function(text) api.editor:open(text, {}) end,
})
end)
api.keymap:map_key("", "<C-t>", "toggle_bottom_panel")
-- Дерево диагностики в нижней панели.
--
@@ -318,7 +476,7 @@ app:map_key("", "<C-t>", "toggle_bottom_panel")
-- тишины: если серверы присылают диагностику чаще, промежуточные вызовы
-- пропускаются, а последняя остаётся и обрабатывается после затишья.
-- При каждом вызове дерево очищается целиком и строится заново.
local diagnostics_tree = app.bottom_panel:add_tree("Диагностика")
local diagnostics_tree = api.panels.bottom:add_tree("Диагностика")
local severity_icons = {
error = "circle-x",
@@ -346,8 +504,8 @@ end
local diagnostic_gutter = {}
local function active_tab_title()
local active = app.editor:active_tab()
for _, tab in ipairs(app.editor:tabs()) do
local active = api.editor:active_tab()
for _, tab in ipairs(api.editor:tabs()) do
if tab.index == active then
return tab.title
end
@@ -370,14 +528,14 @@ local function path_matches_title(path, title)
return #path > #suffix and path:sub(-#suffix) == suffix
end
app.editor:on_lsp_diagnostics(100, function(servers)
api.editor:on_lsp_diagnostics(100, function(servers)
diagnostics_tree:clear()
-- Иконки прошлого обновления активной вкладки могли устареть.
local title = active_tab_title()
if title and diagnostic_gutter[title] then
for line in pairs(diagnostic_gutter[title]) do
app.editor:clear_gutter_icon(line)
api.editor:clear_gutter_icon(line)
end
diagnostic_gutter[title] = nil
end
@@ -449,7 +607,7 @@ app.editor:on_lsp_diagnostics(100, function(servers)
local group = groups[key]
local icon = severity_icons[group.severity] or "circle-alert"
if active then
app.editor:set_gutter_icon(group.line, icon)
api.editor:set_gutter_icon(group.line, icon)
gutter_lines[group.line] = true
end
errors[#errors + 1] = {
@@ -498,21 +656,21 @@ end)
diagnostics_tree:on_click(function(id)
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
if path then
app.editor:open(path, { tonumber(line), tonumber(character) })
api.editor:open(path, { tonumber(line), tonumber(character) })
else
-- Клик по файлу: фокус в редактор, курсор не переставляем.
app.editor:open(id, {})
api.editor:open(id, {})
end
end)
app.bottom_panel:open()
api.panels.bottom:open()
-- Верхняя панель: пример лейблов и кнопок.
-- app.toolbar:add_label("Готово", nil, nil, "status", "left")
app.toolbar:add_label("UTF-8", nil, "#efefe6", "encoding", "right")
app.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
-- api.panels.toolbar:add_label("Готово", nil, nil, "status", "left")
api.panels.toolbar:add_label("UTF-8", nil, "#efefe6", "encoding", "right")
api.panels.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
print("[toolbar] кнопка «Запустить» нажата")
end)
app.editor:set_gutter_icon(2, "play", "#efefe6")
app.editor:set_gutter_icon(90, "play")
api.editor:set_gutter_icon(2, "play", "#efefe6")
api.editor:set_gutter_icon(90, "play")
+583 -207
View File
@@ -2,7 +2,11 @@
//!
//! Единственная точка входа для команд управления окном и редактором: UI-код
//! и Lua-сценарии не трогают `Window` и состояние вида напрямую, а вызывают
//! методы [`AppApi`] или [`EditorApi`] (`app.editor`).
//! методы объектов [`AppApi`].
//!
//! Для Lua `AppApi` — это объект `api`, разбитый на группы: `api.editor`,
//! `api.panels`, `api.path`, `api.window` и `api.keymap`. Сами методы живут в
//! API своей группы, а [`AppApi`] отдаёт только их.
//!
//! Команды окна не выполняются в момент вызова: Lua-обработчик выполняется вне
//! контекста `App`/`Window`, поэтому [`AppApi`] складывает их в очередь, а вид
@@ -10,6 +14,7 @@
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
@@ -41,7 +46,7 @@ thread_local! {
static WINDOW_COMMANDS: RefCell<VecDeque<WindowCommand>> = const { RefCell::new(VecDeque::new()) };
/// Развёрнуто ли окно. Вид обновляет значение в каждом кадре, чтобы
/// `app:is_maximized()` из Lua видел актуальное состояние.
/// `api.window:is_maximized()` из Lua видел актуальное состояние.
static WINDOW_MAXIMIZED: Cell<bool> = const { Cell::new(false) };
}
@@ -82,10 +87,10 @@ pub struct TabInfo {
pub icon: String,
}
/// Элемент каталога, возвращаемый `app:list_dir(path)`.
/// Элемент каталога, возвращаемый `api.path:list(path)`.
///
/// `id` — путь относительно текущего каталога процесса: его можно сразу
/// положить в `id` узла дерева и передать в `app:open(id)`.
/// положить в `id` узла дерева и передать в `api.editor:open(id)`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirEntryInfo {
/// Путь элемента относительно текущего каталога.
@@ -108,13 +113,23 @@ struct DiagnosticsHandler {
/// Глобальный объект с функциями управления приложением.
///
/// Использование: `AppApi::global().close()`, `AppApi::global().editor()`.
/// Использование: `AppApi::global()`, `AppApi::global().editor()`.
/// Экземпляр устанавливается один раз при создании приложения (см.
/// [`AppApi::install`]) и указывает на общие с видом документы и карты клавиш.
///
/// В Lua объект называется `api` и состоит из групп:
///
/// * `api.editor` — вкладки, текст, gutter, сохранение, LSP и привязки
/// редактора ([`EditorApi`]);
/// * `api.panels` — левая, правая и нижняя панели, а также верхняя панель
/// редактора ([`PanelsApi`]);
/// * `api.path` — файловая система ([`PathApi`]);
/// * `api.window` — окно и плавающее поле ввода ([`WindowApi`]);
/// * `api.keymap` — глобальные привязки клавиш ([`KeymapApi`]).
///
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
/// `#[mlua::userdata_impl]`: в сценариях они доступны как `app:method(...)`.
/// Чтобы не экспортировать метод, пометьте его `#[lua(skip)]`.
/// `#[mlua::userdata_impl]`. Чтобы не экспортировать метод, пометьте его
/// `#[lua(skip)]`.
#[derive(Clone, mlua::UserData)]
pub struct AppApi {
#[lua(skip)]
@@ -145,7 +160,7 @@ pub struct AppApi {
/// API редактора: вкладки, текст, gutter, сохранение, LSP и свои привязки
/// клавиш.
///
/// В Lua доступно как `app.editor`. Объект всегда смотрит на те же документы
/// В Lua доступно как `api.editor`. Объект всегда смотрит на те же документы
/// и LSP-менеджер, что и вид: второго состояния нет.
#[derive(Clone, mlua::UserData)]
pub struct EditorApi {
@@ -176,12 +191,46 @@ impl EditorApi {
}
}
/// API панелей окна.
///
/// В Lua доступно как `api.panels`. Отдаёт объекты панелей:
/// `api.panels.left`, `api.panels.right`, `api.panels.bottom` и
/// `api.panels.toolbar`. У боковых и нижней панели одинаковый набор методов,
/// поэтому сценарий управляет ими единообразно.
#[derive(Clone, mlua::UserData)]
pub struct PanelsApi {
#[lua(skip)]
left: PanelHandle,
#[lua(skip)]
right: PanelHandle,
#[lua(skip)]
bottom: PanelHandle,
#[lua(skip)]
toolbar: ToolbarHandle,
}
impl PanelsApi {
fn new(
left: PanelHandle,
right: PanelHandle,
bottom: PanelHandle,
toolbar: ToolbarHandle,
) -> Self {
Self {
left,
right,
bottom,
toolbar,
}
}
}
/// API одной панели окна.
///
/// Один и тот же тип используется для `app.left_panel`, `app.right_panel` и
/// `app.bottom_panel`: отличается только состояние, на которое ссылается
/// объект. Поэтому панели управляются одинаково: `app.left_panel:open()`,
/// `app.bottom_panel:toggle()` и так далее.
/// Один и тот же тип используется для `api.panels.left`, `api.panels.right` и
/// `api.panels.bottom`: отличается только состояние, на которое ссылается
/// объект. Поэтому панели управляются одинаково: `api.panels.left:open()`,
/// `api.panels.bottom:toggle()` и так далее.
#[derive(mlua::UserData)]
pub struct PanelApi {
#[lua(skip)]
@@ -196,9 +245,9 @@ impl PanelApi {
/// API верхней панели редактора.
///
/// В Lua доступно как `app.toolbar`. Элементы добавляются в левую или правую
/// область панели и опознаются по `id`, чтобы их можно было скрыть и снова
/// показать.
/// В Lua доступно как `api.panels.toolbar`. Элементы добавляются в левую или
/// правую область панели и опознаются по `id`, чтобы их можно было скрыть и
/// снова показать.
#[derive(mlua::UserData)]
pub struct ToolbarApi {
#[lua(skip)]
@@ -211,9 +260,53 @@ impl ToolbarApi {
}
}
/// API файловой системы.
///
/// В Lua доступно как `api.path`. Все методы, принимающие путь, понимают и
/// строку `"foo/bar"`, и массив сегментов `{ "foo", "bar" }`.
#[derive(Clone, Copy, Default, mlua::UserData)]
pub struct PathApi;
impl PathApi {
fn new() -> Self {
Self
}
}
/// API окна и плавающего поля ввода.
///
/// В Lua доступно как `api.window`.
#[derive(Clone, mlua::UserData)]
pub struct WindowApi {
#[lua(skip)]
prompt_request: PromptRequestHandle,
}
impl WindowApi {
fn new(prompt_request: PromptRequestHandle) -> Self {
Self { prompt_request }
}
}
/// API глобальных привязок клавиш.
///
/// В Lua доступно как `api.keymap`. Привязки редактора живут отдельно в
/// `api.editor:map_key`, а блоков панелей — в `tree:map_key`.
#[derive(Clone, mlua::UserData)]
pub struct KeymapApi {
#[lua(skip)]
keymap: Rc<RefCell<KeyMap>>,
}
impl KeymapApi {
fn new(keymap: Rc<RefCell<KeyMap>>) -> Self {
Self { keymap }
}
}
/// API одного блока дерева.
///
/// Объект возвращает `app.left_panel:add_tree()`. Редактор не знает, что
/// Объект возвращает `api.panels.left:add_tree()`. Редактор не знает, что
/// означают узлы: сценарий сам решает, будет это файловое дерево проекта или
/// что-то ещё, и передаёт готовый список узлов в [`TreeApi::append`].
#[derive(mlua::UserData)]
@@ -338,13 +431,75 @@ impl TreeApi {
self.state.borrow().selected().map(str::to_owned)
}
/// Получает ли дерево клавиатурный фокус прямо сейчас.
///
/// Значение обновляется видом перед обработкой каждого события клавиатуры,
/// поэтому его можно проверять в глобальных обработчиках привязок.
#[lua(infallible)]
pub fn has_focus(&self) -> bool {
self.state.borrow().has_focus()
}
/// Раскрывает всех закрытых родителей узла `id`, выбирает его и передаёт
/// фокус дереву.
///
/// Без аргумента (`tree:focus()`) только передаёт дереву фокус, не меняя
/// выбранную строку.
///
/// Возвращает `false`, если узла с таким идентификатором нет.
#[lua(infallible)]
pub fn focus(&self, id: String) -> bool {
self.state.borrow_mut().focus(&id)
pub fn focus(&self, id: Option<String>) -> bool {
let mut state = self.state.borrow_mut();
match id {
Some(id) => state.focus(&id),
None => {
state.request_focus();
true
}
}
}
}
#[mlua::userdata_impl]
impl PanelsApi {
/// Левая панель окна.
///
/// В Lua доступна как `api.panels.left` с методами `open`, `close`,
/// `toggle`, `is_open` и `add_tree`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn left(&self) -> PanelApi {
PanelApi::new(self.left.clone())
}
/// Правая панель окна.
///
/// В Lua доступна как `api.panels.right` с теми же методами, что и
/// `api.panels.left`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn right(&self) -> PanelApi {
PanelApi::new(self.right.clone())
}
/// Нижняя панель окна.
///
/// В Lua доступна как `api.panels.bottom` с теми же методами, что и
/// `api.panels.left`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn bottom(&self) -> PanelApi {
PanelApi::new(self.bottom.clone())
}
/// Верхняя панель редактора.
///
/// В Lua доступна как `api.panels.toolbar` с методами `add_label`,
/// `add_button`, `hide`, `show` и `clear`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn toolbar(&self) -> ToolbarApi {
ToolbarApi::new(self.toolbar.clone())
}
}
@@ -380,10 +535,10 @@ impl PanelApi {
/// сохраняют, но не рисуют.
///
/// ```lua
/// local tree = app.left_panel:add_tree()
/// local tree = api.panels.left:add_tree()
/// tree:append({ { id = "a", label = "a.txt" } }, "")
///
/// local diagnostics = app.bottom_panel:add_tree("Диагностика")
/// local diagnostics = api.panels.bottom:add_tree("Диагностика")
/// ```
#[lua(infallible, name = "add_tree")]
pub fn add_tree(&self, title: Option<String>) -> TreeApi {
@@ -391,6 +546,305 @@ impl PanelApi {
}
}
#[mlua::userdata_impl]
impl PathApi {
/// Читает содержимое каталога.
///
/// `path` — строка `"src"` или массив сегментов `{ "src", "main" }`;
/// пустая строка, `nil` или `{ }` означают текущий рабочий каталог.
///
/// Возвращает массив `{ id = "src/main.rs", name = "main.rs",
/// is_dir = false }`. Элементы отсортированы по имени без учёта регистра;
/// `id` — путь относительно текущего каталога, если `path` тоже
/// относительный.
#[lua(name = "list")]
pub fn list(&self, path: mlua::Value) -> mlua::Result<Vec<DirEntryInfo>> {
let path = parse_path_value(path)?;
let raw = path_to_string(&path);
let relative_to_cwd = raw.is_empty() || raw == ".";
let directory = if raw.is_empty() {
Path::new(".")
} else {
path.as_path()
};
let read_dir = std::fs::read_dir(directory).map_err(|err| {
mlua::Error::RuntimeError(format!(
"не удалось прочитать каталог {}: {err}",
path_label(&path)
))
})?;
let mut entries = Vec::new();
for entry in read_dir {
let entry = entry.map_err(|err| {
mlua::Error::RuntimeError(format!(
"не удалось прочитать элемент каталога {}: {err}",
path_label(&path)
))
})?;
let name = entry.file_name().to_string_lossy().into_owned();
let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false);
let id = if relative_to_cwd {
name.clone()
} else {
path.join(&name).to_string_lossy().replace('\\', "/")
};
entries.push(DirEntryInfo { id, name, is_dir });
}
entries.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then_with(|| a.name.cmp(&b.name))
});
Ok(entries)
}
/// Создаёт каталоги, включая все отсутствующие родительские.
///
/// `api.path:mkdir({ "foo", "bar" })` и `api.path:mkdir("foo/bar")`
/// создают `foo`, затем `foo/bar`. Путь отсчитывается от текущего рабочего
/// каталога процесса; пустые сегменты пропускаются.
///
/// Возвращает созданный путь с разделителями `/`. Для пустого пути
/// ничего не создаёт и возвращает пустую строку.
pub fn mkdir(&self, path: mlua::Value) -> mlua::Result<String> {
let path = parse_path_value(path)?;
if path.as_os_str().is_empty() {
return Ok(String::new());
}
std::fs::create_dir_all(&path).map_err(|err| {
mlua::Error::RuntimeError(format!(
"не удалось создать каталог {}: {err}",
path_label(&path)
))
})?;
Ok(path_to_string(&path))
}
/// Удаляет файл или каталог вместе со всем содержимым.
///
/// Возвращает `true`, если что-то было удалено, и `false`, если пути не
/// существовало. Пустой путь удалять нельзя — это ошибка Lua.
pub fn remove(&self, path: mlua::Value) -> mlua::Result<bool> {
let path = parse_path_value(path)?;
if path.as_os_str().is_empty() {
return Err(mlua::Error::RuntimeError(
"нельзя удалить пустой путь".to_owned(),
));
}
let metadata = match std::fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(err) => {
return Err(mlua::Error::RuntimeError(format!(
"не удалось прочитать путь {}: {err}",
path_label(&path)
)));
}
};
let result = if metadata.file_type().is_dir() {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
result.map_err(|err| {
mlua::Error::RuntimeError(format!("не удалось удалить {}: {err}", path_label(&path)))
})?;
Ok(true)
}
/// Создаёт пустой файл, если его нет, и обновляет время изменения.
///
/// Содержимое существующего файла не меняется. Родительский каталог
/// должен существовать. Возвращает путь с разделителями `/`.
pub fn touch(&self, path: mlua::Value) -> mlua::Result<String> {
let path = parse_path_value(path)?;
if path.as_os_str().is_empty() {
return Err(mlua::Error::RuntimeError(
"нельзя создать файл по пустому пути".to_owned(),
));
}
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.open(&path)
.map_err(|err| {
mlua::Error::RuntimeError(format!(
"не удалось создать файл {}: {err}",
path_label(&path)
))
})?;
file.set_modified(std::time::SystemTime::now())
.map_err(|err| {
mlua::Error::RuntimeError(format!(
"не удалось обновить время файла {}: {err}",
path_label(&path)
))
})?;
Ok(path_to_string(&path))
}
/// Читает содержимое файла в строку.
pub fn read(&self, path: mlua::Value) -> mlua::Result<String> {
let path = parse_path_value(path)?;
std::fs::read_to_string(&path).map_err(|err| {
mlua::Error::RuntimeError(format!(
"не удалось прочитать файл {}: {err}",
path_label(&path)
))
})
}
/// Записывает строку в файл.
///
/// `append = true` дописывает содержимое в конец, `false` или `nil` —
/// перезаписывает файл с нуля. Файл создаётся, если его нет; родительский
/// каталог должен существовать. Возвращает путь с разделителями `/`.
pub fn write(
&self,
path: mlua::Value,
content: String,
append: Option<bool>,
) -> mlua::Result<String> {
let path = parse_path_value(path)?;
if path.as_os_str().is_empty() {
return Err(mlua::Error::RuntimeError(
"нельзя записать файл по пустому пути".to_owned(),
));
}
let mut options = std::fs::OpenOptions::new();
options.create(true);
if append.unwrap_or(false) {
options.append(true);
} else {
options.write(true).truncate(true);
}
let mut file = options.open(&path).map_err(|err| {
mlua::Error::RuntimeError(format!(
"не удалось открыть файл {}: {err}",
path_label(&path)
))
})?;
file.write_all(content.as_bytes()).map_err(|err| {
mlua::Error::RuntimeError(format!(
"не удалось записать файл {}: {err}",
path_label(&path)
))
})?;
Ok(path_to_string(&path))
}
}
#[mlua::userdata_impl]
impl WindowApi {
/// Закрывает окно приложения.
#[lua(infallible)]
pub fn close(&self) {
push_window_command(WindowCommand::Close);
}
/// Разворачивает окно на весь экран или возвращает его прежний размер.
#[lua(infallible)]
pub fn toggle_maximize(&self) {
push_window_command(WindowCommand::ToggleMaximize);
}
/// Разворачивает (`true`) или восстанавливает (`false`) окно.
#[lua(infallible)]
pub fn set_maximized(&self, maximized: bool) {
push_window_command(WindowCommand::SetMaximized(maximized));
}
/// Сворачивает окно.
#[lua(infallible)]
pub fn minimize(&self) {
push_window_command(WindowCommand::Minimize);
}
/// Развёрнуто ли окно прямо сейчас.
#[lua(infallible)]
pub fn is_maximized(&self) -> bool {
WINDOW_MAXIMIZED.with(Cell::get)
}
/// Открывает плавающее поле ввода чуть выше центра окна.
///
/// `options` — таблица `{ title = "Имя", placeholder = "main.rs",
/// callback = function(text) ... end }`.
/// `title` и `placeholder` необязательны: без заголовка окно состоит из
/// одной строки ввода, а без placeholder подсказка не рисуется.
/// Пока окно открыто, сочетания клавиш приложения не работают.
/// `Enter` подтверждает ввод и вызывает `callback` уже после закрытия окна,
/// `Esc` и клик вне окна закрывают его без колбэка.
pub fn open_input(&self, options: Option<mlua::Table>) -> mlua::Result<()> {
*self.prompt_request.borrow_mut() = Some(PromptRequest::from_options(options)?);
Ok(())
}
}
#[mlua::userdata_impl]
impl KeymapApi {
/// Назначает глобальный обработчик на нажатие или отпускание клавиши.
///
/// Первый аргумент `modes` — строка режимов, в которых действует
/// сочетание: пустая строка — все режимы, `"-"` — только режим по
/// умолчанию, `"-iv"` — режим по умолчанию, `i` и `v`. `keys` записывается
/// в духе nvim:
///
/// * `a`, `1` — нажатие клавиши без модификаторов;
/// * `<S-c>`, `<C-z>`, `<A-r>`, `<D-s>` — нажатие с Shift, Ctrl, Alt или
/// платформенным модификатором (Cmd/Super);
/// * `!a` — отпускание клавиши;
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
///
/// Обработчик — Lua-функция, имя встроенной команды (`save`, `next_tab`,
/// `toggle_left_panel` и т. п.) или `nil`, чтобы удалить привязку. Повторное
/// назначение той же клавиши заменяет обработчик.
///
/// # Errors
/// Возвращает ошибку Lua, если запись привязки или имя команды некорректны.
pub fn map_key(&self, modes: String, keys: String, handler: mlua::Value) -> mlua::Result<()> {
bind_key(&self.keymap, KeyContext::Global, &modes, &keys, handler)
}
/// Переключает глобальные привязки в режим `mode`.
///
/// Режим — ровно один символ; `"-"` — режим по умолчанию. Режимы
/// динамические: заранее создавать их не нужно.
pub fn set_mode(&self, mode: String) -> mlua::Result<()> {
self.keymap
.borrow_mut()
.set_mode(&mode)
.map_err(mlua::Error::RuntimeError)
}
/// Текущий режим глобальных привязок.
#[lua(infallible)]
pub fn mode(&self) -> String {
self.keymap.borrow().mode().to_string()
}
/// Назначает обработчик клавиши, пока открыт список автодополнения.
///
/// Синтаксис совпадает с [`KeymapApi::map_key`]. Встроенные команды этого
/// слоя: `completion_trigger`, `completion_next`, `completion_previous`,
/// `completion_page_up`, `completion_page_down`, `completion_accept`,
/// `completion_cancel`, `completion_dismiss`.
#[lua(name = "map_completion_key")]
pub fn map_completion_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
bind_key(&self.keymap, KeyContext::Completion, "", &keys, handler)
}
}
#[mlua::userdata_impl]
impl ToolbarApi {
/// Добавляет текстовый лейбл в левую или правую область панели.
@@ -550,8 +1004,8 @@ impl AppApi {
/// API редактора.
///
/// В Lua доступно как `app.editor`: `app.editor:open_tab()`,
/// `app.editor:save()`, `app.editor:map_key("n", "<C-s>", "save")`.
/// В Lua доступно как `api.editor`: `api.editor:open_tab()`,
/// `api.editor:save()`, `api.editor:map_key("n", "<C-s>", "save")`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn editor(&self) -> EditorApi {
@@ -563,193 +1017,45 @@ impl AppApi {
)
}
/// Читает содержимое каталога (Rust-версия `app:list_dir`).
/// API панелей окна.
///
/// Пустой путь или `"."` означают текущий рабочий каталог процесса.
/// Элементы сортируются по имени без учёта регистра. В `id` попадает путь
/// относительно текущего каталога, если `path` тоже относительный.
#[lua(skip)]
pub fn list_dir(&self, path: impl AsRef<Path>) -> std::io::Result<Vec<DirEntryInfo>> {
let raw = path.as_ref().as_os_str().to_string_lossy().into_owned();
let relative_to_cwd = raw.is_empty() || raw == ".";
let directory = if raw.is_empty() {
Path::new(".")
} else {
path.as_ref()
};
let mut entries = Vec::new();
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false);
let id = if relative_to_cwd {
name.clone()
} else {
Path::new(&raw)
.join(&name)
.to_string_lossy()
.replace('\\', "/")
};
entries.push(DirEntryInfo { id, name, is_dir });
}
entries.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then_with(|| a.name.cmp(&b.name))
});
Ok(entries)
}
/// Список содержимого каталога `path` относительно текущего каталога.
///
/// Каждый элемент — таблица `{ id = "src/main.rs", name = "main.rs",
/// is_dir = false }`. Пустая строка или `"."` — текущий каталог. Метод
/// предназначен для построения деревьев и списков из Lua.
#[lua(name = "list_dir")]
fn lua_list_dir(&self, path: Option<String>) -> mlua::Result<Vec<DirEntryInfo>> {
let path = path.unwrap_or_default();
self.list_dir(&path).map_err(|err| {
let shown = if path.is_empty() { "." } else { path.as_str() };
mlua::Error::RuntimeError(format!("не удалось прочитать каталог {shown}: {err}"))
})
}
/// Левая панель окна.
///
/// В Lua доступна как `app.left_panel` с методами `open`, `close`,
/// `toggle` и `is_open`.
/// В Lua доступно как `api.panels`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn left_panel(&self) -> PanelApi {
PanelApi::new(self.left_panel.clone())
pub fn panels(&self) -> PanelsApi {
PanelsApi::new(
self.left_panel.clone(),
self.right_panel.clone(),
self.bottom_panel.clone(),
self.toolbar.clone(),
)
}
/// Правая панель окна.
/// API файловой системы.
///
/// В Lua доступна как `app.right_panel` с методами `open`, `close`,
/// `toggle` и `is_open`.
/// В Lua доступно как `api.path`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn right_panel(&self) -> PanelApi {
PanelApi::new(self.right_panel.clone())
pub fn path(&self) -> PathApi {
PathApi::new()
}
/// Нижняя панель окна.
/// API окна и плавающего поля ввода.
///
/// В Lua доступна как `app.bottom_panel` с теми же методами, что и
/// `app.right_panel`.
/// В Lua доступно как `api.window`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn bottom_panel(&self) -> PanelApi {
PanelApi::new(self.bottom_panel.clone())
pub fn window(&self) -> WindowApi {
WindowApi::new(self.prompt_request.clone())
}
/// Верхняя панель редактора.
/// API глобальных привязок клавиш.
///
/// В Lua доступна как `app.toolbar` с методами `add_label`, `add_button`,
/// `hide`, `show` и `clear`.
/// В Lua доступно как `api.keymap`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn toolbar(&self) -> ToolbarApi {
ToolbarApi::new(self.toolbar.clone())
}
/// Закрывает окно приложения.
#[lua(infallible)]
pub fn close(&self) {
push_window_command(WindowCommand::Close);
}
/// Разворачивает окно на весь экран или возвращает его прежний размер.
#[lua(infallible)]
pub fn toggle_maximize(&self) {
push_window_command(WindowCommand::ToggleMaximize);
}
/// Разворачивает (`true`) или восстанавливает (`false`) окно.
#[lua(infallible)]
pub fn set_maximized(&self, maximized: bool) {
push_window_command(WindowCommand::SetMaximized(maximized));
}
/// Сворачивает окно.
#[lua(infallible)]
pub fn minimize(&self) {
push_window_command(WindowCommand::Minimize);
}
/// Развёрнуто ли окно прямо сейчас.
#[lua(infallible)]
pub fn is_maximized(&self) -> bool {
WINDOW_MAXIMIZED.with(Cell::get)
}
/// Открывает плавающее поле ввода чуть выше центра окна.
///
/// `options` — таблица `{ title = "Имя", placeholder = "main.rs",
/// callback = function(text) ... end }`.
/// `title` и `placeholder` необязательны: без заголовка окно состоит из
/// одной строки ввода, а без placeholder подсказка не рисуется.
/// Пока окно открыто, сочетания клавиш приложения не работают.
/// `Enter` подтверждает ввод и вызывает `callback` уже после закрытия окна,
/// `Esc` и клик вне окна закрывают его без колбэка.
pub fn open_input(&self, options: Option<mlua::Table>) -> mlua::Result<()> {
*self.prompt_request.borrow_mut() = Some(PromptRequest::from_options(options)?);
Ok(())
}
/// Назначает глобальный обработчик на нажатие или отпускание клавиши.
///
/// Первый аргумент `modes` — строка режимов, в которых действует
/// сочетание: пустая строка — все режимы, `"-"` — только режим по
/// умолчанию, `"-iv"` — режим по умолчанию, `i` и `v`. `keys` записывается
/// в духе nvim:
///
/// * `a`, `1` — нажатие клавиши без модификаторов;
/// * `<S-c>`, `<C-z>`, `<A-r>`, `<D-s>` — нажатие с Shift, Ctrl, Alt или
/// платформенным модификатором (Cmd/Super);
/// * `!a` — отпускание клавиши;
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
///
/// Обработчик — Lua-функция, имя встроенной команды (`save`, `next_tab`,
/// `toggle_left_panel` и т. п.) или `nil`, чтобы удалить привязку. Повторное
/// назначение той же клавиши заменяет обработчик.
///
/// # Errors
/// Возвращает ошибку Lua, если запись привязки или имя команды некорректны.
pub fn map_key(&self, modes: String, keys: String, handler: mlua::Value) -> mlua::Result<()> {
bind_key(&self.keymap, KeyContext::Global, &modes, &keys, handler)
}
/// Переключает глобальные привязки в режим `mode`.
///
/// Режим — ровно один символ; `"-"` — режим по умолчанию. Режимы
/// динамические: заранее создавать их не нужно.
pub fn set_mode(&self, mode: String) -> mlua::Result<()> {
self.keymap
.borrow_mut()
.set_mode(&mode)
.map_err(mlua::Error::RuntimeError)
}
/// Текущий режим глобальных привязок.
#[lua(infallible)]
pub fn mode(&self) -> String {
self.keymap.borrow().mode().to_string()
}
/// Назначает обработчик клавиши, пока открыт список автодополнения.
///
/// Синтаксис совпадает с [`AppApi::map_key`]. Встроенные команды этого
/// слоя: `completion_trigger`, `completion_next`, `completion_previous`,
/// `completion_page_up`, `completion_page_down`, `completion_accept`,
/// `completion_cancel`, `completion_dismiss`.
#[lua(name = "map_completion_key")]
pub fn map_completion_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
bind_key(&self.keymap, KeyContext::Completion, "", &keys, handler)
pub fn keymap(&self) -> KeymapApi {
KeymapApi::new(self.keymap.clone())
}
}
@@ -885,6 +1191,17 @@ impl EditorApi {
self.keymap.borrow().mode().to_string()
}
/// Переводит клавиатурный фокус в редактор.
///
/// Активная вкладка, текст и позиция курсора не меняются. Запрос
/// применяется видом в ближайшем кадре.
#[lua(infallible)]
pub fn focus(&self) {
self.workspace
.borrow_mut()
.set_focus_after_sync(FocusAfterSync::Editor(None));
}
/// Содержимое активной вкладки.
#[lua(infallible)]
pub fn active_text(&self) -> String {
@@ -911,19 +1228,19 @@ impl EditorApi {
workspace.active_index()
}
/// Открывает файл с диска на редактирование (Rust-версия `app.editor:open`).
/// Открывает файл с диска на редактирование (Rust-версия `api.editor:open`).
///
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
/// Язык подсветки определяется по расширению, если для него есть грамматика.
/// Фокус редактора не меняется: для этого есть [`EditorApi::open_focused`]
/// и Lua-форма `app.editor:open(path, {...})`.
/// и Lua-форма `api.editor:open(path, {...})`.
#[lua(skip)]
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
self.open_impl(path, None, false)
}
/// Открывает файл и переводит фокус в редактор (Rust-версия
/// `app:open(path, position)`).
/// `api.editor:open(path, position)`).
///
/// `position` — строка и столбец с нуля, как в диагностике LSP; столбец
/// считается в UTF-16. `None` оставляет курсор там, где он был.
@@ -967,7 +1284,10 @@ impl EditorApi {
Ok(index)
}
/// Lua-версия [`EditorApi::open`]: `app.editor:open("main.go")`.
/// Lua-версия [`EditorApi::open`]: `api.editor:open("main.go")`.
///
/// Первый аргумент — путь: строка `"src/main.rs"` или массив сегментов
/// `{ "src", "main.rs" }`.
///
/// Второй аргумент необязателен:
///
@@ -977,12 +1297,19 @@ impl EditorApi {
///
/// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua.
#[lua(name = "open")]
fn lua_open(&self, path: String, position: mlua::Value) -> mlua::Result<usize> {
fn lua_open(&self, path: mlua::Value, position: mlua::Value) -> mlua::Result<usize> {
let path = parse_path_value(path)?;
if path.as_os_str().is_empty() {
return Err(mlua::Error::RuntimeError(
"api.editor:open: путь не может быть пустым".to_owned(),
));
}
let name = path_to_string(&path);
match parse_open_focus(position)? {
OpenFocus::Keep => self.open(&path),
OpenFocus::Editor(position) => self.open_focused(&path, position),
}
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {name}: {err}")))
}
/// Делает вкладку активной по её номеру в списке [`EditorApi::tabs`].
@@ -1117,8 +1444,11 @@ impl EditorApi {
/// Включает подсветку синтаксиса для вкладки `index`.
///
/// `language` — название языка: `rust`, `go`, `js`, `json` или `python`
/// (принимаются также `rs`, `golang`, `javascript`, `py`; регистр не важен).
/// `language` — название языка: `rust`, `go`, `js`, `json`, `python`,
/// `lua`, `bash`, `yaml`, `make`, `dockerfile`, `sql`, `markdown`, `css`,
/// `c`, `cpp`, `html` или `xml` (принимаются также короткие имена вроде
/// `rs`, `golang`, `javascript`, `py`, `sh`, `yml`, `makefile`, `md`,
/// `c++`; регистр не важен).
/// Подсветка по умолчанию выключена и включается только этим методом.
///
/// Возвращает `false`, если вкладки нет или язык не поддерживается.
@@ -1166,7 +1496,7 @@ impl EditorApi {
/// Пример из Lua:
///
/// ```lua
/// app:run_lsp({
/// api.editor:run_lsp({
/// name = "gopls",
/// cmd = "/usr/local/bin/gopls",
/// args = { "serve" },
@@ -1223,7 +1553,7 @@ impl EditorApi {
/// Передача `nil` вместо функции удаляет обработчик.
///
/// ```lua
/// app:on_lsp_diagnostics(1000, function(servers)
/// api.editor:on_lsp_diagnostics(1000, function(servers)
/// for _, entry in ipairs(servers) do
/// print(entry.server, entry.path, #entry.diagnostics)
/// end
@@ -1279,7 +1609,7 @@ impl EditorApi {
}
}
/// Второй аргумент `app.editor:open`.
/// Второй аргумент `api.editor:open`.
enum OpenFocus {
/// Фокус не меняется.
Keep,
@@ -1287,14 +1617,15 @@ enum OpenFocus {
Editor(Option<(u32, u32)>),
}
/// Разбирает необязательный второй аргумент `app.editor:open`.
/// Разбирает необязательный второй аргумент `api.editor:open`.
fn parse_open_focus(value: mlua::Value) -> mlua::Result<OpenFocus> {
let table = match value {
mlua::Value::Nil => return Ok(OpenFocus::Keep),
mlua::Value::Table(table) => table,
_ => {
return Err(mlua::Error::RuntimeError(
"во втором аргументе app:open нужна таблица {строка, столбец} или {}".to_owned(),
"во втором аргументе api.editor:open нужна таблица {строка, столбец} или {}"
.to_owned(),
));
}
};
@@ -1307,12 +1638,56 @@ fn parse_open_focus(value: mlua::Value) -> mlua::Result<OpenFocus> {
Ok(OpenFocus::Editor(Some((line as u32, column as u32))))
}
_ => Err(mlua::Error::RuntimeError(
"позиция в app:open — два неотрицательных числа: {строка, столбец}".to_owned(),
"позиция в api.editor:open — два неотрицательных числа: {строка, столбец}".to_owned(),
)),
}
}
/// Разбирает Lua-таблицу, переданную в `app:run_lsp`.
/// Разбирает путь из Lua: строка `"foo/bar"` или массив сегментов
/// `{ "foo", "bar" }`. `nil` означает пустой путь (текущий каталог).
fn parse_path_value(value: mlua::Value) -> mlua::Result<PathBuf> {
match value {
mlua::Value::Nil => Ok(PathBuf::new()),
mlua::Value::String(value) => Ok(PathBuf::from(value.to_string_lossy())),
mlua::Value::Table(table) => {
let mut path = PathBuf::new();
for component in table.sequence_values::<mlua::Value>() {
let component = match component? {
mlua::Value::String(value) => value.to_string_lossy(),
mlua::Value::Nil => continue,
_ => {
return Err(mlua::Error::RuntimeError(
"элементы пути должны быть строками".to_owned(),
));
}
};
if !component.is_empty() {
path.push(component);
}
}
Ok(path)
}
_ => Err(mlua::Error::RuntimeError(
"путь должен быть строкой или массивом строк".to_owned(),
)),
}
}
/// Путь в записи с разделителями `/`.
fn path_to_string(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
/// Путь для сообщения об ошибке: пустой путь показывается как текущий каталог.
fn path_label(path: &Path) -> String {
if path.as_os_str().is_empty() {
".".to_owned()
} else {
path_to_string(path)
}
}
/// Разбирает Lua-таблицу, переданную в `api.editor:run_lsp`.
fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
let name: String = table
.get("name")
@@ -1423,7 +1798,8 @@ fn bind_key(
Ok(())
}
/// Разбирает обработчик, переданный в `app:map_key` / `app:map_completion_key`.
/// Разбирает обработчик, переданный в `api.keymap:map_key` /
/// `api.keymap:map_completion_key`.
///
/// `nil` означает «удалить привязку».
fn parse_key_handler(
+62 -17
View File
@@ -190,13 +190,13 @@ pub struct MyApp {
/// Привязки клавиш редактора: действуют дополнительно к глобальным,
/// когда фокус находится в редакторе.
editor_keymap: Rc<RefCell<KeyMap>>,
/// Состояние левой панели, общее с `app.left_panel` в Lua.
/// Состояние левой панели, общее с `api.panels.left` в Lua.
left_panel: PanelHandle,
/// Состояние правой панели, общее с `app.right_panel` в Lua.
/// Состояние правой панели, общее с `api.panels.right` в Lua.
right_panel: PanelHandle,
/// Состояние нижней панели, общее с `app.bottom_panel` в Lua.
/// Состояние нижней панели, общее с `api.panels.bottom` в Lua.
bottom_panel: PanelHandle,
/// Элементы верхней панели, общие с `app.toolbar` в Lua.
/// Элементы верхней панели, общие с `api.panels.toolbar` в Lua.
toolbar: ToolbarHandle,
/// Открытость панелей в предыдущем кадре: по переходу «закрыта →
/// открыта» вид возвращает фокус в дерево, которое было выбрано раньше.
@@ -212,7 +212,7 @@ pub struct MyApp {
displayed_id: u64,
/// Курсор редактора ещё ни разу не получал фокус.
focused_once: bool,
/// Явный запрос `app:open(path, ...)` на фокус в редакторе.
/// Явный запрос `api.editor:open(path, ...)` на фокус в редакторе.
///
/// Применяется повторно после отрисовки панелей: дерево могло отложить
/// собственный запрос фокуса и перехватить его в этом же кадре.
@@ -805,7 +805,7 @@ impl MyApp {
&& let Some(callback) = callback
&& let Err(err) = callback.call::<()>(text)
{
eprintln!("Lua: ошибка в обработчике app:open_input: {err}");
eprintln!("Lua: ошибка в обработчике api.window:open_input: {err}");
}
cx.notify();
@@ -1001,7 +1001,7 @@ impl MyApp {
/// закрывают список и работают с курсором как обычно.
///
/// Все привязки этого слоя приходят из [`KeyMap`], поэтому их можно
/// заменить через `app:map_completion_key`.
/// заменить через `api.keymap:map_completion_key`.
fn handle_completion_key(
&mut self,
keystroke: &Keystroke,
@@ -1420,7 +1420,7 @@ impl MyApp {
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(
"Серверы LSP не запущены. Запустите их из init.lua через app:run_lsp(...).",
"Серверы LSP не запущены. Запустите их из init.lua через api.editor:run_lsp(...).",
),
);
} else {
@@ -1504,6 +1504,9 @@ impl MyApp {
.w_full()
.flex_1()
.min_h_0()
// Тот же токен, что и у активной вкладки: содержимое
// блока продолжает её фон и сливается с полосой вкладок.
.bg(cx.theme().tokens.tab_active)
.child(self.render_tree_block(tree, window, cx)),
);
}
@@ -1580,6 +1583,9 @@ impl MyApp {
if focus_requested {
focus_handle.focus(window, cx);
}
// Выделение делаем заметнее, когда фокус на дереве: так видно,
// какая панель сейчас принимает клавиатуру.
let focused = focus_handle.is_focused(window);
let entity = cx.entity();
let tree_ptr = Rc::as_ptr(tree) as usize;
@@ -1646,7 +1652,11 @@ impl MyApp {
});
let is_selected = selected.as_deref() == Some(row.id.as_str());
let hover_bg = cx.theme().list_hover;
let selected_bg = cx.theme().list_active;
let selected_bg = if focused {
cx.theme().selection
} else {
cx.theme().list_active
};
let mut line = h_flex()
.id(ElementId::Name(
@@ -1764,6 +1774,19 @@ impl MyApp {
fn sync_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let revision = self.workspace.borrow().revision();
if revision == self.synced_revision {
// Текст не менялся, но Lua мог явно попросить фокус редактора
// (`api.editor:focus()`). Такой запрос не должен ждать ревизии.
let focus_after_sync = self.workspace.borrow_mut().take_focus_after_sync();
if let Some(FocusAfterSync::Editor(cursor)) = focus_after_sync {
self.focus_editor_after_render = true;
self.editor.update(cx, |state, cx| {
if let Some(cursor) = cursor {
let cursor = cursor.min(state.value().len());
state.set_selected_range(cursor..cursor, cx);
}
state.focus(window, cx);
});
}
return;
}
self.synced_revision = revision;
@@ -1782,7 +1805,7 @@ impl MyApp {
};
// `None` сохраняет прежнее поведение: любое внешнее изменение само
// возвращает фокус редактору. `app:open` без позиции это отменяет.
// возвращает фокус редактору. `api.editor:open` без позиции это отменяет.
let (focus_editor, focus_cursor) = match focus_after_sync {
Some(FocusAfterSync::Keep) => (false, None),
Some(FocusAfterSync::Editor(cursor)) => (true, cursor),
@@ -1993,7 +2016,7 @@ impl MyApp {
/// Полоса между вкладками и редактором.
///
/// Элементы добавляются сценарием через `app.toolbar`. Левые элементы
/// Элементы добавляются сценарием через `api.panels.toolbar`. Левые элементы
/// выстраиваются от левого края вправо, правые — от правого края влево.
fn render_editor_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let entity = cx.entity();
@@ -2224,7 +2247,7 @@ impl MyApp {
impl Render for MyApp {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Состояние окна нужно `app:is_maximized()` из Lua.
// Состояние окна нужно `api.window:is_maximized()` из Lua.
api::set_window_maximized(window.is_maximized());
// Команды могли накопиться в Lua-обработчике или в стартовом сценарии.
apply_window_commands(window);
@@ -2245,7 +2268,7 @@ impl Render for MyApp {
}
// Панели могут отложить возврат фокуса своему дереву — явный запрос
// `app:open(path, ...)` применяем ещё раз уже после их отрисовки.
// `api.editor:open(path, ...)` применяем ещё раз уже после их отрисовки.
let workspace = self.render_workspace(window, cx).into_any_element();
if self.focus_editor_after_render {
self.focus_editor_after_render = false;
@@ -2290,6 +2313,9 @@ impl Render for MyApp {
if entity.read(cx).prompt.is_some() {
return;
}
// Деревья должны знать о фокусе до глобальных обработчиков:
// Lua проверяет это через `tree:has_focus()`.
sync_tree_focus(&panels, window);
// Сначала всегда срабатывают глобальные привязки.
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, true);
// Затем — привязки редактора или блока, на котором фокус.
@@ -2319,6 +2345,7 @@ impl Render for MyApp {
if entity.read(cx).prompt.is_some() {
return;
}
sync_tree_focus(&panels, window);
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, false);
let focused = run_focused_keymap(
&editor,
@@ -2598,13 +2625,13 @@ fn run_app_command(command: AppCommand) {
editor.close_tab(editor.active_tab_index());
}
AppCommand::ToggleLeftPanel => {
app.left_panel().toggle();
app.panels().left().toggle();
}
AppCommand::ToggleRightPanel => {
app.right_panel().toggle();
app.panels().right().toggle();
}
AppCommand::ToggleBottomPanel => {
app.bottom_panel().toggle();
app.panels().bottom().toggle();
}
}
}
@@ -2651,6 +2678,24 @@ fn run_focused_keymap(
}
}
/// Обновляет признак клавиатурного фокуса у всех деревьев панелей.
///
/// Глобальные Lua-обработчики выполняются раньше привязок блока, поэтому
/// `tree:has_focus()` должен видеть актуальное состояние уже на этом шаге.
fn sync_tree_focus(panels: &[PanelHandle; 3], window: &Window) {
for panel in panels {
let panel = panel.borrow();
for block in panel.blocks() {
let PanelBlock::Tree { tree, .. } = block;
let mut tree = tree.borrow_mut();
let focused = tree
.existing_focus_handle()
.is_some_and(|focus| focus.is_focused(window));
tree.set_focused(focused);
}
}
}
/// Ищет карту клавиш блока, владеющего фокусом.
///
/// Когда у панелей появятся другие виды блоков, каждый из них должен отдавать
@@ -2676,7 +2721,7 @@ fn focused_block_keymap(panels: &[PanelHandle; 3], window: &Window) -> Option<Rc
///
/// Возвращает `true`, если хотя бы одна привязка сработала. Заём карты
/// заканчивается до вызова колбэков: обработчик может сам вызвать
/// `app:map_key` и изменить привязки.
/// `api.keymap:map_key` и изменить привязки.
fn run_keymap(
keymap: &Rc<RefCell<KeyMap>>,
context: KeyContext,
+107 -15
View File
@@ -24,13 +24,26 @@ pub enum Language {
JavaScript,
Json,
Python,
Lua,
Bash,
Yaml,
Make,
Dockerfile,
Sql,
Markdown,
Css,
C,
Cpp,
Html,
Xml,
}
impl Language {
/// Разбирает название языка из API/сценария.
///
/// Основные имена — `rust`, `go`, `js`, `json`, `python`; короткие
/// (`rs`, `golang`, `javascript`, `py`) и регистр тоже принимаются.
/// Основные имена — [`Self::name`]; короткие (`rs`, `golang`,
/// `javascript`, `py`, `sh`, `yml`, `makefile`, `md`, `c++`, …)
/// и регистр тоже принимаются.
pub fn from_name(name: &str) -> Option<Self> {
match name.trim().to_ascii_lowercase().as_str() {
"rust" | "rs" => Some(Self::Rust),
@@ -38,6 +51,18 @@ impl Language {
"js" | "javascript" | "node" => Some(Self::JavaScript),
"json" => Some(Self::Json),
"python" | "py" => Some(Self::Python),
"lua" => Some(Self::Lua),
"bash" | "sh" | "shell" | "shellscript" => Some(Self::Bash),
"yaml" | "yml" => Some(Self::Yaml),
"make" | "makefile" | "mk" => Some(Self::Make),
"dockerfile" | "containerfile" | "docker" => Some(Self::Dockerfile),
"sql" => Some(Self::Sql),
"markdown" | "md" | "mdx" => Some(Self::Markdown),
"css" => Some(Self::Css),
"c" | "h" => Some(Self::C),
"cpp" | "c++" | "cxx" | "cc" | "hpp" | "hxx" | "hh" => Some(Self::Cpp),
"html" | "htm" => Some(Self::Html),
"xml" | "xsd" | "xsl" | "xslt" | "svg" => Some(Self::Xml),
_ => None,
}
}
@@ -50,6 +75,18 @@ impl Language {
Self::JavaScript => "js",
Self::Json => "json",
Self::Python => "python",
Self::Lua => "lua",
Self::Bash => "bash",
Self::Yaml => "yaml",
Self::Make => "make",
Self::Dockerfile => "dockerfile",
Self::Sql => "sql",
Self::Markdown => "markdown",
Self::Css => "css",
Self::C => "c",
Self::Cpp => "cpp",
Self::Html => "html",
Self::Xml => "xml",
}
}
@@ -60,37 +97,88 @@ impl Language {
/// в Lua-API.
pub fn syntax_name(self) -> &'static str {
match self {
Self::Rust => "rust",
Self::Go => "go",
Self::JavaScript => "javascript",
Self::Json => "json",
Self::Python => "python",
_ => self.name(),
}
}
/// Идентификатор языка для LSP.
///
/// Совпадает с [`Self::syntax_name`], кроме Bash и Make: грамматики
/// называются `bash` и `make`, а `languageId` в LSP — `shellscript` и
/// `makefile`.
pub fn lsp_language_id(self) -> &'static str {
match self {
Self::Bash => "shellscript",
Self::Make => "makefile",
_ => self.syntax_name(),
}
}
/// Определяет язык подсветки по имени файла.
///
/// Используется при открытии файла с диска (`app:open(path)`), чтобы
/// Используется при открытии файла с диска (`api.editor:open(path)`), чтобы
/// подсветка включалась автоматически там, где для неё есть грамматика.
/// Кроме расширений учитываются файлы без расширения (`Makefile`,
/// `Dockerfile`, `Containerfile`).
pub fn from_path(path: &str) -> Option<Self> {
let extension = path.rsplit_once('.')?.1.to_ascii_lowercase();
match extension.as_str() {
if let Some(language) = special_file_language(path) {
return Some(language);
}
let file_name = file_name(path);
let (_, extension) = file_name.rsplit_once('.')?;
Self::from_extension(extension)
}
/// Язык по расширению файла без учёта регистра.
fn from_extension(extension: &str) -> Option<Self> {
match extension.to_ascii_lowercase().as_str() {
"rs" => Some(Self::Rust),
"go" => Some(Self::Go),
"js" | "mjs" | "cjs" | "jsx" => Some(Self::JavaScript),
"json" => Some(Self::Json),
"py" => Some(Self::Python),
"lua" => Some(Self::Lua),
"sh" | "bash" => Some(Self::Bash),
"yaml" | "yml" => Some(Self::Yaml),
"mk" => Some(Self::Make),
"dockerfile" => Some(Self::Dockerfile),
"sql" => Some(Self::Sql),
"md" | "markdown" | "mdx" => Some(Self::Markdown),
"css" => Some(Self::Css),
"c" | "h" => Some(Self::C),
"cc" | "cpp" | "cxx" | "c++" | "hpp" | "hxx" | "hh" => Some(Self::Cpp),
"html" | "htm" => Some(Self::Html),
"xml" | "xsd" | "xsl" | "xslt" | "svg" => Some(Self::Xml),
_ => None,
}
}
}
/// Имя файла без каталогов.
fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().unwrap_or(path)
}
/// Языки, у которых имя файла не содержит расширения.
fn special_file_language(path: &str) -> Option<Language> {
match file_name(path).to_ascii_lowercase().as_str() {
"makefile" | "gnumakefile" | "bsdmakefile" => Some(Language::Make),
"dockerfile" | "containerfile" => Some(Language::Dockerfile),
_ => None,
}
}
/// Язык LSP по расширению имени файла.
///
/// Нужен для документов, у которых подсветка ещё не включена: LSP-сервер
/// выбирается и получает `languageId` независимо от tree-sitter.
pub fn language_id_from_path(name: &str) -> &'static str {
let extension = name
if let Some(language) = special_file_language(name) {
return language.lsp_language_id();
}
let extension = file_name(name)
.rsplit_once('.')
.map(|(_, extension)| extension.to_ascii_lowercase());
@@ -103,15 +191,19 @@ pub fn language_id_from_path(name: &str) -> &'static str {
Some("tsx") => "typescriptreact",
Some("json") => "json",
Some("py") => "python",
Some("md" | "markdown") => "markdown",
Some("md" | "markdown" | "mdx") => "markdown",
Some("toml") => "toml",
Some("yaml" | "yml") => "yaml",
Some("lua") => "lua",
Some("c" | "h") => "c",
Some("cc" | "cpp" | "cxx" | "hpp" | "hh") => "cpp",
Some("cc" | "cpp" | "cxx" | "c++" | "hpp" | "hxx" | "hh") => "cpp",
Some("sh" | "bash") => "shellscript",
Some("mk") => "makefile",
Some("dockerfile") => "dockerfile",
Some("sql") => "sql",
Some("html" | "htm") => "html",
Some("css") => "css",
Some("xml" | "xsd" | "xsl" | "xslt" | "svg") => "xml",
_ => "plaintext",
}
}
@@ -169,7 +261,7 @@ impl Document {
/// по расширению имени вкладки — так LSP работает и без `set_language`.
pub fn lsp_language_id(&self) -> String {
if let Some(language) = self.language {
return language.syntax_name().to_owned();
return language.lsp_language_id().to_owned();
}
language_id_from_path(&self.name).to_owned()
}
@@ -190,7 +282,7 @@ impl Document {
/// Что сделать с фокусом редактора после синхронизации модели с видом.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FocusAfterSync {
/// Оставить фокус там, где он был (`app:open` без позиции).
/// Оставить фокус там, где он был (`api.editor:open` без позиции).
Keep,
/// Перевести фокус в редактор и, если задано, поставить курсор.
///
@@ -308,7 +400,7 @@ impl Workspace {
/// Открывает файл с диска как вкладку и делает её активной.
///
/// Если вкладка с таким именем уже открыта и не содержит несохранённых
/// изменений, её содержимое обновляется из файла — повторный `app:open`
/// изменений, её содержимое обновляется из файла — повторный `api.editor:open`
/// не создаёт дубликат. Грязная вкладка не трогается: открывается новая.
pub fn open_file(
&mut self,
+1 -1
View File
@@ -1,7 +1,7 @@
//! Иконки на поле редактора (gutter).
//!
//! Gutter — узкая колонка слева от редактора. Сценарий ставит на строку
//! иконку через `app:set_gutter_icon`, а вид рисует её на той же высоте, что и
//! иконку через `api.editor:set_gutter_icon`, а вид рисует её на той же высоте, что и
//! строка в редакторе, учитывая прокрутку.
//!
//! Состояние принадлежит документу: у каждой вкладки свои иконки, и при
+1 -1
View File
@@ -1,7 +1,7 @@
//! Иконки вкладок.
//!
//! Имена совпадают с названиями Lucide (`file`, `file-code`, `book-open`) —
//! именно их принимает `app:set_tab_icon(index, name)`. Неизвестное имя не
//! именно их принимает `api.editor:set_tab_icon(index, name)`. Неизвестное имя не
//! подменяется молча: API возвращает `false`, а вкладка продолжает показывать
//! иконку по умолчанию.
+3 -3
View File
@@ -460,7 +460,7 @@ impl KeyMap {
///
/// Заём `KeyMap` здесь заканчивается: вызывать Lua нужно уже после
/// возврата из метода, потому что обработчик может сам менять привязки
/// через `app:map_key`.
/// через `api.keymap:map_key`.
pub fn handlers_for(
&self,
context: KeyContext,
@@ -480,8 +480,8 @@ impl KeyMap {
/// Назначает встроенные привязки приложения.
///
/// Они проходят через тот же `KeyMap`, поэтому `app:map_key` и
/// `app:map_completion_key` могут их заменить или удалить.
/// Они проходят через тот же `KeyMap`, поэтому `api.keymap:map_key` и
/// `api.keymap:map_completion_key` могут их заменить или удалить.
pub fn install_defaults(&mut self) {
for spec in ["<C-s>", "<D-s>"] {
self.set(
+2 -2
View File
@@ -36,7 +36,7 @@ use serde_json::{Value, json};
/// Диагностика одного сервера для одного документа.
///
/// Эту структуру Lua получает в обработчике `app:on_lsp_diagnostics(...)`:
/// Эту структуру Lua получает в обработчике `api.editor:on_lsp_diagnostics(...)`:
/// она содержит имя приславшего сервера, URI и путь файла, а также список
/// сообщений.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -580,7 +580,7 @@ impl LspManager {
/// Снимок всей диагностики всех серверов.
///
/// Используется обработчиком `app:on_lsp_diagnostics(...)`: он получает
/// Используется обработчиком `api.editor:on_lsp_diagnostics(...)`: он получает
/// файлы, серверы и сообщения целиком, а не только для активной вкладки.
pub fn diagnostics_snapshot(&self) -> Vec<DocumentDiagnostics> {
self.diagnostics.snapshot()
+4 -1
View File
@@ -17,6 +17,7 @@ mod lsp;
mod panel;
mod prompt;
mod script;
mod syntax;
mod toolbar;
mod tree;
@@ -41,6 +42,8 @@ fn main() {
application().with_assets(assets::AllAssets).run(|cx| {
// Инициализация темы, шрифтов и компонентов обязательна до создания окон.
init(cx);
// Грамматики Dockerfile и XML в gpui-kit не входят — регистрируем их сами.
syntax::register_extra_grammars();
open_window(window_options(), cx, |window, cx| {
cx.new(|cx| MyApp::new(window, cx).with_startup_script())
@@ -52,7 +55,7 @@ fn main() {
/// Применяет аргументы запуска до создания окна.
///
/// `--config` задаёт каталог `init.lua`, а первый позиционный аргумент — файл,
/// который [`MyApp`] откроет через `app:open(path)` после выполнения сценария.
/// который [`MyApp`] откроет через `api.editor:open(path)` после выполнения сценария.
/// Рабочий каталог процесса и пути сохранения файлов не меняются.
fn apply_command_line() -> Result<(), String> {
let command_line = constants::parse_command_line(std::env::args_os().skip(1))?;
+2 -2
View File
@@ -1,7 +1,7 @@
//! Состояние боковых панелей окна.
//!
//! Модель общая с API: Lua-сценарии меняют [`PanelState`] через `app.right_panel`
//! и `app.bottom_panel`, а вид [`MyApp`](crate::app::MyApp) читает его при
//! Модель общая с API: Lua-сценарии меняют [`PanelState`] через `api.panels.right`
//! и `api.panels.bottom`, а вид [`MyApp`](crate::app::MyApp) читает его при
//! отрисовке. Как и документы, состояние лежит в `Rc<RefCell<_>>`, чтобы вид и
//! API видели одни и те же панели.
//!
+4 -4
View File
@@ -1,6 +1,6 @@
//! Плавающее поле ввода, открываемое из Lua.
//!
//! Сценарий вызывает `app:open_input{ title = "Имя", callback = function(text) end }`.
//! Сценарий вызывает `api.window:open_input{ title = "Имя", callback = function(text) end }`.
//! Запрос складывается в общее состояние API, а вид при следующем кадре
//! создаёт поле ввода, показывает его чуть выше центра и забирает фокус.
//! Пока окно открыто, сочетания клавиш приложения не работают: текст набируется
@@ -24,7 +24,7 @@ pub struct PromptRequest {
}
impl PromptRequest {
/// Разбирает Lua-таблицу опций `app:open_input`.
/// Разбирает Lua-таблицу опций `api.window:open_input`.
///
/// # Errors
/// Возвращает ошибку Lua, если текстовое поле не строка или `callback`
@@ -46,7 +46,7 @@ impl PromptRequest {
mlua::Value::Function(callback) => Some(callback),
_ => {
return Err(mlua::Error::RuntimeError(
"app:open_input: поле callback должно быть функцией".to_owned(),
"api.window:open_input: поле callback должно быть функцией".to_owned(),
));
}
};
@@ -68,7 +68,7 @@ fn optional_text(options: &mlua::Table, field: &str) -> mlua::Result<Option<Stri
Ok((!text.is_empty()).then_some(text))
}
_ => Err(mlua::Error::RuntimeError(format!(
"app:open_input: поле {field} должно быть строкой"
"api.window:open_input: поле {field} должно быть строкой"
))),
}
}
+12 -10
View File
@@ -3,9 +3,10 @@
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`script_dir`]
//! (см. [`ScriptEngine::run_startup`]). Скриптам доступны:
//!
//! * объект `app` — управление окном и панелями (методы [`AppApi`], вызываются
//! через двоеточие: `app:next_tab()`); во вложенном `app.editor` живёт API
//! редактора ([`EditorApi`](crate::api::EditorApi)): `app.editor:save()`;
//! * объект `api` — управление приложением ([`AppApi`]). Он разбит на группы:
//! `api.editor` (редактор), `api.panels` (панели), `api.path` (файловая
//! система), `api.window` (окно и поле ввода) и `api.keymap` (глобальные
//! привязки); вызов — через двоеточие: `api.editor:save()`;
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`script_dir`]
//! (или по абсолютному пути) в общем глобальном окружении;
//! * стандартный `require`, который также ищет модули в [`script_dir`].
@@ -102,7 +103,7 @@ impl ScriptEngine {
lua: Lua::new(),
script_dir: directory.as_ref().to_path_buf(),
};
engine.expose_app_api(api)?;
engine.expose_api(api)?;
engine.configure_require()?;
Ok(engine)
}
@@ -121,15 +122,16 @@ impl ScriptEngine {
run_file(&self.lua, path).map(|_| ())
}
/// Публикует объект `app` с функциями управления приложением.
/// Публикует объект `api` с функциями управления приложением.
///
/// Все методы [`AppApi`] из `#[mlua::userdata_impl]`-блока доступны как
/// методы userdata: `app:next_tab()`, `app:set_active_text("...")`.
/// Номера вкладок всюду нумеруются с нуля.
fn expose_app_api(&self, api: AppApi) -> mlua::Result<()> {
/// Все методы объектов [`AppApi`] из `#[mlua::userdata_impl]`-блоков
/// доступны как методы userdata: `api.editor:save()`,
/// `api.path:list("src")`, `api.window:close()`. Номера вкладок всюду
/// нумеруются с нуля.
fn expose_api(&self, api: AppApi) -> mlua::Result<()> {
self.lua
.globals()
.set("app", self.lua.create_userdata(api)?)?;
.set("api", self.lua.create_userdata(api)?)?;
Ok(())
}
+62
View File
@@ -0,0 +1,62 @@
//! Дополнительные грамматики tree-sitter.
//!
//! Большинство языков приходит из `gpui-kit` готовыми флагами `tree-sitter-*`.
//! Для Dockerfile и XML таких флагов нет, поэтому их грамматики регистрируются
//! в общем `LanguageRegistry` вручную. Регистрация выполняется до первой
//! отрисовки редактора и повторный вызов безопасен.
use gpui_kit::component::highlighter::{GrammarConfig, LanguageRegistry};
/// Регистрирует грамматики, которых нет в `gpui-kit`.
pub fn register_extra_grammars() {
let registry = LanguageRegistry::singleton();
// Команды, строки и heredoc Dockerfile/Containerfile; shell-команды
// внутри `RUN` подсвечиваются через инъекцию грамматики bash.
registry.register(
"dockerfile",
&GrammarConfig::new(
"dockerfile",
tree_sitter_containerfile::LANGUAGE.into(),
Vec::new(),
tree_sitter_containerfile::HIGHLIGHTS_QUERY,
tree_sitter_containerfile::INJECTIONS_QUERY,
"",
),
);
registry.register(
"xml",
&GrammarConfig::new(
"xml",
tree_sitter_xml::LANGUAGE_XML.into(),
Vec::new(),
tree_sitter_xml::XML_HIGHLIGHT_QUERY,
"",
"",
),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extra_grammars_are_registered_with_a_highlight_query() {
register_extra_grammars();
let registry = LanguageRegistry::singleton();
for name in ["dockerfile", "xml"] {
let config = registry
.language(name)
.unwrap_or_else(|| panic!("язык {name:?} не зарегистрирован"));
assert_eq!(config.name, name);
assert!(config.has_grammar(), "у языка {name:?} нет грамматики");
assert!(
!config.highlights.is_empty(),
"у языка {name:?} нет запроса подсветки"
);
}
}
}
+477 -137
View File
@@ -36,12 +36,12 @@ fn lua_lsp_api_lists_no_servers_and_validates_config() {
std::fs::write(
&script,
r#"
assert(#app.editor:lsp_servers() == 0)
assert(app.editor:stop_lsp("missing") == false)
assert(app.editor:restart_lsp("missing") == false)
assert(#api.editor:lsp_servers() == 0)
assert(api.editor:stop_lsp("missing") == false)
assert(api.editor:restart_lsp("missing") == false)
-- Без cmd сервер не запускается, а ошибка объясняет, чего не хватает.
local ok, err = pcall(function() app.editor:run_lsp({ name = "broken" }) end)
local ok, err = pcall(function() api.editor:run_lsp({ name = "broken" }) end)
assert(not ok)
assert(tostring(err):find("cmd", 1, true), tostring(err))
"#,
@@ -184,14 +184,14 @@ fn lua_script_can_set_tab_icons() {
std::fs::write(
&script,
r#"
assert(app.editor:tab_icon(0) == "file")
assert(app.editor:set_tab_icon(0, "book-open"))
assert(app.editor:tab_icon(0) == "book-open")
app.editor:open_tab()
assert(app.editor:tabs()[2].icon == "file")
assert(not app.editor:set_tab_icon(0, "no-such-icon"))
assert(app.editor:clear_tab_icon(0))
assert(app.editor:tab_icon(0) == "file")
assert(api.editor:tab_icon(0) == "file")
assert(api.editor:set_tab_icon(0, "book-open"))
assert(api.editor:tab_icon(0) == "book-open")
api.editor:open_tab()
assert(api.editor:tabs()[2].icon == "file")
assert(not api.editor:set_tab_icon(0, "no-such-icon"))
assert(api.editor:clear_tab_icon(0))
assert(api.editor:tab_icon(0) == "file")
"#,
)
.unwrap();
@@ -253,26 +253,26 @@ fn lua_script_can_set_gutter_icons() {
std::fs::write(
&script,
r##"
assert(app.editor:gutter_icon(0) == nil)
assert(app.editor:set_gutter_icon(4, "circle"))
assert(app.editor:gutter_icon(4) == "circle")
assert(app.editor:gutter_color(4) == nil)
assert(api.editor:gutter_icon(0) == nil)
assert(api.editor:set_gutter_icon(4, "circle"))
assert(api.editor:gutter_icon(4) == "circle")
assert(api.editor:gutter_color(4) == nil)
assert(app.editor:set_gutter_icon(4, "play", "#00ff00"))
assert(app.editor:gutter_icon(4) == "play")
assert(app.editor:gutter_color(4) == "#00ff00")
assert(api.editor:set_gutter_icon(4, "play", "#00ff00"))
assert(api.editor:gutter_icon(4) == "play")
assert(api.editor:gutter_color(4) == "#00ff00")
assert(not app.editor:set_gutter_icon(1, "no-such-icon"))
assert(not app.editor:set_gutter_icon(1, "circle", "oops"))
assert(app.editor:gutter_icon(1) == nil)
assert(not api.editor:set_gutter_icon(1, "no-such-icon"))
assert(not api.editor:set_gutter_icon(1, "circle", "oops"))
assert(api.editor:gutter_icon(1) == nil)
assert(app.editor:clear_gutter_icon(4))
assert(not app.editor:clear_gutter_icon(4))
assert(app.editor:gutter_icon(4) == nil)
assert(api.editor:clear_gutter_icon(4))
assert(not api.editor:clear_gutter_icon(4))
assert(api.editor:gutter_icon(4) == nil)
app.editor:set_gutter_icon(0, "circle")
app.editor:clear_gutter()
assert(app.editor:gutter_icon(0) == nil)
api.editor:set_gutter_icon(0, "circle")
api.editor:clear_gutter()
assert(api.editor:gutter_icon(0) == nil)
"##,
)
.unwrap();
@@ -344,10 +344,10 @@ fn window_commands_are_queued_for_the_view() {
// Очередь могла остаться от другого теста на этом потоке — очищаем.
let _ = drain_window_commands();
api.minimize();
api.toggle_maximize();
api.set_maximized(false);
api.close();
api.window().minimize();
api.window().toggle_maximize();
api.window().set_maximized(false);
api.window().close();
assert_eq!(
drain_window_commands(),
@@ -374,26 +374,26 @@ fn panel_api_opens_closes_and_toggles_the_same_panel_state() {
assert!(!right.borrow().is_open());
assert!(!bottom.borrow().is_open());
api.left_panel().open();
api.panels().left().open();
assert!(left.borrow().is_open());
assert!(api.left_panel().is_open());
api.left_panel().close();
assert!(api.panels().left().is_open());
api.panels().left().close();
assert!(!left.borrow().is_open());
api.right_panel().open();
api.panels().right().open();
assert!(right.borrow().is_open());
assert!(api.right_panel().is_open());
assert!(api.panels().right().is_open());
// toggle возвращает новое состояние и не трогает вторую панель.
assert!(!api.right_panel().toggle());
assert!(!api.panels().right().toggle());
assert!(!right.borrow().is_open());
assert!(api.right_panel().toggle());
assert!(api.panels().right().toggle());
assert!(right.borrow().is_open());
api.right_panel().close();
api.panels().right().close();
assert!(!right.borrow().is_open());
api.bottom_panel().open();
api.panels().bottom().open();
assert!(bottom.borrow().is_open());
assert!(!right.borrow().is_open());
}
@@ -411,25 +411,25 @@ fn lua_script_can_control_panels() {
std::fs::write(
&script,
r#"
app.left_panel:open()
assert(app.left_panel:is_open())
app.left_panel:close()
assert(not app.left_panel:is_open())
assert(app.left_panel:toggle() == true)
api.panels.left:open()
assert(api.panels.left:is_open())
api.panels.left:close()
assert(not api.panels.left:is_open())
assert(api.panels.left:toggle() == true)
app.right_panel:open()
assert(app.right_panel:is_open())
assert(not app.bottom_panel:is_open())
api.panels.right:open()
assert(api.panels.right:is_open())
assert(not api.panels.bottom:is_open())
app.bottom_panel:open()
app.right_panel:toggle()
assert(not app.right_panel:is_open())
api.panels.bottom:open()
api.panels.right:toggle()
assert(not api.panels.right:is_open())
assert(app.bottom_panel:toggle() == false)
app.bottom_panel:close()
assert(not app.bottom_panel:is_open())
assert(api.panels.bottom:toggle() == false)
api.panels.bottom:close()
assert(not api.panels.bottom:is_open())
assert(app.right_panel:toggle() == true)
assert(api.panels.right:toggle() == true)
"#,
)
.expect("panels.lua");
@@ -457,24 +457,24 @@ fn lua_script_configures_toolbar_and_hides_items_by_id() {
std::fs::write(
&script,
r##"
app.toolbar:add_label("Готово", nil, "#808080", "status", "left")
app.toolbar:add_button("Запустить", "play", nil, "run", "right", function() end)
app.toolbar:add_label("UTF-8", nil, nil, "encoding", "right")
api.panels.toolbar:add_label("Готово", nil, "#808080", "status", "left")
api.panels.toolbar:add_button("Запустить", "play", nil, "run", "right", function() end)
api.panels.toolbar:add_label("UTF-8", nil, nil, "encoding", "right")
assert(app.toolbar:hide("run") == true)
assert(app.toolbar:show("run") == true)
assert(app.toolbar:hide("missing") == false)
assert(app.toolbar:show("missing") == false)
assert(api.panels.toolbar:hide("run") == true)
assert(api.panels.toolbar:show("run") == true)
assert(api.panels.toolbar:hide("missing") == false)
assert(api.panels.toolbar:show("missing") == false)
-- Неизвестная сторона и иконка — ошибка, а не молчаливый пропуск.
assert(not pcall(function()
app.toolbar:add_label("x", nil, nil, "bad", "middle")
api.panels.toolbar:add_label("x", nil, nil, "bad", "middle")
end))
assert(not pcall(function()
app.toolbar:add_label("x", "not-an-icon", nil, "bad-icon", "left")
api.panels.toolbar:add_label("x", "not-an-icon", nil, "bad-icon", "left")
end))
assert(not pcall(function()
app.toolbar:add_label("x", nil, "not-a-color", "bad-color", "left")
api.panels.toolbar:add_label("x", nil, "not-a-color", "bad-color", "left")
end))
"##,
)
@@ -510,7 +510,7 @@ fn lua_script_configures_toolbar_and_hides_items_by_id() {
fn toolbar_replaces_items_with_the_same_id() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let toolbar_api = api.toolbar();
let toolbar_api = api.panels().toolbar();
let toolbar = api.toolbar_handle();
toolbar_api
@@ -547,7 +547,7 @@ fn lua_tree_block_appends_sorts_and_removes_nodes() {
std::fs::write(
&script,
r#"
local tree = app.left_panel:add_tree()
local tree = api.panels.left:add_tree()
tree:append({
{ id = "b", label = "banana", expandable = false },
{ id = "src", label = "src", icon = "folder", expandable = true, expanded = true },
@@ -562,6 +562,11 @@ fn lua_tree_block_appends_sorts_and_removes_nodes() {
tree:on_click(function(id) clicked = clicked + 1 end)
assert(toggled == 0 and clicked == 0)
assert(tree:remove("missing") == false)
assert(tree:focus() == true)
assert(tree:has_focus() == false)
assert(tree:focus("src") == true)
assert(tree:focus("missing") == false)
"#,
)
.expect("tree.lua");
@@ -608,7 +613,7 @@ fn lua_bottom_panel_tree_has_a_tab_title_and_clears() {
std::fs::write(
&script,
r#"
diag_tree = app.bottom_panel:add_tree("Диагностика")
diag_tree = api.panels.bottom:add_tree("Диагностика")
diag_tree:append({
{ id = "a", label = "a", expandable = true, expanded = true },
}, "")
@@ -653,7 +658,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() {
std::fs::write(
&script,
r#"
app.editor:on_lsp_diagnostics(0, function(servers)
api.editor:on_lsp_diagnostics(0, function(servers)
assert(#servers == 1, #servers)
local entry = servers[1]
assert(entry.server == "test")
@@ -670,7 +675,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() {
assert(first.message == "плохо")
assert(entry.diagnostics[2].severity_name == "warning")
app.editor:open_tab()
api.editor:open_tab()
end)
"#,
)
@@ -729,13 +734,15 @@ fn lua_app_open_input_queues_options() {
std::fs::write(
&script,
r#"
app:open_input({ title = "Имя", placeholder = "main.rs", callback = function(text) end })
api.window:open_input({ title = "Имя", placeholder = "main.rs", callback = function(text) end })
"#,
)
.expect("input.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine.run_file(&script).expect("app:open_input с опциями");
engine
.run_file(&script)
.expect("api.window:open_input с опциями");
let request = requests.borrow_mut().take().expect("запрос на поле ввода");
assert_eq!(request.title.as_deref(), Some("Имя"));
@@ -743,8 +750,8 @@ fn lua_app_open_input_queues_options() {
assert!(request.callback.is_some());
// Без аргументов окно состоит из одной строки ввода, колбэка нет.
std::fs::write(&script, "app:open_input()\n").expect("input.lua");
engine.run_file(&script).expect("app:open_input()");
std::fs::write(&script, "api.window:open_input()\n").expect("input.lua");
engine.run_file(&script).expect("api.window:open_input()");
let request = requests.borrow_mut().take().expect("запрос без опций");
assert!(request.title.is_none());
assert!(request.placeholder.is_none());
@@ -754,9 +761,9 @@ fn lua_app_open_input_queues_options() {
std::fs::write(
&script,
r#"
assert(not pcall(function() app:open_input({ title = 1 }) end))
assert(not pcall(function() app:open_input({ placeholder = 1 }) end))
assert(not pcall(function() app:open_input({ callback = "x" }) end))
assert(not pcall(function() api.window:open_input({ title = 1 }) end))
assert(not pcall(function() api.window:open_input({ placeholder = 1 }) end))
assert(not pcall(function() api.window:open_input({ callback = "x" }) end))
"#,
)
.expect("input.lua");
@@ -768,7 +775,7 @@ fn lua_app_open_input_queues_options() {
}
#[test]
fn lua_list_dir_returns_sorted_entries_with_paths() {
fn lua_path_list_returns_sorted_entries_with_paths() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
@@ -785,7 +792,7 @@ fn lua_list_dir_returns_sorted_entries_with_paths() {
&script,
format!(
r#"
local entries = app:list_dir({quoted})
local entries = api.path:list({quoted})
assert(#entries == 3, #entries)
assert(entries[1].name == "A.txt")
assert(entries[2].name == "b.txt")
@@ -793,13 +800,108 @@ fn lua_list_dir_returns_sorted_entries_with_paths() {
assert(entries[1].is_dir == false)
assert(entries[3].is_dir == true)
assert(entries[1].id == {quoted} .. "/A.txt")
-- Массив сегментов — та же директория, что и строка с путём.
assert(#api.path:list({{ {quoted} }}) == 3)
assert(#api.path:list({{ {quoted}, "sub" }}) == 0)
"#
),
)
.expect("list.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine.run_file(&script).expect("app:list_dir");
engine.run_file(&script).expect("api.path:list");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&scripts);
}
#[test]
fn lua_path_mkdir_accepts_segments_and_string() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let dir = temp_script_dir("mkdir");
let root = dir.join("foo");
let quoted = format!("{:?}", root.to_string_lossy());
let scripts = temp_script_dir("mkdir-script");
let script = scripts.join("mkdir.lua");
std::fs::write(
&script,
format!(
r#"
local created = api.path:mkdir({{ {quoted}, "bar", "", "baz" }})
assert(created == {quoted} .. "/bar/baz", created)
assert(api.path:mkdir({{}}) == "")
-- Строка с разделителями — тот же путь, что и массив.
local by_string = api.path:mkdir({quoted} .. "/bar/baz/qux")
assert(by_string == {quoted} .. "/bar/baz/qux", by_string)
"#
),
)
.expect("mkdir.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine.run_file(&script).expect("api.path:mkdir");
assert!(dir.join("foo/bar/baz").is_dir());
assert!(dir.join("foo/bar/baz/qux").is_dir());
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&scripts);
}
#[test]
fn lua_path_touch_read_write_and_remove_files_and_directories() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let dir = temp_script_dir("path-fs");
let quoted = format!("{:?}", dir.to_string_lossy());
let scripts = temp_script_dir("path-fs-script");
let script = scripts.join("fs.lua");
std::fs::write(
&script,
format!(
r#"
local root = {quoted}
local work = api.path:mkdir({{ root, "work" }})
assert(work == root .. "/work", work)
local created = api.path:touch({{ work, "a.txt" }})
assert(created == work .. "/a.txt", created)
assert(api.path:read(work .. "/a.txt") == "")
api.path:write(work .. "/a.txt", "one")
assert(api.path:read({{ work, "a.txt" }}) == "one")
api.path:write(work .. "/a.txt", " two", true)
assert(api.path:read(work .. "/a.txt") == "one two")
api.path:write(work .. "/a.txt", "reset")
assert(api.path:read(work .. "/a.txt") == "reset")
assert(api.path:remove(work .. "/a.txt") == true)
assert(api.path:remove(work .. "/a.txt") == false)
api.path:mkdir({{ work, "tree", "deep" }})
assert(api.path:remove({{ work, "tree" }}) == true)
assert(#api.path:list(work) == 0)
"#
),
)
.expect("fs.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine
.run_file(&script)
.expect("api.path touch/read/write/remove");
assert!(!dir.join("work/a.txt").exists());
assert!(!dir.join("work/tree").exists());
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&scripts);
@@ -829,13 +931,13 @@ fn lua_script_can_include_files_and_control_the_app() {
let main = dir.join("main.lua");
std::fs::write(
&helper,
"function lua_config() app.editor:set_active_text(\"from lua\") end\n",
"function lua_config() api.editor:set_active_text(\"from lua\") end\n",
)
.expect("helper.lua");
std::fs::write(
&main,
format!(
"include({:?})\nlua_config()\napp.editor:open_tab()\napp.editor:set_language(1, \"python\")\n",
"include({:?})\nlua_config()\napi.editor:open_tab()\napi.editor:set_language(1, \"python\")\n",
helper.to_string_lossy()
),
)
@@ -853,12 +955,12 @@ fn lua_script_can_include_files_and_control_the_app() {
assert_eq!(
state.active_index(),
1,
"app.editor:open_tab() не переключил вкладку"
"api.editor:open_tab() не переключил вкладку"
);
assert_eq!(
state.documents()[1].language,
Some(Language::Python),
"app.editor:set_language() не включил подсветку из Lua"
"api.editor:set_language() не включил подсветку из Lua"
);
let _ = std::fs::remove_dir_all(&dir);
@@ -905,12 +1007,27 @@ fn lua_open_controls_focus_and_cursor() {
let plain = dir.join("plain.lua");
std::fs::write(
&plain,
format!("app.editor:open({:?})\n", path.to_string_lossy()),
format!("api.editor:open({:?})\n", path.to_string_lossy()),
)
.unwrap();
engine
.run_file(&plain)
.expect("app.editor:open без позиции");
.expect("api.editor:open без позиции");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Keep)
);
// Первый аргумент принимает и массив сегментов пути.
let array = dir.join("array.lua");
std::fs::write(
&array,
format!("api.editor:open({{ {:?} }})\n", path.to_string_lossy()),
)
.unwrap();
engine
.run_file(&array)
.expect("api.editor:open с массивом пути");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Keep)
@@ -920,12 +1037,12 @@ fn lua_open_controls_focus_and_cursor() {
let focus = dir.join("focus.lua");
std::fs::write(
&focus,
format!("app.editor:open({:?}, {{}})\n", path.to_string_lossy()),
format!("api.editor:open({:?}, {{}})\n", path.to_string_lossy()),
)
.unwrap();
engine
.run_file(&focus)
.expect("app.editor:open с пустой таблицей");
.expect("api.editor:open с пустой таблицей");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(None))
@@ -935,10 +1052,10 @@ fn lua_open_controls_focus_and_cursor() {
let at = dir.join("at.lua");
std::fs::write(
&at,
format!("app.editor:open({:?}, {{2, 5}})\n", path.to_string_lossy()),
format!("api.editor:open({:?}, {{2, 5}})\n", path.to_string_lossy()),
)
.unwrap();
engine.run_file(&at).expect("app.editor:open с позицией");
engine.run_file(&at).expect("api.editor:open с позицией");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(Some(19)))
@@ -948,6 +1065,29 @@ fn lua_open_controls_focus_and_cursor() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_editor_focus_requests_focus_without_changing_documents() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
workspace.borrow_mut().set_active_text("unchanged");
let dir = temp_script_dir("editor-focus");
let script = dir.join("focus.lua");
std::fs::write(&script, "api.editor:focus()\n").expect("focus.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine.run_file(&script).expect("api.editor:focus");
assert_eq!(
workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(None))
);
assert_eq!(workspace.borrow().active().content, "unchanged");
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn api_open_missing_file_creates_an_empty_buffer() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -980,14 +1120,14 @@ fn lua_app_open_opens_a_file_and_returns_its_tab() {
std::fs::write(
&script,
format!(
"local index = app.editor:open({:?})\nassert(index == app.editor:active_tab())\n",
"local index = api.editor:open({:?})\nassert(index == api.editor:active_tab())\n",
path.to_string_lossy()
),
)
.unwrap();
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
engine.run_file(&script).expect("app.editor:open из Lua");
engine.run_file(&script).expect("api.editor:open из Lua");
let state = workspace.borrow();
assert_eq!(state.active().name, path.to_string_lossy());
@@ -1007,7 +1147,7 @@ fn lua_engine_loads_init_from_a_custom_config_dir() {
let dir = temp_script_dir("config-dir");
std::fs::write(
dir.join("helper.lua"),
"function config_value() app.editor:set_active_text(\"custom config\") end\n",
"function config_value() api.editor:set_active_text(\"custom config\") end\n",
)
.expect("helper.lua");
std::fs::write(
@@ -1036,11 +1176,11 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
std::fs::write(
&script,
r#"
app:map_key("", "a", function() app.editor:set_active_text("a pressed") end)
app:map_key("", "!1", function() app.editor:set_active_text("1 released") end)
app:map_key("", "<S-c>", function() app.editor:set_active_text("shift c") end)
app:map_key("", "<C-z>", function() app.editor:set_active_text("ctrl z") end)
app:map_key("", "<A-!r>", function() app.editor:set_active_text("alt r released") end)
api.keymap:map_key("", "a", function() api.editor:set_active_text("a pressed") end)
api.keymap:map_key("", "!1", function() api.editor:set_active_text("1 released") end)
api.keymap:map_key("", "<S-c>", function() api.editor:set_active_text("shift c") end)
api.keymap:map_key("", "<C-z>", function() api.editor:set_active_text("ctrl z") end)
api.keymap:map_key("", "<A-!r>", function() api.editor:set_active_text("alt r released") end)
"#,
)
.expect("keys.lua");
@@ -1138,11 +1278,11 @@ fn lua_keymap_accepts_command_names_and_nil() {
std::fs::write(
&script,
r#"
app:map_key("", "<C-n>", "next_tab")
app:map_completion_key("<C-n>", "completion_next")
api.keymap:map_key("", "<C-n>", "next_tab")
api.keymap:map_completion_key("<C-n>", "completion_next")
local ok, err = pcall(function()
app:map_key("", "<C-x>", "no_such_command")
api.keymap:map_key("", "<C-x>", "no_such_command")
end)
assert(not ok)
assert(tostring(err):find("доступны", 1, true), tostring(err))
@@ -1183,7 +1323,7 @@ fn lua_keymap_accepts_command_names_and_nil() {
// nil удаляет привязку, не оставляя заглушку.
std::fs::write(
&script,
"app:map_key(\"\", \"<C-n>\", nil)\napp:map_completion_key(\"<C-n>\", nil)\n",
"api.keymap:map_key(\"\", \"<C-n>\", nil)\napi.keymap:map_completion_key(\"<C-n>\", nil)\n",
)
.expect("keys.lua");
engine.run_file(&script).expect("привязки должны удалиться");
@@ -1216,6 +1356,30 @@ fn language_names_cover_all_supported_grammars() {
("json", Language::Json),
("python", Language::Python),
("py", Language::Python),
("lua", Language::Lua),
("bash", Language::Bash),
("sh", Language::Bash),
("shellscript", Language::Bash),
("yaml", Language::Yaml),
("yml", Language::Yaml),
("make", Language::Make),
("Makefile", Language::Make),
("dockerfile", Language::Dockerfile),
("Dockerfile", Language::Dockerfile),
("containerfile", Language::Dockerfile),
("sql", Language::Sql),
("markdown", Language::Markdown),
("md", Language::Markdown),
("css", Language::Css),
("c", Language::C),
("h", Language::C),
("cpp", Language::Cpp),
("c++", Language::Cpp),
("hpp", Language::Cpp),
("html", Language::Html),
("htm", Language::Html),
("xml", Language::Xml),
("svg", Language::Xml),
] {
assert_eq!(Language::from_name(name), Some(expected), "язык {name:?}");
}
@@ -1227,6 +1391,8 @@ fn language_names_cover_all_supported_grammars() {
assert_eq!(Language::Rust.name(), "rust");
assert_eq!(Language::JavaScript.name(), "js");
assert_eq!(Language::Python.name(), "python");
assert_eq!(Language::Yaml.name(), "yaml");
assert_eq!(Language::Cpp.name(), "cpp");
// Каждый язык перечитывается по имени и по полному имени для
// tree-sitter/LSP.
@@ -1236,12 +1402,68 @@ fn language_names_cover_all_supported_grammars() {
Language::JavaScript,
Language::Json,
Language::Python,
Language::Lua,
Language::Bash,
Language::Yaml,
Language::Make,
Language::Dockerfile,
Language::Sql,
Language::Markdown,
Language::Css,
Language::C,
Language::Cpp,
Language::Html,
Language::Xml,
] {
assert_eq!(Language::from_name(language.name()), Some(language));
assert_eq!(Language::from_name(language.syntax_name()), Some(language));
}
}
#[test]
fn language_is_detected_by_file_name() {
for (path, expected) in [
("src/main.rs", Some(Language::Rust)),
("main.go", Some(Language::Go)),
("app.js", Some(Language::JavaScript)),
("data.json", Some(Language::Json)),
("tool.py", Some(Language::Python)),
("init.lua", Some(Language::Lua)),
("run.sh", Some(Language::Bash)),
("config.yml", Some(Language::Yaml)),
("config.yaml", Some(Language::Yaml)),
("Makefile", Some(Language::Make)),
("build/GNUmakefile", Some(Language::Make)),
("rules.mk", Some(Language::Make)),
("Dockerfile", Some(Language::Dockerfile)),
("docker/Containerfile", Some(Language::Dockerfile)),
("image.dockerfile", Some(Language::Dockerfile)),
("query.sql", Some(Language::Sql)),
("README.md", Some(Language::Markdown)),
("style.css", Some(Language::Css)),
("main.c", Some(Language::C)),
("main.h", Some(Language::C)),
("main.cpp", Some(Language::Cpp)),
("main.hpp", Some(Language::Cpp)),
("index.html", Some(Language::Html)),
("layout.xml", Some(Language::Xml)),
("icon.svg", Some(Language::Xml)),
("notes.txt", None),
] {
assert_eq!(Language::from_path(path), expected, "файл {path:?}");
}
}
#[test]
fn lsp_language_id_matches_the_grammar_name() {
assert_eq!(Language::Rust.lsp_language_id(), "rust");
assert_eq!(Language::JavaScript.lsp_language_id(), "javascript");
assert_eq!(Language::Bash.lsp_language_id(), "shellscript");
assert_eq!(Language::Make.lsp_language_id(), "makefile");
assert_eq!(Language::Dockerfile.lsp_language_id(), "dockerfile");
assert_eq!(Language::Xml.lsp_language_id(), "xml");
}
#[test]
fn highlighting_is_off_by_default_and_enabled_through_api() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -1321,7 +1543,7 @@ fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext
let script = dir.join("keys.lua");
std::fs::write(
&script,
r#"app:map_key("", "<A-r>", function() app.editor:set_active_text("alt r pressed") end)"#,
r#"api.keymap:map_key("", "<A-r>", function() api.editor:set_active_text("alt r pressed") end)"#,
)
.expect("keys.lua");
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
@@ -1356,13 +1578,13 @@ fn editor_and_block_keymaps_depend_on_focus(cx: &mut TestAppContext) {
std::fs::write(
&script,
r#"
app:map_key("", "<A-g>", function() app.editor:set_gutter_icon(0, "circle") end)
app.editor:map_key("", "<A-r>", function() app.editor:set_gutter_icon(0, "play") end)
api.keymap:map_key("", "<A-g>", function() api.editor:set_gutter_icon(0, "circle") end)
api.editor:map_key("", "<A-r>", function() api.editor:set_gutter_icon(0, "play") end)
local tree = app.left_panel:add_tree()
local tree = api.panels.left:add_tree()
tree:append({ { id = "a", label = "a" } }, "")
tree:map_key("", "<A-t>", function() app.editor:set_gutter_icon(0, "file-code") end)
app.left_panel:open()
tree:map_key("", "<A-t>", function() api.editor:set_gutter_icon(0, "file-code") end)
api.panels.left:open()
"#,
)
.expect("keys.lua");
@@ -1422,6 +1644,120 @@ fn editor_and_block_keymaps_depend_on_focus(cx: &mut TestAppContext) {
let _ = std::fs::remove_dir_all(&dir);
}
#[gpui_kit::test]
fn global_keymap_sees_tree_focus_in_lua(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let dir = temp_script_dir("tree-focus-query");
let script = dir.join("focus.lua");
std::fs::write(
&script,
r#"
local tree = api.panels.left:add_tree()
tree:append({ { id = "a", label = "a" } }, "")
api.panels.left:open()
api.keymap:map_key("", "<A-h>", function()
if tree:has_focus() then
api.editor:set_gutter_icon(0, "folder")
else
api.editor:set_gutter_icon(0, "file")
end
end)
"#,
)
.expect("focus.lua");
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
engine.run_file(&script).expect("привязки");
let tree = {
let panel = AppApi::global().left_panel_handle();
let panel = panel.borrow();
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
tree.clone()
};
let icon = || AppApi::global().editor().gutter_icon(0);
// Фокус в редакторе: `tree:has_focus()` должен быть ложным.
visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle()
.update(cx, |state, cx| state.focus(window, cx));
});
});
visual.run_until_parked();
visual.simulate_keystrokes("alt-h");
visual.run_until_parked();
assert_eq!(icon().as_deref(), Some("file"));
// Фокус на дереве: глобальный обработчик видит `tree:has_focus()`.
tree.borrow_mut().focus("a");
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
visual.run_until_parked();
visual.simulate_keystrokes("alt-h");
visual.run_until_parked();
assert_eq!(icon().as_deref(), Some("folder"));
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[gpui_kit::test]
fn editor_focus_from_lua_moves_focus_out_of_tree(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let tree = {
let panel = AppApi::global().left_panel_handle();
let tree = panel.borrow_mut().add_tree(None);
panel.borrow_mut().open();
tree
};
tree.borrow_mut()
.append(
"",
vec![TreeNode {
id: "a".to_owned(),
label: "a".to_owned(),
icon: None,
expandable: false,
expanded: false,
}],
)
.expect("узел дерева");
tree.borrow_mut().focus("a");
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
visual.run_until_parked();
visual.update(|window, _cx| {
let focus = tree
.borrow()
.existing_focus_handle()
.expect("дерево отрисовано");
assert!(focus.is_focused(window), "фокус должен быть на дереве");
});
AppApi::global().editor().focus();
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
visual.run_until_parked();
visual.update(|window, cx| {
let editor = app.read(cx).editor_handle();
let presentation = editor.read(cx).presentation();
let focus = presentation.focus_handle();
assert!(focus.is_focused(window), "фокус должен быть в редакторе");
});
}
#[test]
fn lua_key_modes_are_per_object_and_dynamic() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -1432,24 +1768,24 @@ fn lua_key_modes_are_per_object_and_dynamic() {
std::fs::write(
&script,
r#"
assert(app:mode() == "-")
app:set_mode("i")
assert(app:mode() == "i")
app:map_key("-", "<A-a>", function() app.editor:set_active_text("default") end)
app:map_key("i", "<A-b>", function() app.editor:set_active_text("insert") end)
app:map_key("", "<A-c>", function() app.editor:set_active_text("all") end)
assert(api.keymap:mode() == "-")
api.keymap:set_mode("i")
assert(api.keymap:mode() == "i")
api.keymap:map_key("-", "<A-a>", function() api.editor:set_active_text("default") end)
api.keymap:map_key("i", "<A-b>", function() api.editor:set_active_text("insert") end)
api.keymap:map_key("", "<A-c>", function() api.editor:set_active_text("all") end)
assert(app.editor:mode() == "-")
app.editor:set_mode("n")
assert(app.editor:mode() == "n")
app.editor:map_key("n", "<A-d>", function() app.editor:set_active_text("editor n") end)
app.editor:map_key("i", "<A-e>", function() app.editor:set_active_text("editor i") end)
assert(api.editor:mode() == "-")
api.editor:set_mode("n")
assert(api.editor:mode() == "n")
api.editor:map_key("n", "<A-d>", function() api.editor:set_active_text("editor n") end)
api.editor:map_key("i", "<A-e>", function() api.editor:set_active_text("editor i") end)
local tree = app.left_panel:add_tree()
local tree = api.panels.left:add_tree()
assert(tree:mode() == "-")
tree:set_mode("v")
assert(tree:mode() == "v")
tree:map_key("v", "<A-f>", function() app.editor:set_active_text("tree v") end)
tree:map_key("v", "<A-f>", function() api.editor:set_active_text("tree v") end)
"#,
)
.expect("modes.lua");
@@ -1524,9 +1860,9 @@ fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
let (app, visual) = cx.add_window_view(MyApp::new);
let api = AppApi::global();
api.left_panel().open();
api.right_panel().open();
api.bottom_panel().open();
api.panels().left().open();
api.panels().right().open();
api.panels().bottom().open();
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
@@ -1552,7 +1888,7 @@ fn toolbar_items_render_from_api(cx: &mut TestAppContext) {
.eval::<mlua::Function>()
.unwrap();
let toolbar = AppApi::global().toolbar();
let toolbar = AppApi::global().panels().toolbar();
toolbar
.add_label(
"Слева".into(),
@@ -1644,7 +1980,7 @@ fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
}],
)
.unwrap();
api.left_panel().open();
api.panels().left().open();
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
@@ -1686,7 +2022,7 @@ fn bottom_panel_renders_blocks_as_tabs(cx: &mut TestAppContext) {
assert!(panel.borrow_mut().set_active(0));
assert!(!panel.borrow_mut().set_active(2));
api.bottom_panel().open();
api.panels().bottom().open();
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
@@ -1724,7 +2060,7 @@ fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
],
)
.unwrap();
api.left_panel().open();
api.panels().left().open();
tree.borrow_mut().focus("src");
visual.update(|_window, cx| {
@@ -1735,12 +2071,12 @@ fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
// Панель закрыли и открыли снова: фокус должен вернуться на выбранную
// строку, иначе стрелка уйдёт в редактор.
api.left_panel().close();
api.panels().left().close();
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
visual.run_until_parked();
api.left_panel().open();
api.panels().left().open();
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
@@ -1855,16 +2191,16 @@ fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
let open = dir.join("open.lua");
std::fs::write(
&keys,
r#"app:map_key("", "<A-r>", function() app.editor:set_active_text("blocked") end)"#,
r#"api.keymap:map_key("", "<A-r>", function() api.editor:set_active_text("blocked") end)"#,
)
.expect("keys.lua");
std::fs::write(
&open,
r#"
app:open_input({
api.window:open_input({
title = "Имя",
placeholder = "main.rs",
callback = function(text) app.editor:set_active_text(text) end,
callback = function(text) api.editor:set_active_text(text) end,
})
"#,
)
@@ -1872,7 +2208,7 @@ fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
engine.run_file(&keys).expect("привязка");
engine.run_file(&open).expect("app:open_input");
engine.run_file(&open).expect("api.window:open_input");
let notify = |visual: &mut gpui_kit::VisualTestContext| {
visual.update(|_window, cx| {
@@ -1899,7 +2235,9 @@ fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
assert_eq!(workspace.borrow().active().content, "");
// Клик вне окна тоже закрывает его без колбэка.
engine.run_file(&open).expect("app:open_input повторно");
engine
.run_file(&open)
.expect("api.window:open_input повторно");
notify(visual);
assert!(prompt_open(visual));
visual.simulate_click(point(px(2.), px(2.)), Modifiers::default());
@@ -1911,7 +2249,9 @@ fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
assert_eq!(workspace.borrow().active().content, "");
// Enter подтверждает ввод и вызывает колбэк уже после закрытия окна.
engine.run_file(&open).expect("app:open_input в третий раз");
engine
.run_file(&open)
.expect("api.window:open_input в третий раз");
notify(visual);
assert!(prompt_open(visual));
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Панель состоит из двух областей. Элементы левой области выстраиваются от
//! левого края вправо, элементы правой — от правого края влево. Сценарий
//! добавляет в области лейблы и кнопки через `app.toolbar`.
//! добавляет в области лейблы и кнопки через `api.panels.toolbar`.
//!
//! Как и панели окна, состояние лежит в `Rc<RefCell<_>>`: Lua-сценарий меняет
//! его через API, а вид [`MyApp`](crate::app::MyApp) читает при отрисовке.
+20 -1
View File
@@ -107,6 +107,10 @@ pub struct TreeState {
focus_handle: Option<FocusHandle>,
/// Следующий кадр должен передать фокус этому дереву.
focus_requested: bool,
/// Владеет ли дерево клавиатурным фокусом. Вид обновляет флаг перед
/// обработкой событий клавиатуры, чтобы глобальные привязки могли
/// проверить его из Lua через `tree:has_focus()`.
focused: bool,
/// Прокрутка списка: выбранная строка всегда прокручивается в видимую часть.
scroll: ScrollHandle,
/// Привязки клавиш блока и его текущий режим.
@@ -143,11 +147,26 @@ impl TreeState {
return false;
}
self.expand_ancestors(id);
self.focus_requested = true;
self.select_and_scroll(id);
self.request_focus();
true
}
/// Просит вид передать дереву фокус в следующем кадре, не меняя выбор.
pub fn request_focus(&mut self) {
self.focus_requested = true;
}
/// Запоминает, владеет ли дерево клавиатурным фокусом.
pub fn set_focused(&mut self, focused: bool) {
self.focused = focused;
}
/// Владеет ли дерево клавиатурным фокусом по последним данным вида.
pub fn has_focus(&self) -> bool {
self.focused
}
/// Обработчик прокрутки списка дерева.
pub fn scroll_handle(&self) -> ScrollHandle {
self.scroll.clone()