diff --git a/AGENTS.md b/AGENTS.md index 8233db9..ca02b28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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>` / `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"` — по умолчанию и diff --git a/Cargo.lock b/Cargo.lock index ebd8dbc..6e95cce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index c63ff3a..ef73fe0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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, отслеживание файлов и типы протокола. diff --git a/DOCS.md b/DOCS.md deleted file mode 100644 index 377dbf3..0000000 --- a/DOCS.md +++ /dev/null @@ -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", "", "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 | -| `""` | C при зажатом Shift | -| `""` | Z при зажатом Ctrl | -| `""` | отпускание R при зажатом Alt | -| `""` | S при зажатом Cmd на macOS или Super на Linux | - -Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux -и Windows буквы берутся по аппаратному keycode, поэтому `` работает -и на русской раскладке. - -#### Режимы - -У каждого объекта своя текущая режимная метка: один символ (`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", "", "next_tab") -- только в режиме n -app.editor:map_key("i", "", "save") -- только в режиме i редактора -app.editor:map_key("", "", 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("", "", function() app.editor:save() end) - --- Убрать встроенную привязку. -app:map_key("", "", nil) - --- Назначить встроенную команду на другую клавишу. -app:map_key("", "", "next_tab") -app:map_key("", "", "toggle_left_panel") - --- Навигация по списку автодополнения на Ctrl+N / Ctrl+P. -app:map_completion_key("", "completion_next") -app:map_completion_key("", "completion_previous") - --- Привязка, которая срабатывает только при фокусе в редакторе. -app.editor:map_key("", "", function() - app.editor:set_active_text("editor mode") -end) - --- Привязка блока дерева: работает, когда фокус на этом дереве. -tree:map_key("v", "", 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("", "", 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("", "", function() app:close() end) -app:map_key("", "", "toggle_left_panel") -app:map_key("", "", "toggle_bottom_panel") - -app.editor:run_lsp({ - name = "rust-analyzer", - cmd = "rust-analyzer", - languages = { "rust" }, - watcher = "**/*.rs", -}) -``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..94749bb --- /dev/null +++ b/docs/README.md @@ -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("", "", "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`. diff --git a/docs/editor.md b/docs/editor.md new file mode 100644 index 0000000..9d53314 --- /dev/null +++ b/docs/editor.md @@ -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("", "", function() + api.editor:set_active_text("editor mode") +end) +``` diff --git a/docs/keymap.md b/docs/keymap.md new file mode 100644 index 0000000..eac9ae2 --- /dev/null +++ b/docs/keymap.md @@ -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 | +| `""` | C при зажатом Shift | +| `""` | Z при зажатом Ctrl | +| `""` | отпускание R при зажатом Alt | +| `""` | S при зажатом Cmd на macOS или Super на Linux | + +Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux +и Windows буквы берутся по аппаратному keycode, поэтому `` работает +и на русской раскладке. + +## Режимы + +У каждого объекта своя текущая режимная метка: один символ (`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", "", "next_tab") -- только в режиме n +api.editor:map_key("i", "", "save") -- только в режиме i редактора +api.editor:map_key("", "", 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("", "", function() api.editor:save() end) + +-- Убрать встроенную привязку. +api.keymap:map_key("", "", nil) + +-- Назначить встроенную команду на другую клавишу. +api.keymap:map_key("", "", "next_tab") +api.keymap:map_key("", "", "toggle_left_panel") + +-- Навигация по списку автодополнения на Ctrl+N / Ctrl+P. +api.keymap:map_completion_key("", "completion_next") +api.keymap:map_completion_key("", "completion_previous") + +-- Привязка, которая срабатывает только при фокусе в редакторе. +api.editor:map_key("", "", function() + api.editor:set_active_text("editor mode") +end) + +-- Привязка блока дерева: работает, когда фокус на этом дереве. +tree:map_key("v", "", function() print("tree v") end) +``` + +Пока список автодополнения открыт, его слой проверяется раньше действий +редактора. Сочетания с модификаторами и горизонтальные стрелки закрывают +список, даже если для них нет своей привязки, — так `Shift+↑` продолжает +выделять текст. diff --git a/docs/lsp.md b/docs/lsp.md new file mode 100644 index 0000000..ccbb599 --- /dev/null +++ b/docs/lsp.md @@ -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 +``` diff --git a/docs/panels.md b/docs/panels.md new file mode 100644 index 0000000..a6ec873 --- /dev/null +++ b/docs/panels.md @@ -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("", "", 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() +``` diff --git a/docs/path.md b/docs/path.md new file mode 100644 index 0000000..80e0bf8 --- /dev/null +++ b/docs/path.md @@ -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") +``` diff --git a/docs/window.md b/docs/window.md new file mode 100644 index 0000000..e3ecdca --- /dev/null +++ b/docs/window.md @@ -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, +}) +``` diff --git a/init.lua b/init.lua index 2d0cb2d..e0cd35c 100644 --- a/init.lua +++ b/init.lua @@ -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, поэтому -- -- работает и на русской раскладке --- 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("", "", function() app.editor:save() end) --- app:map_key("", "", function() app:close() end) +-- api.keymap:map_key("", "", function() api.editor:save() end) +-- api.keymap:map_key("", "", function() api.window:close() end) -- Убрать встроенную привязку можно так: --- app:map_key("", "", nil) --- app:map_completion_key("", "completion_next") --- app:map_completion_key("", "completion_previous") +-- api.keymap:map_key("", "", nil) +-- api.keymap:map_completion_key("", "completion_next") +-- api.keymap:map_completion_key("", "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("", "", 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("", "", 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("", "", 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("", "", "toggle_left_panel") -app:map_key("", "", "toggle_bottom_panel") +-- Ctrl+E открывает панель и переводит фокус на дерево, а если панель уже +-- открыта — только переводит фокус. На дереве Ctrl+Q закрывает панель, +-- в остальных местах — по-прежнему закрывает приложение. +api.keymap:map_key("", "", function() + api.panels.left:open() + project_tree:focus() +end) + +api.keymap:map_key("", "", 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("", "", function() + api.window:open_input({ + -- title = "Открыть файл", + placeholder = "src/main.rs", + callback = function(text) api.editor:open(text, {}) end, + }) +end) + +api.keymap:map_key("", "", "toggle_bottom_panel") -- Дерево диагностики в нижней панели. -- @@ -318,7 +476,7 @@ app:map_key("", "", "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") diff --git a/src/api.rs b/src/api.rs index d2f0e43..15cdef0 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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> = const { RefCell::new(VecDeque::new()) }; /// Развёрнуто ли окно. Вид обновляет значение в каждом кадре, чтобы - /// `app:is_maximized()` из Lua видел актуальное состояние. + /// `api.window:is_maximized()` из Lua видел актуальное состояние. static WINDOW_MAXIMIZED: Cell = 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>, +} + +impl KeymapApi { + fn new(keymap: Rc>) -> 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) -> 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) -> 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> { + 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 { + 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 { + 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 { + 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 { + 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, + ) -> mlua::Result { + 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::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` — нажатие клавиши без модификаторов; + /// * ``, ``, ``, `` — нажатие с Shift, Ctrl, Alt или + /// платформенным модификатором (Cmd/Super); + /// * `!a` — отпускание клавиши; + /// * `` — отпускание клавиши при зажатом 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", "", "save")`. + /// В Lua доступно как `api.editor`: `api.editor:open_tab()`, + /// `api.editor:save()`, `api.editor:map_key("n", "", "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) -> std::io::Result> { - 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) -> mlua::Result> { - 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::Result<()> { - *self.prompt_request.borrow_mut() = Some(PromptRequest::from_options(options)?); - Ok(()) - } - - /// Назначает глобальный обработчик на нажатие или отпускание клавиши. - /// - /// Первый аргумент `modes` — строка режимов, в которых действует - /// сочетание: пустая строка — все режимы, `"-"` — только режим по - /// умолчанию, `"-iv"` — режим по умолчанию, `i` и `v`. `keys` записывается - /// в духе nvim: - /// - /// * `a`, `1` — нажатие клавиши без модификаторов; - /// * ``, ``, ``, `` — нажатие с Shift, Ctrl, Alt или - /// платформенным модификатором (Cmd/Super); - /// * `!a` — отпускание клавиши; - /// * `` — отпускание клавиши при зажатом 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) -> std::io::Result { 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 { + fn lua_open(&self, path: mlua::Value, position: mlua::Value) -> mlua::Result { + 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 { 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 { 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 { + 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::() { + 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 { 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( diff --git a/src/app.rs b/src/app.rs index a27c5e3..26b677a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -190,13 +190,13 @@ pub struct MyApp { /// Привязки клавиш редактора: действуют дополнительно к глобальным, /// когда фокус находится в редакторе. editor_keymap: Rc>, - /// Состояние левой панели, общее с `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) { 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) -> 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) -> 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>, context: KeyContext, diff --git a/src/editor.rs b/src/editor.rs index 25de5eb..9881b76 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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 { 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 { - 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 { + 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 { + 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, diff --git a/src/gutter.rs b/src/gutter.rs index 170436d..35e27fb 100644 --- a/src/gutter.rs +++ b/src/gutter.rs @@ -1,7 +1,7 @@ //! Иконки на поле редактора (gutter). //! //! Gutter — узкая колонка слева от редактора. Сценарий ставит на строку -//! иконку через `app:set_gutter_icon`, а вид рисует её на той же высоте, что и +//! иконку через `api.editor:set_gutter_icon`, а вид рисует её на той же высоте, что и //! строка в редакторе, учитывая прокрутку. //! //! Состояние принадлежит документу: у каждой вкладки свои иконки, и при diff --git a/src/icons.rs b/src/icons.rs index 12efe42..7bf86ed 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -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`, а вкладка продолжает показывать //! иконку по умолчанию. diff --git a/src/keymap.rs b/src/keymap.rs index 0c66d44..6888a1a 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -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 ["", ""] { self.set( diff --git a/src/lsp.rs b/src/lsp.rs index 2209bcd..ab0f417 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -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 { self.diagnostics.snapshot() diff --git a/src/main.rs b/src/main.rs index 391223c..0f6c775 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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))?; diff --git a/src/panel.rs b/src/panel.rs index 6191ac1..d5556ad 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -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>`, чтобы вид и //! API видели одни и те же панели. //! diff --git a/src/prompt.rs b/src/prompt.rs index 9fe116c..314d458 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -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 Err(mlua::Error::RuntimeError(format!( - "app:open_input: поле {field} должно быть строкой" + "api.window:open_input: поле {field} должно быть строкой" ))), } } diff --git a/src/script.rs b/src/script.rs index 8f65362..d43499a 100644 --- a/src/script.rs +++ b/src/script.rs @@ -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(()) } diff --git a/src/syntax.rs b/src/syntax.rs new file mode 100644 index 0000000..9e70116 --- /dev/null +++ b/src/syntax.rs @@ -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:?} нет запроса подсветки" + ); + } + } +} diff --git a/src/tests.rs b/src/tests.rs index 8a1b566..446d726 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -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("", "", function() app.editor:set_active_text("shift c") end) - app:map_key("", "", function() app.editor:set_active_text("ctrl z") end) - app:map_key("", "", 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("", "", function() api.editor:set_active_text("shift c") end) + api.keymap:map_key("", "", function() api.editor:set_active_text("ctrl z") end) + api.keymap:map_key("", "", 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("", "", "next_tab") - app:map_completion_key("", "completion_next") + api.keymap:map_key("", "", "next_tab") + api.keymap:map_completion_key("", "completion_next") local ok, err = pcall(function() - app:map_key("", "", "no_such_command") + api.keymap:map_key("", "", "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(\"\", \"\", nil)\napp:map_completion_key(\"\", nil)\n", + "api.keymap:map_key(\"\", \"\", nil)\napi.keymap:map_completion_key(\"\", 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("", "", function() app.editor:set_active_text("alt r pressed") end)"#, + r#"api.keymap:map_key("", "", 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("", "", function() app.editor:set_gutter_icon(0, "circle") end) - app.editor:map_key("", "", function() app.editor:set_gutter_icon(0, "play") end) + api.keymap:map_key("", "", function() api.editor:set_gutter_icon(0, "circle") end) + api.editor:map_key("", "", 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("", "", function() app.editor:set_gutter_icon(0, "file-code") end) - app.left_panel:open() + tree:map_key("", "", 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("", "", 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("-", "", function() app.editor:set_active_text("default") end) - app:map_key("i", "", function() app.editor:set_active_text("insert") end) - app:map_key("", "", 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("-", "", function() api.editor:set_active_text("default") end) + api.keymap:map_key("i", "", function() api.editor:set_active_text("insert") end) + api.keymap:map_key("", "", 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", "", function() app.editor:set_active_text("editor n") end) - app.editor:map_key("i", "", 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", "", function() api.editor:set_active_text("editor n") end) + api.editor:map_key("i", "", 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", "", function() app.editor:set_active_text("tree v") end) + tree:map_key("v", "", 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::() .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("", "", function() app.editor:set_active_text("blocked") end)"#, + r#"api.keymap:map_key("", "", 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)); diff --git a/src/toolbar.rs b/src/toolbar.rs index 7a9d138..b6c9601 100644 --- a/src/toolbar.rs +++ b/src/toolbar.rs @@ -2,7 +2,7 @@ //! //! Панель состоит из двух областей. Элементы левой области выстраиваются от //! левого края вправо, элементы правой — от правого края влево. Сценарий -//! добавляет в области лейблы и кнопки через `app.toolbar`. +//! добавляет в области лейблы и кнопки через `api.panels.toolbar`. //! //! Как и панели окна, состояние лежит в `Rc>`: Lua-сценарий меняет //! его через API, а вид [`MyApp`](crate::app::MyApp) читает при отрисовке. diff --git a/src/tree.rs b/src/tree.rs index 3c0f378..2103788 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -107,6 +107,10 @@ pub struct TreeState { focus_handle: Option, /// Следующий кадр должен передать фокус этому дереву. 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()