refactoring
This commit is contained in:
@@ -11,8 +11,8 @@
|
|||||||
| `src/main.rs` | Точка входа, окно, аргументы командной строки |
|
| `src/main.rs` | Точка входа, окно, аргументы командной строки |
|
||||||
| `src/constants.rs` | Константы окна, CLI и каталог сценариев |
|
| `src/constants.rs` | Константы окна, CLI и каталог сценариев |
|
||||||
| `src/editor.rs` | Доменная модель: документы, вкладки, языки |
|
| `src/editor.rs` | Доменная модель: документы, вкладки, языки |
|
||||||
| `src/api.rs` | `AppApi` / `EditorApi` / `PanelApi` / `TreeApi` / `ToolbarApi` — единственная поверхность управления |
|
| `src/api.rs` | `AppApi` / `EditorApi` / `PanelsApi` / `PanelApi` / `ToolbarApi` / `TreeApi` / `PathApi` / `WindowApi` / `KeymapApi` — единственная поверхность управления |
|
||||||
| `src/script.rs` | Lua-движок, `app`, `include`, `require` |
|
| `src/script.rs` | Lua-движок, `api`, `include`, `require` |
|
||||||
| `src/prompt.rs` | Плавающее поле ввода: запрос из Lua и открытое окно |
|
| `src/prompt.rs` | Плавающее поле ввода: запрос из Lua и открытое окно |
|
||||||
| `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики |
|
| `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики |
|
||||||
| `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение |
|
| `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение |
|
||||||
@@ -23,18 +23,41 @@
|
|||||||
| `src/gutter.rs` | Иконки на полях слева от редактора |
|
| `src/gutter.rs` | Иконки на полях слева от редактора |
|
||||||
| `src/icons.rs` | Каталог иконок Lucide |
|
| `src/icons.rs` | Каталог иконок Lucide |
|
||||||
| `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер |
|
| `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` — центр приложения
|
### 1. `AppApi` — центр приложения
|
||||||
|
|
||||||
- Документы, вкладки, панели, дерево, окно, подсветка, LSP и привязки клавиш
|
- Документы, вкладки, панели, дерево, окно, подсветка, LSP и привязки клавиш
|
||||||
меняются только через `AppApi`, `EditorApi` или `PanelApi` / `TreeApi` /
|
меняются только через `AppApi`, `EditorApi`, `PanelsApi`, `PanelApi`,
|
||||||
`ToolbarApi`.
|
`ToolbarApi`, `TreeApi`, `PathApi`, `WindowApi` или `KeymapApi`.
|
||||||
- Всё, что относится к редактору (вкладки, текст, gutter, сохранение, LSP и
|
- Всё, что относится к редактору (вкладки, текст, gutter, сохранение, LSP и
|
||||||
привязки редактора), живёт в `app.editor`; окно, панели и глобальные
|
привязки редактора), живёт в `api.editor`; панели — в `api.panels`, файловая
|
||||||
привязки — в `app`.
|
система — в `api.path`, окно — в `api.window`, глобальные привязки — в
|
||||||
|
`api.keymap`.
|
||||||
- Состояние живёт в общих `Rc<RefCell<_>>` / `Arc`, чтобы UI, API и Lua
|
- Состояние живёт в общих `Rc<RefCell<_>>` / `Arc`, чтобы UI, API и Lua
|
||||||
видели одни и те же объекты. Второй копии состояния быть не должно.
|
видели одни и те же объекты. Второй копии состояния быть не должно.
|
||||||
- Команды окна складываются в очередь и применяются в начале кадра
|
- Команды окна складываются в очередь и применяются в начале кадра
|
||||||
@@ -47,15 +70,21 @@
|
|||||||
|
|
||||||
### 2. Lua повторяет возможности API
|
### 2. Lua повторяет возможности API
|
||||||
|
|
||||||
- Публичные методы `impl AppApi` / `impl EditorApi` с `#[mlua::userdata_impl]`
|
- Публичные методы `impl AppApi` / `impl EditorApi` / `impl PathApi` и других
|
||||||
доступны как `app:method(...)` / `app.editor:method(...)`. Добавляя
|
`*Api` с `#[mlua::userdata_impl]` доступны из Lua через объект `api`:
|
||||||
возможность, сразу добавляйте и Lua-метод.
|
`api.editor:method(...)`, `api.path:method(...)`, `api.panels.left:method(...)`.
|
||||||
|
Добавляя возможность, сразу добавляйте и Lua-метод.
|
||||||
- Имена — `snake_case`; вкладки и индексы нумеруются с нуля; панели —
|
- Имена — `snake_case`; вкладки и индексы нумеруются с нуля; панели —
|
||||||
`app.left_panel`, `app.right_panel`, `app.bottom_panel`; блоки панелей
|
`api.panels.left`, `api.panels.right`, `api.panels.bottom`,
|
||||||
получают свои объекты (`tree`).
|
`api.panels.toolbar`; блоки панелей получают свои объекты (`tree`).
|
||||||
|
- `AppApi` — только группа-навигатор: методы живут в `EditorApi`,
|
||||||
|
`PanelsApi`, `PathApi`, `WindowApi` или `KeymapApi`. Не добавляйте новый
|
||||||
|
метод прямо в `api`, если для него есть подходящая группа.
|
||||||
|
- Все методы `PathApi` принимают путь либо строкой `"foo/bar"`, либо
|
||||||
|
массивом сегментов `{ "foo", "bar" }`.
|
||||||
- Для методов, которые не должны попадать в Lua, используется `#[lua(skip)]`.
|
- Для методов, которые не должны попадать в Lua, используется `#[lua(skip)]`.
|
||||||
Для generic-методов добавляется тонкая не-generic Lua-обёртка.
|
Для generic-методов добавляется тонкая не-generic Lua-обёртка.
|
||||||
- `DOCS.md` и комментарии в `init.lua` обновляются в том же изменении, что и
|
- `docs/*.md` и комментарии в `init.lua` обновляются в том же изменении, что и
|
||||||
публичный API. Документация описывает фактическое поведение, без
|
публичный API. Документация описывает фактическое поведение, без
|
||||||
«запланированных» методов.
|
«запланированных» методов.
|
||||||
|
|
||||||
@@ -63,8 +92,8 @@
|
|||||||
|
|
||||||
- Встроенные привязки (`Ctrl/Cmd+S`, `Ctrl/Cmd+Space`, навигация по списку
|
- Встроенные привязки (`Ctrl/Cmd+S`, `Ctrl/Cmd+Space`, навигация по списку
|
||||||
автодополнения) регистрируются в `KeyMap` и могут быть заменены или удалены
|
автодополнения) регистрируются в `KeyMap` и могут быть заменены или удалены
|
||||||
из Lua через `app:map_key` / `app.editor:map_key` /
|
из Lua через `api.keymap:map_key` / `api.editor:map_key` /
|
||||||
`app:map_completion_key`.
|
`api.keymap:map_completion_key`.
|
||||||
- У приложения, редактора и каждого блока панели своя `KeyMap` и свой текущий
|
- У приложения, редактора и каждого блока панели своя `KeyMap` и свой текущий
|
||||||
режим (один символ; `-` — режим по умолчанию). Привязка хранит строку
|
режим (один символ; `-` — режим по умолчанию). Привязка хранит строку
|
||||||
режимов, в которых она действует: `""` — все, `"-iv"` — по умолчанию и
|
режимов, в которых она действует: `""` — все, `"-iv"` — по умолчанию и
|
||||||
|
|||||||
Generated
+137
-11
@@ -172,7 +172,7 @@ checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"memchr",
|
"memchr",
|
||||||
"unicode-width 0.2.3",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -820,9 +820,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.10.0"
|
version = "1.2.67"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
|
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -916,7 +916,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
"unicode-width 0.1.14",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2154,13 +2154,23 @@ dependencies = [
|
|||||||
"smol",
|
"smol",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
|
"tree-sitter-bash",
|
||||||
|
"tree-sitter-c",
|
||||||
|
"tree-sitter-cpp",
|
||||||
|
"tree-sitter-css",
|
||||||
"tree-sitter-go",
|
"tree-sitter-go",
|
||||||
|
"tree-sitter-html",
|
||||||
"tree-sitter-javascript",
|
"tree-sitter-javascript",
|
||||||
"tree-sitter-json",
|
"tree-sitter-json",
|
||||||
|
"tree-sitter-lua",
|
||||||
|
"tree-sitter-make",
|
||||||
|
"tree-sitter-md",
|
||||||
"tree-sitter-python",
|
"tree-sitter-python",
|
||||||
"tree-sitter-rust",
|
"tree-sitter-rust",
|
||||||
|
"tree-sitter-sequel",
|
||||||
|
"tree-sitter-yaml",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width 0.2.3",
|
"unicode-width",
|
||||||
"uuid",
|
"uuid",
|
||||||
"windows 0.58.0",
|
"windows 0.58.0",
|
||||||
]
|
]
|
||||||
@@ -5305,6 +5315,8 @@ dependencies = [
|
|||||||
"notify",
|
"notify",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tree-sitter-containerfile",
|
||||||
|
"tree-sitter-xml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7099,6 +7111,56 @@ dependencies = [
|
|||||||
"tree-sitter-language",
|
"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]]
|
[[package]]
|
||||||
name = "tree-sitter-go"
|
name = "tree-sitter-go"
|
||||||
version = "0.23.4"
|
version = "0.23.4"
|
||||||
@@ -7109,6 +7171,16 @@ dependencies = [
|
|||||||
"tree-sitter-language",
|
"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]]
|
[[package]]
|
||||||
name = "tree-sitter-javascript"
|
name = "tree-sitter-javascript"
|
||||||
version = "0.23.1"
|
version = "0.23.1"
|
||||||
@@ -7135,6 +7207,36 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a"
|
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]]
|
[[package]]
|
||||||
name = "tree-sitter-python"
|
name = "tree-sitter-python"
|
||||||
version = "0.23.6"
|
version = "0.23.6"
|
||||||
@@ -7155,6 +7257,36 @@ dependencies = [
|
|||||||
"tree-sitter-language",
|
"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]]
|
[[package]]
|
||||||
name = "triomphe"
|
name = "triomphe"
|
||||||
version = "0.1.17"
|
version = "0.1.17"
|
||||||
@@ -7270,12 +7402,6 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
|
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-width"
|
|
||||||
version = "0.1.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|||||||
+14
@@ -10,7 +10,21 @@ gpui-kit = { version = "0.8.1", features = [
|
|||||||
"tree-sitter-go",
|
"tree-sitter-go",
|
||||||
"tree-sitter-javascript",
|
"tree-sitter-javascript",
|
||||||
"tree-sitter-python",
|
"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"] }
|
mlua = { version = "0.12", features = ["lua54", "vendored", "macros"] }
|
||||||
|
|
||||||
# LSP-клиент: JSON-RPC поверх stdio, отслеживание файлов и типы протокола.
|
# LSP-клиент: JSON-RPC поверх stdio, отслеживание файлов и типы протокола.
|
||||||
|
|||||||
@@ -1,713 +0,0 @@
|
|||||||
# Lua API
|
|
||||||
|
|
||||||
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект
|
|
||||||
`app` для управления окном, панелями и глобальными привязками клавиш.
|
|
||||||
Вкладки, текст, gutter, подсветка, сохранение и LSP-серверы живут в `app.editor`,
|
|
||||||
а блоки панелей (деревья) — в своих объектах.
|
|
||||||
|
|
||||||
Сам редактор не содержит демонстрационных документов: при запуске открыта
|
|
||||||
одна пустая вкладка `untitled-1.txt`. Всё остальное — файлы, деревья, серверы —
|
|
||||||
настраивается сценарием.
|
|
||||||
|
|
||||||
## Запуск и каталог сценариев
|
|
||||||
|
|
||||||
Каталог поиска `init.lua` задаётся константой `SCRIPT_DIR` в `src/constants.rs`
|
|
||||||
(по умолчанию — текущий каталог). Его можно переопределить аргументом командной строки:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
qode --config /path/to/project main.go
|
|
||||||
cargo run -- --config ./config
|
|
||||||
cargo run -- main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
- В каталоге `--config` должны лежать `init.lua` и подключаемые им модули.
|
|
||||||
- Первый позиционный аргумент открывается после выполнения `init.lua`
|
|
||||||
через `app.editor:open(path)` — как `vim main.go` или `nano main.go`.
|
|
||||||
- `--config` меняет только каталог сценариев. Рабочий каталог процесса, а
|
|
||||||
вместе с ним пути `app.editor:open`, `app.editor:save` и `root` у LSP не меняются.
|
|
||||||
|
|
||||||
## Объект `app`
|
|
||||||
|
|
||||||
Объект `app` управляет окном и панелями. Всё, что относится к самому
|
|
||||||
редактору — вкладки, текст, gutter, сохранение, LSP и привязки клавиш
|
|
||||||
редактора, — живёт в `app.editor`:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app.editor:open("src/main.rs")
|
|
||||||
app.editor:save()
|
|
||||||
app.editor:map_key("n", "<C-s>", "save")
|
|
||||||
```
|
|
||||||
|
|
||||||
Номера вкладок во всех методах нумеруются с нуля. Массив, который возвращает
|
|
||||||
`app.editor:tabs()`, остаётся обычной Lua-таблицей с индексами от 1, поэтому поле
|
|
||||||
`index` — единственный надёжный способ сослаться на вкладку.
|
|
||||||
|
|
||||||
### Вкладки
|
|
||||||
|
|
||||||
| Метод | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `app.editor:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs", icon = "file" }, ...}` |
|
|
||||||
| `app.editor:active_tab()` | Индекс активной вкладки |
|
|
||||||
| `app.editor:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует |
|
|
||||||
| `app.editor:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
|
|
||||||
| `app.editor:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс |
|
|
||||||
| `app.editor:open_tab()` | Открыть пустую вкладку, вернуть индекс |
|
|
||||||
| `app.editor:open(path, position)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем. Без второго аргумента фокус не меняется; `{}` — перевести фокус в редактор, оставив курсор; `{line, column}` — ещё и поставить курсор (с нуля) |
|
|
||||||
| `app.editor:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта |
|
|
||||||
| `app.editor:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна |
|
|
||||||
| `app.editor:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) |
|
|
||||||
| `app.editor:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет |
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app.editor:open("src/main.rs") -- открыть, не трогая фокус
|
|
||||||
app.editor:open("src/main.rs", {}) -- фокус в редактор, курсор на месте
|
|
||||||
app.editor:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор на строку 4, столбец 2
|
|
||||||
```
|
|
||||||
|
|
||||||
Строка и столбец нумеруются с нуля, как в диагностике LSP; столбец считается
|
|
||||||
в UTF-16.
|
|
||||||
|
|
||||||
### Иконки вкладок
|
|
||||||
|
|
||||||
Слева от названия вкладки показывается иконка; по умолчанию это `file`.
|
|
||||||
Имя — название иконки [Lucide](https://lucide.dev/icons/), регистр не важен.
|
|
||||||
Например:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app.editor:set_tab_icon(0, "file-code")
|
|
||||||
app.editor:set_tab_icon(1, "book-open")
|
|
||||||
app.editor:clear_tab_icon(1) -- снова "file"
|
|
||||||
```
|
|
||||||
|
|
||||||
Подчёркивания и пробелы считаются дефисом, а `icons/` и `.svg` можно
|
|
||||||
не писать: `file_code`, `file-code` и `icons/file-code.svg` равнозначны.
|
|
||||||
Неизвестное имя не назначается — метод вернёт `false`.
|
|
||||||
|
|
||||||
### Иконки на полях редактора (gutter)
|
|
||||||
|
|
||||||
Слева от редактора есть узкая колонка с иконками. Методы действуют на
|
|
||||||
активную вкладку, а номера строк нумеруются с нуля, как в LSP. На одной строке
|
|
||||||
живёт одна иконка; повторный вызов заменяет прежнюю.
|
|
||||||
|
|
||||||
| Метод | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `app.editor:set_gutter_icon(line, icon, color)` | Поставить иконку на строку; `color` необязателен (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`). Без цвета иконка рисуется цветом темы. `false` — иконка или цвет неизвестны |
|
|
||||||
| `app.editor:gutter_icon(line)` | Имя иконки на строке или `nil` |
|
|
||||||
| `app.editor:gutter_color(line)` | Цвет иконки или `nil`, если цвет не задан |
|
|
||||||
| `app.editor:clear_gutter_icon(line)` | Убрать иконку со строки; `false`, если её не было |
|
|
||||||
| `app.editor:clear_gutter()` | Убрать все иконки активной вкладки |
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app.editor:set_gutter_icon(4, "circle", "#e5484d") -- красная точка на 5-й строке
|
|
||||||
app.editor:set_gutter_icon(9, "play") -- цвет темы
|
|
||||||
app.editor:clear_gutter_icon(9)
|
|
||||||
```
|
|
||||||
|
|
||||||
Иконки привязаны к номерам строк и автоматически не сдвигаются при правке
|
|
||||||
текста: сценарий сам решает, когда их переставить. При прокрутке колонка
|
|
||||||
двигается вместе с редактором.
|
|
||||||
|
|
||||||
### Текст
|
|
||||||
|
|
||||||
| Метод | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `app.editor:active_text()` | Текст активной вкладки |
|
|
||||||
| `app.editor:set_active_text(text)` | Заменить текст активной вкладки |
|
|
||||||
|
|
||||||
### Подсветка синтаксиса
|
|
||||||
|
|
||||||
Подсветка по умолчанию выключена. Поддерживаемые языки: `rust`, `go`, `js`, `json`,
|
|
||||||
`python`.
|
|
||||||
|
|
||||||
| Метод | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `app.editor:set_language(index, "rust")` | Включить подсветку для вкладки; `false`, если вкладки нет или язык не поддерживается |
|
|
||||||
| `app.editor:language(index)` | Язык подсветки вкладки или `nil` |
|
|
||||||
| `app.editor:clear_language(index)` | Выключить подсветку для вкладки |
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app.editor:set_language(0, "rust")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сохранение
|
|
||||||
|
|
||||||
| Метод | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `app.editor:save()` | Сохранить активную вкладку в её файл |
|
|
||||||
|
|
||||||
### Файловая система
|
|
||||||
|
|
||||||
| Метод | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `app:list_dir(path)` | Содержимое каталога: `{{ id = "src/main.rs", name = "main.rs", is_dir = false }, ...}` |
|
|
||||||
|
|
||||||
Путь отсчитывается от текущего рабочего каталога процесса. Пустая строка или
|
|
||||||
`"."` означают сам текущий каталог. Поле `id` — путь элемента относительно
|
|
||||||
текущего каталога: его можно передать в `app.editor:open(id)` и использовать как
|
|
||||||
идентификатор узла дерева. Элементы уже отсортированы по имени без учёта
|
|
||||||
регистра.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
for _, entry in ipairs(app:list_dir("src")) do
|
|
||||||
print(entry.name, entry.is_dir)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Привязка клавиш
|
|
||||||
|
|
||||||
Привязки живут у трёх видов объектов: у приложения (`app`), у редактора
|
|
||||||
(`app.editor`) и у каждого блока панели (`tree`). Встроенные сочетания можно
|
|
||||||
заменить или удалить: обработчиком принимается Lua-функция, имя встроенной
|
|
||||||
команды или `nil`.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app:map_key(modes, keys, handler) -- глобальные
|
|
||||||
app.editor:map_key(modes, keys, handler) -- только когда фокус в редакторе
|
|
||||||
app:map_completion_key(keys, handler) -- список автодополнения
|
|
||||||
```
|
|
||||||
|
|
||||||
Слой автодополнения режимами не управляется: `map_completion_key` принимает
|
|
||||||
только клавишу и обработчик.
|
|
||||||
|
|
||||||
Первым аргументом `map_key` идёт строка режимов. Порядок обработки всегда
|
|
||||||
такой: сначала глобальные привязки, затем — если фокус в редакторе — привязки
|
|
||||||
редактора, а если фокус на блоке панели — привязки этого блока. Привязки
|
|
||||||
неактивного объекта не срабатывают.
|
|
||||||
|
|
||||||
Запись клавиши в духе nvim:
|
|
||||||
|
|
||||||
| Запись | Значение |
|
|
||||||
| --- | --- |
|
|
||||||
| `"a"` | нажатие A |
|
|
||||||
| `"1"` | нажатие 1 |
|
|
||||||
| `"!a"` | отпускание A |
|
|
||||||
| `"<S-c>"` | C при зажатом Shift |
|
|
||||||
| `"<C-z>"` | Z при зажатом Ctrl |
|
|
||||||
| `"<A-!r>"` | отпускание R при зажатом Alt |
|
|
||||||
| `"<D-s>"` | S при зажатом Cmd на macOS или Super на Linux |
|
|
||||||
|
|
||||||
Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux
|
|
||||||
и Windows буквы берутся по аппаратному keycode, поэтому `<C-q>` работает
|
|
||||||
и на русской раскладке.
|
|
||||||
|
|
||||||
#### Режимы
|
|
||||||
|
|
||||||
У каждого объекта своя текущая режимная метка: один символ (`n`, `i`, `v`, `r`
|
|
||||||
или любой другой) либо `-` — режим по умолчанию. Режимы динамические: заранее
|
|
||||||
создавать их не нужно, достаточно переключить метку.
|
|
||||||
|
|
||||||
| Значение `modes` | Когда срабатывает |
|
|
||||||
| --- | --- |
|
|
||||||
| `""` | во всех режимах |
|
|
||||||
| `"-"` | только в режиме по умолчанию |
|
|
||||||
| `"niv"` | в режимах `n`, `i`, `v` |
|
|
||||||
| `"-iv"` | в режиме по умолчанию, а также `i` и `v` |
|
|
||||||
|
|
||||||
Текущий режим меняется методами `set_mode(mode)` и читается методом `mode()`.
|
|
||||||
Режимы у приложения, редактора и каждого блока независимы.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app:set_mode("n")
|
|
||||||
app.editor:set_mode("i")
|
|
||||||
|
|
||||||
app:map_key("n", "<C-n>", "next_tab") -- только в режиме n
|
|
||||||
app.editor:map_key("i", "<C-s>", "save") -- только в режиме i редактора
|
|
||||||
app.editor:map_key("", "<C-space>", function() end) -- в любом режиме
|
|
||||||
```
|
|
||||||
|
|
||||||
Встроенные команды:
|
|
||||||
|
|
||||||
| Имя | Действие |
|
|
||||||
| --- | --- |
|
|
||||||
| `"save"` | Сохранить активную вкладку |
|
|
||||||
| `"next_tab"` | Следующая вкладка |
|
|
||||||
| `"previous_tab"` | Предыдущая вкладка |
|
|
||||||
| `"open_tab"` | Новая пустая вкладка |
|
|
||||||
| `"close_tab"` | Закрыть активную вкладку |
|
|
||||||
| `"toggle_left_panel"` | Переключить левую панель |
|
|
||||||
| `"toggle_right_panel"` | Переключить правую панель |
|
|
||||||
| `"toggle_bottom_panel"` | Переключить нижнюю панель |
|
|
||||||
|
|
||||||
Встроенные команды списка автодополнения:
|
|
||||||
|
|
||||||
| Имя | Действие |
|
|
||||||
| --- | --- |
|
|
||||||
| `"completion_trigger"` | Открыть список |
|
|
||||||
| `"completion_next"` / `"completion_previous"` | Сдвинуть выделение |
|
|
||||||
| `"completion_page_down"` / `"completion_page_up"` | Пролистать список |
|
|
||||||
| `"completion_accept"` | Вставить выбранный элемент |
|
|
||||||
| `"completion_cancel"` | Закрыть список |
|
|
||||||
| `"completion_dismiss"` | Закрыть список и передать клавишу редактору |
|
|
||||||
|
|
||||||
При запуске уже назначены:
|
|
||||||
|
|
||||||
- `Ctrl/Cmd+S` → `save` во всех режимах;
|
|
||||||
- `Ctrl/Cmd+Space` → `completion_trigger`;
|
|
||||||
- `Enter`/`Tab`, `Esc`, `↑`/`↓`, `PageUp`/`PageDown`, `←`/`→`, `Home`/`End`
|
|
||||||
→ команды слоя автодополнения.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
-- Переопределить сохранение своей функцией во всех режимах.
|
|
||||||
app:map_key("", "<C-s>", function() app.editor:save() end)
|
|
||||||
|
|
||||||
-- Убрать встроенную привязку.
|
|
||||||
app:map_key("", "<C-s>", nil)
|
|
||||||
|
|
||||||
-- Назначить встроенную команду на другую клавишу.
|
|
||||||
app:map_key("", "<C-n>", "next_tab")
|
|
||||||
app:map_key("", "<C-b>", "toggle_left_panel")
|
|
||||||
|
|
||||||
-- Навигация по списку автодополнения на Ctrl+N / Ctrl+P.
|
|
||||||
app:map_completion_key("<C-n>", "completion_next")
|
|
||||||
app:map_completion_key("<C-p>", "completion_previous")
|
|
||||||
|
|
||||||
-- Привязка, которая срабатывает только при фокусе в редакторе.
|
|
||||||
app.editor:map_key("", "<A-r>", function()
|
|
||||||
app.editor:set_active_text("editor mode")
|
|
||||||
end)
|
|
||||||
|
|
||||||
-- Привязка блока дерева: работает, когда фокус на этом дереве.
|
|
||||||
tree:map_key("v", "<A-f>", function() print("tree v") end)
|
|
||||||
```
|
|
||||||
|
|
||||||
Пока список открыт, его слой проверяется раньше действий редактора. Сочетания
|
|
||||||
с модификаторами и горизонтальные стрелки закрывают список, даже если для них
|
|
||||||
нет своей привязки, — так `Shift+↑` продолжает выделять текст.
|
|
||||||
|
|
||||||
### Окно
|
|
||||||
|
|
||||||
| Метод | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `app:close()` | Закрыть окно |
|
|
||||||
| `app:toggle_maximize()` | Развернуть окно / вернуть прежний размер |
|
|
||||||
| `app:set_maximized(true/false)` | Развернуть (`true`) или восстановить (`false`) окно |
|
|
||||||
| `app:minimize()` | Свернуть окно |
|
|
||||||
| `app:is_maximized()` | Развёрнуто ли окно прямо сейчас |
|
|
||||||
|
|
||||||
### Плавающее поле ввода
|
|
||||||
|
|
||||||
`app:open_input(options)` открывает однострочное поле ввода чуть выше
|
|
||||||
центра окна. Параметры передаются таблицей:
|
|
||||||
|
|
||||||
| Поле | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `title` | Текст над полем; если не задан, окно состоит из одной строки ввода |
|
|
||||||
| `placeholder` | Подсказка внутри пустого поля ввода |
|
|
||||||
| `callback` | `function(text)` — вызывается после Enter, когда окно уже закрыто |
|
|
||||||
|
|
||||||
Пока окно открыто, сочетания клавиш приложения не работают. Текст набирается
|
|
||||||
в поле; `Enter` подтверждает ввод и запускает `callback`, `Esc` и клик мышью
|
|
||||||
вне окна закрывают его без вызова колбэка.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app:open_input({
|
|
||||||
title = "Имя файла",
|
|
||||||
placeholder = "src/main.rs",
|
|
||||||
callback = function(text)
|
|
||||||
app.editor:open(text, {})
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## LSP-серверы
|
|
||||||
|
|
||||||
Запуск серверов целиком настраивается Lua-сценарием.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app.editor:run_lsp({
|
|
||||||
name = "gopls", -- условное имя для stop/restart
|
|
||||||
cmd = "gopls", -- путь к бинарнику или имя в PATH
|
|
||||||
args = { "serve" }, -- аргументы процесса
|
|
||||||
env = { GOFLAGS = "-mod=mod" }, -- переменные окружения
|
|
||||||
languages = { "go" }, -- какие languageId обслуживает
|
|
||||||
watcher = "**/*.go", -- шаблон файлового вотчера
|
|
||||||
root = ".", -- корень workspace (по умолчанию cwd)
|
|
||||||
initialization_options = {}, -- initializationOptions сервера
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
| Метод | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `app.editor:run_lsp(config)` | Запустить сервер по описанию |
|
|
||||||
| `app.editor:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` |
|
|
||||||
| `app.editor:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен |
|
|
||||||
| `app.editor:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками |
|
|
||||||
| `app.editor:on_lsp_diagnostics(delay_ms, fn)` | Обработчик диагностики всех серверов; `nil` вместо `fn` удаляет его |
|
|
||||||
|
|
||||||
`cmd` обязателен. Если `root` не задан или относителен, он отсчитывается от
|
|
||||||
текущего рабочего каталога процесса. Поле `watcher` принимает строку, таблицу
|
|
||||||
или boolean:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
watcher = "**/*.go"
|
|
||||||
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
|
|
||||||
watcher = true -- все файлы, игнорируя только стандартные каталоги сборки
|
|
||||||
```
|
|
||||||
|
|
||||||
Автодополнение включается само при вводе идентификатора; `Ctrl/Cmd+Space`
|
|
||||||
открывает список вручную. Поведение клавиш описано в разделе «Привязка
|
|
||||||
клавиш» и может быть переопределено через `app:map_completion_key`.
|
|
||||||
Документация выбранного элемента показывается второй всплывающей панелью
|
|
||||||
справа от списка.
|
|
||||||
|
|
||||||
Диагностику, которую сервер присылает после правок, редактор подчёркивает
|
|
||||||
волнистой линией: ошибки — красной, предупреждения и остальные сообщения —
|
|
||||||
жёлтой. Подчёркивание работает и при выключенной подсветке синтаксиса.
|
|
||||||
|
|
||||||
### Обработчик диагностики
|
|
||||||
|
|
||||||
`app.editor:on_lsp_diagnostics(delay_ms, fn)` регистрирует один обработчик на все
|
|
||||||
LSP-серверы. `delay_ms` — пауза тишины: если сообщения приходят чаще, вызовы
|
|
||||||
пропускаются, а обработчик получает последний снимок после того, как
|
|
||||||
обновления прекратились. Если зарегистрировать обработчик, когда диагностика
|
|
||||||
уже накоплена, он сработает сразу после этой паузы.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app.editor:on_lsp_diagnostics(1000, function(servers)
|
|
||||||
for _, entry in ipairs(servers) do
|
|
||||||
print(entry.server, entry.path, #entry.diagnostics)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
```
|
|
||||||
|
|
||||||
`servers` — массив записей по одной на пару «документ, сервер»:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
{
|
|
||||||
server = "gopls", -- имя сервера, приславшего диагностику
|
|
||||||
uri = "file:///project/main.go", -- URI документа
|
|
||||||
path = "/project/main.go", -- путь файла; nil для не-file URI
|
|
||||||
diagnostics = {
|
|
||||||
{
|
|
||||||
line = 0, character = 2, -- начало (с нуля, как в LSP)
|
|
||||||
end_line = 0, end_character = 8,
|
|
||||||
severity = 1, -- 1 ошибка, 2 предупреждение, 3 info, 4 hint
|
|
||||||
severity_name = "error", -- error | warning | information | hint
|
|
||||||
message = "undefined: foo",
|
|
||||||
source = "gopls",
|
|
||||||
code = "UndeclaredName",
|
|
||||||
tags = {}, -- unnecessary, deprecated
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Позиции нумеруются с нуля; для отображения прибавляйте единицу к `line` и
|
|
||||||
`character`. Остановка сервера удаляет его сообщения из следующих снимков.
|
|
||||||
|
|
||||||
Примеры запуска:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" },
|
|
||||||
languages = { "go" }, watcher = "**/*.go" })
|
|
||||||
|
|
||||||
app.editor:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer",
|
|
||||||
languages = { "rust" }, watcher = "**/*.rs" })
|
|
||||||
```
|
|
||||||
|
|
||||||
Пример из `init.lua` с защитой от ошибки запуска:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local gopls_ok, gopls_err = pcall(function()
|
|
||||||
app.editor:run_lsp({
|
|
||||||
name = "gopls",
|
|
||||||
cmd = "gopls",
|
|
||||||
args = { "serve" },
|
|
||||||
root = ".",
|
|
||||||
languages = { "go" },
|
|
||||||
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } },
|
|
||||||
initialization_options = {
|
|
||||||
usePlaceholders = true,
|
|
||||||
completeUnimported = true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
end)
|
|
||||||
if not gopls_ok then
|
|
||||||
print("[lsp] gopls не запущен: " .. tostring(gopls_err))
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
> `--config` меняет только каталог `init.lua`, поэтому `root = "."` указывает
|
|
||||||
> на текущий каталог запуска — проект должен быть текущим рабочим каталогом.
|
|
||||||
|
|
||||||
## Верхняя панель редактора
|
|
||||||
|
|
||||||
Полоса между вкладками и редактором. У панели две области: элементы левой
|
|
||||||
выстраиваются от левого края вправо, элементы правой — от правого края влево.
|
|
||||||
|
|
||||||
| Метод | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `app.toolbar:add_label(text, icon, background, id, side)` | Добавить лейбл |
|
|
||||||
| `app.toolbar:add_button(text, icon, background, id, side, fn)` | Добавить кнопку |
|
|
||||||
| `app.toolbar:hide(id)` | Скрыть элемент; `false`, если элемента с таким `id` нет |
|
|
||||||
| `app.toolbar:show(id)` | Снова показать скрытый элемент |
|
|
||||||
| `app.toolbar:clear()` | Удалить все элементы панели |
|
|
||||||
|
|
||||||
- `icon` — имя иконки Lucide (`play`, `settings`, `file`, ...) или `nil`.
|
|
||||||
- `background` — `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` или `nil` (прозрачный
|
|
||||||
фон). Цвет текста подбирается автоматически: тёмный на светлом фоне, светлый
|
|
||||||
на тёмном.
|
|
||||||
- `id` — идентификатор элемента для `hide` и `show`. Повторное добавление с тем
|
|
||||||
же `id` заменяет элемент, а не создаёт дубликат.
|
|
||||||
- `side` — `"left"` или `"right"`.
|
|
||||||
- `fn` — обработчик нажатия; вызывается без аргументов.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app.toolbar:add_label("Готово", nil, nil, "status", "left")
|
|
||||||
app.toolbar:add_label("UTF-8", nil, "#3f3f46", "encoding", "right")
|
|
||||||
app.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
|
|
||||||
print("Запуск")
|
|
||||||
end)
|
|
||||||
|
|
||||||
app.toolbar:hide("run")
|
|
||||||
app.toolbar:show("run")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Панели окна
|
|
||||||
|
|
||||||
По умолчанию панели закрыты, размер меняется перетаскиванием.
|
|
||||||
Для `app.left_panel`, `app.right_panel` и `app.bottom_panel` доступны одинаковые методы:
|
|
||||||
|
|
||||||
| Метод | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `app.left_panel:open()` | Открыть панель |
|
|
||||||
| `app.left_panel:close()` | Закрыть панель |
|
|
||||||
| `app.left_panel:toggle()` | Переключить панель; вернуть `true`, если панель открыта |
|
|
||||||
| `app.left_panel:is_open()` | Открыта ли панель |
|
|
||||||
|
|
||||||
```lua
|
|
||||||
app.left_panel:open()
|
|
||||||
app.right_panel:toggle()
|
|
||||||
app.bottom_panel:close()
|
|
||||||
```
|
|
||||||
|
|
||||||
Если у правой панели нет блоков, она показывает состояние LSP-серверов.
|
|
||||||
Блоки нижней панели показываются вкладками, как вкладки редактора; боковые
|
|
||||||
панели рисуют все свои блоки подряд и заголовки не показывают.
|
|
||||||
|
|
||||||
### Блоки панелей
|
|
||||||
|
|
||||||
Панель может содержать блоки. Блок создаётся через API панели и живёт вместе
|
|
||||||
с ней; сейчас поддерживается один вид блока — абстрактное дерево.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local tree = app.left_panel:add_tree()
|
|
||||||
local diagnostics = app.bottom_panel:add_tree("Диагностика")
|
|
||||||
```
|
|
||||||
|
|
||||||
Необязательный аргумент `add_tree` — заголовок вкладки. В нижней панели он
|
|
||||||
показывается на вкладке; в боковых панелях заголовок сохраняется, но не
|
|
||||||
рисуется. Без заголовка используется «Дерево». Новый блок становится
|
|
||||||
активной вкладкой нижней панели.
|
|
||||||
|
|
||||||
Редактор отвечает только за отрисовку дерева. Что означает каждый узел,
|
|
||||||
решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно.
|
|
||||||
Файловое дерево проекта в `init.lua` собирается из `app:list_dir`, но сам
|
|
||||||
редактор о файловой системе в блоке ничего не знает.
|
|
||||||
|
|
||||||
| Метод | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `tree:append(nodes, parent)` | Добавить узлы к `parent`; `parent = ""` — корень |
|
|
||||||
| `tree:clear()` | Удалить все узлы и сбросить выбор |
|
|
||||||
| `tree:remove(id)` | Удалить узел и всех потомков рекурсивно; `false`, если узла нет |
|
|
||||||
| `tree:on_toggle(fn)` | Обработчик раскрытия: `fn(expanded, id, loaded)` |
|
|
||||||
| `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` |
|
|
||||||
| `tree:selected()` | Идентификатор выбранной строки или `nil` |
|
|
||||||
| `tree:focus(id)` | Раскрыть всех родителей, выбрать узел и передать фокус дереву |
|
|
||||||
| `tree:map_key(modes, keys, handler)` | Привязка, которая действует, когда фокус на этом блоке |
|
|
||||||
| `tree:set_mode(mode)` | Переключить режим привязок блока |
|
|
||||||
| `tree:mode()` | Текущий режим привязок блока |
|
|
||||||
|
|
||||||
Узел — таблица:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
{
|
|
||||||
id = "src/main.rs", -- строковый идентификатор (путь файла)
|
|
||||||
label = "main.rs", -- отображаемый текст
|
|
||||||
icon = "file-code", -- имя иконки Lucide (необязательно)
|
|
||||||
expandable = false, -- раскрываемая ли нода (папка)
|
|
||||||
expanded = false, -- раскрыта ли она сейчас
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`append` добавляет узлы, а не заменяет дерево. Повторная передача того же `id`
|
|
||||||
обновляет описание узла, но не создаёт копию. `clear` удаляет всё дерево;
|
|
||||||
обработчики `on_toggle` и `on_click` продолжают действовать, поэтому дерево
|
|
||||||
удобно строить заново на каждое обновление данных.
|
|
||||||
|
|
||||||
Дети каждого уровня сортируются по `label` без учёта регистра. Сначала идут
|
|
||||||
раскрываемые узлы (`expandable = true`), затем конечные; внутри каждой группы —
|
|
||||||
по алфавиту.
|
|
||||||
|
|
||||||
В обработчик `on_toggle` приходит `loaded = true`, если дочерние узлы уже
|
|
||||||
добавляли в эту ноду; при первом раскрытии — `false`.
|
|
||||||
|
|
||||||
Клик по строке выбирает её. Выбранную строку можно перемещать стрелками
|
|
||||||
`↑`/`↓`; `↑`/`↓` двигают выбор, `→` раскрывает папку или переходит к первому
|
|
||||||
ребёнку, `←` сворачивает папку или выбирает родителя, `Enter` раскрывает папку
|
|
||||||
или вызывает `on_click` для конечной ноды. Выбор сохраняется, пока панель
|
|
||||||
закрыта; при повторном открытии панели фокус возвращается на выбранную строку.
|
|
||||||
Привязки блока (`tree:map_key`) обрабатываются дополнительно к глобальным и
|
|
||||||
только пока фокус находится на этом дереве; у каждого блока свой режим.
|
|
||||||
|
|
||||||
Пример отложенной загрузки дерева:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local tree = app.left_panel:add_tree()
|
|
||||||
|
|
||||||
local function load_dir(path)
|
|
||||||
local nodes = {}
|
|
||||||
for _, entry in ipairs(app:list_dir(path)) do
|
|
||||||
nodes[#nodes + 1] = {
|
|
||||||
id = entry.id,
|
|
||||||
label = entry.name,
|
|
||||||
icon = entry.is_dir and "folder" or "file",
|
|
||||||
expandable = entry.is_dir,
|
|
||||||
expanded = false,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
tree:append(nodes, path)
|
|
||||||
end
|
|
||||||
|
|
||||||
tree:on_toggle(function(expanded, id, loaded)
|
|
||||||
if expanded and not loaded then
|
|
||||||
load_dir(id)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
tree:on_click(function(id)
|
|
||||||
-- Фокус в редактор, курсор остаётся на прежнем месте.
|
|
||||||
app.editor:open(id, {})
|
|
||||||
end)
|
|
||||||
|
|
||||||
app.left_panel:open()
|
|
||||||
load_dir("")
|
|
||||||
|
|
||||||
-- При открытии панели вернуть фокус на последнюю выбранную строку.
|
|
||||||
app:map_key("", "<C-b>", function()
|
|
||||||
if app.left_panel:toggle() then
|
|
||||||
local selected = tree:selected()
|
|
||||||
if selected then
|
|
||||||
tree:focus(selected)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
```
|
|
||||||
|
|
||||||
Пример дерева диагностики в нижней панели. На первом уровне — файлы с
|
|
||||||
полным путём, на втором — сообщения с иконкой уровня. Сообщения с одинаковыми
|
|
||||||
началом и концом диапазона объединяются в одну запись; клик открывает файл
|
|
||||||
на нужной позиции.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local diagnostics = app.bottom_panel:add_tree("Диагностика")
|
|
||||||
|
|
||||||
local icons = {
|
|
||||||
error = "circle-x",
|
|
||||||
warning = "triangle-alert",
|
|
||||||
information = "circle-alert",
|
|
||||||
hint = "lightbulb",
|
|
||||||
}
|
|
||||||
|
|
||||||
app.editor:on_lsp_diagnostics(1000, function(servers)
|
|
||||||
diagnostics:clear()
|
|
||||||
|
|
||||||
local files = {}
|
|
||||||
local order = {}
|
|
||||||
for _, entry in ipairs(servers) do
|
|
||||||
local path = entry.path or entry.uri
|
|
||||||
if not files[path] then
|
|
||||||
files[path] = {}
|
|
||||||
order[#order + 1] = path
|
|
||||||
end
|
|
||||||
for _, item in ipairs(entry.diagnostics) do
|
|
||||||
table.insert(files[path], item)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
for _, path in ipairs(order) do
|
|
||||||
local groups = {}
|
|
||||||
local keys = {}
|
|
||||||
for _, item in ipairs(files[path]) do
|
|
||||||
local key = string.format("%d:%d:%d:%d", item.line, item.character,
|
|
||||||
item.end_line, item.end_character)
|
|
||||||
if not groups[key] then
|
|
||||||
groups[key] = { item = item, messages = {}, seen = {} }
|
|
||||||
keys[#keys + 1] = key
|
|
||||||
end
|
|
||||||
local group = groups[key]
|
|
||||||
if not group.seen[item.message] then
|
|
||||||
group.seen[item.message] = true
|
|
||||||
group.messages[#group.messages + 1] = item.message
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
diagnostics:append({
|
|
||||||
{ id = path, label = path, icon = "file",
|
|
||||||
expandable = true, expanded = true },
|
|
||||||
}, "")
|
|
||||||
|
|
||||||
local errors = {}
|
|
||||||
for index, key in ipairs(keys) do
|
|
||||||
local group = groups[key]
|
|
||||||
local item = group.item
|
|
||||||
local severity = item.severity_name or "error"
|
|
||||||
errors[#errors + 1] = {
|
|
||||||
id = string.format("diag:%s:%d:%d:%d", path, item.line,
|
|
||||||
item.character, index),
|
|
||||||
label = string.format("%d:%d %s", item.line + 1,
|
|
||||||
item.character + 1,
|
|
||||||
table.concat(group.messages, "; ")),
|
|
||||||
icon = icons[severity] or "circle-alert",
|
|
||||||
}
|
|
||||||
end
|
|
||||||
diagnostics:append(errors, path)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
diagnostics:on_click(function(id)
|
|
||||||
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
|
|
||||||
if path then
|
|
||||||
app.editor:open(path, { tonumber(line), tonumber(character) })
|
|
||||||
else
|
|
||||||
app.editor:open(id, {})
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
app.bottom_panel:open()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Подключение других Lua-файлов
|
|
||||||
|
|
||||||
Файлы подключаются из каталога сценариев:
|
|
||||||
|
|
||||||
| Способ | Описание |
|
|
||||||
| --- | --- |
|
|
||||||
| `include("tools.lua")` | Выполнить файл сразу |
|
|
||||||
| `require("tools")` | Выполнить один раз (как обычный Lua-модуль) |
|
|
||||||
|
|
||||||
## Добавление новых методов
|
|
||||||
|
|
||||||
Методы добавляются в `src/api.rs`: всё, что есть в `impl AppApi` или
|
|
||||||
`impl EditorApi`, помеченном `#[mlua::userdata_impl]`, автоматически доступно
|
|
||||||
из Lua как `app:method(...)` или `app.editor:method(...)`.
|
|
||||||
Чтобы не экспортировать метод, его помечают `#[lua(skip)]`.
|
|
||||||
Новый вид блока панели должен добавить методы в свой `*Api` и отдавать свою
|
|
||||||
`KeyMap`, как это делает `TreeApi`.
|
|
||||||
|
|
||||||
## Полный пример `init.lua`
|
|
||||||
|
|
||||||
```lua
|
|
||||||
print("[qode] init.lua выполнен")
|
|
||||||
|
|
||||||
app:map_key("", "<C-q>", function() app:close() end)
|
|
||||||
app:map_key("", "<C-e>", "toggle_left_panel")
|
|
||||||
app:map_key("", "<C-t>", "toggle_bottom_panel")
|
|
||||||
|
|
||||||
app.editor:run_lsp({
|
|
||||||
name = "rust-analyzer",
|
|
||||||
cmd = "rust-analyzer",
|
|
||||||
languages = { "rust" },
|
|
||||||
watcher = "**/*.rs",
|
|
||||||
})
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# qode — конструктор IDE на Lua
|
||||||
|
|
||||||
|
`qode` — настольный редактор кода на GPUI (`gpui-kit`), который расширяется
|
||||||
|
Lua-сценариями. При запуске приложение выполняет `init.lua` и предоставляет
|
||||||
|
сценарию объект `api`.
|
||||||
|
|
||||||
|
## Идея
|
||||||
|
|
||||||
|
Rust-часть даёт только базовые универсальные примитивы:
|
||||||
|
|
||||||
|
- окно и его элементы;
|
||||||
|
- вкладки, текстовый редактор, подсветка, gutter, автодополнение;
|
||||||
|
- панели и абстрактное дерево;
|
||||||
|
- плавающее поле ввода;
|
||||||
|
- файловая система;
|
||||||
|
- привязки клавиш;
|
||||||
|
- LSP-клиент по stdio.
|
||||||
|
|
||||||
|
Вся бизнес-логика IDE — файловое дерево, дерево диагностики, команды,
|
||||||
|
раскладки, проектные сценарии, оформление и поведение — живёт в Lua.
|
||||||
|
Если поведение зависит от проекта или рабочего процесса, его место в
|
||||||
|
`init.lua` или подключаемых модулях, а не в Rust.
|
||||||
|
|
||||||
|
Новую возможность в Rust стоит добавлять, только если это универсальный
|
||||||
|
примитив, нужный разным сценариям, или если её нельзя выразить через уже
|
||||||
|
существующее API (доступ к GPUI, ОС, протоколу LSP). Бизнес-правила
|
||||||
|
(какие файлы показывать, как создавать документы, что писать в статус,
|
||||||
|
какие команды запускать) остаются в Lua.
|
||||||
|
|
||||||
|
## Запуск и каталог сценариев
|
||||||
|
|
||||||
|
Каталог поиска `init.lua` задаётся константой `SCRIPT_DIR` в
|
||||||
|
`src/constants.rs` (по умолчанию — текущий каталог). Его можно переопределить
|
||||||
|
аргументом командной строки:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
qode --config /path/to/project main.go
|
||||||
|
cargo run -- --config ./config
|
||||||
|
cargo run -- main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
- В каталоге `--config` должны лежать `init.lua` и подключаемые им модули.
|
||||||
|
- Первый позиционный аргумент открывается после выполнения `init.lua`
|
||||||
|
через `api.editor:open(path)` — как `vim main.go` или `nano main.go`.
|
||||||
|
- `--config` меняет только каталог сценариев. Рабочий каталог процесса, а
|
||||||
|
вместе с ним пути `api.editor:open`, `api.editor:save`, `api.path:*` и
|
||||||
|
`root` у LSP не меняются.
|
||||||
|
|
||||||
|
## Объект `api`
|
||||||
|
|
||||||
|
Объект `api` разбит на группы. Методы вызываются через двоеточие:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.editor:open("src/main.rs")
|
||||||
|
api.editor:save()
|
||||||
|
api.panels.left:open()
|
||||||
|
api.path:list("src")
|
||||||
|
api.window:close()
|
||||||
|
api.keymap:map_key("", "<C-s>", "save")
|
||||||
|
```
|
||||||
|
|
||||||
|
| Группа | Назначение | Документация |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `api.editor` | Вкладки, текст, gutter, подсветка, сохранение, LSP, привязки редактора | [editor.md](editor.md) |
|
||||||
|
| `api.panels` | Левая, правая и нижняя панели, верхняя панель редактора, блоки-деревья | [panels.md](panels.md) |
|
||||||
|
| `api.path` | Файловая система: чтение, запись, создание и удаление | [path.md](path.md) |
|
||||||
|
| `api.window` | Окно и плавающее поле ввода | [window.md](window.md) |
|
||||||
|
| `api.keymap` | Глобальные привязки клавиш и режимы | [keymap.md](keymap.md) |
|
||||||
|
| `api.editor` (LSP) | Запуск серверов и обработка диагностики | [lsp.md](lsp.md) |
|
||||||
|
|
||||||
|
Номера вкладок во всех методах нумеруются с нуля. Массив, который возвращает
|
||||||
|
`api.editor:tabs()`, остаётся обычной Lua-таблицей с индексами от 1, поэтому
|
||||||
|
поле `index` — единственный надёжный способ сослаться на вкладку.
|
||||||
|
|
||||||
|
## Подключение других Lua-файлов
|
||||||
|
|
||||||
|
Файлы подключаются из каталога сценариев:
|
||||||
|
|
||||||
|
| Способ | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `include("tools.lua")` | Выполнить файл сразу |
|
||||||
|
| `require("tools")` | Выполнить один раз (как обычный Lua-модуль) |
|
||||||
|
|
||||||
|
## Добавление новых методов
|
||||||
|
|
||||||
|
Методы добавляются в `src/api.rs`. Публичные методы `impl`-блока с
|
||||||
|
`#[mlua::userdata_impl]` автоматически доступны из Lua:
|
||||||
|
|
||||||
|
- методы `AppApi` — у объекта `api`;
|
||||||
|
- методы `EditorApi` — у `api.editor`;
|
||||||
|
- методы `PanelApi` — у `api.panels.left`, `.right`, `.bottom`;
|
||||||
|
- методы `ToolbarApi` — у `api.panels.toolbar`;
|
||||||
|
- методы `PathApi` — у `api.path`;
|
||||||
|
- методы `WindowApi` — у `api.window`;
|
||||||
|
- методы `KeymapApi` — у `api.keymap`.
|
||||||
|
|
||||||
|
Чтобы не экспортировать метод, его помечают `#[lua(skip)]`.
|
||||||
|
Новый вид блока панели должен добавить методы в свой `*Api` и отдавать свою
|
||||||
|
`KeyMap`, как это делает `TreeApi`.
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
# Редактор — `api.editor`
|
||||||
|
|
||||||
|
Группа `api.editor` управляет вкладками, текстом, gutter, подсветкой и
|
||||||
|
сохранением. LSP-серверы описаны отдельно в [lsp.md](lsp.md), привязки
|
||||||
|
клавиш редактора — в [keymap.md](keymap.md).
|
||||||
|
|
||||||
|
Номера вкладок нумеруются с нуля.
|
||||||
|
|
||||||
|
## Вкладки
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `api.editor:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs", icon = "file" }, ...}` |
|
||||||
|
| `api.editor:active_tab()` | Индекс активной вкладки |
|
||||||
|
| `api.editor:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует |
|
||||||
|
| `api.editor:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
|
||||||
|
| `api.editor:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс |
|
||||||
|
| `api.editor:open_tab()` | Открыть пустую вкладку, вернуть индекс |
|
||||||
|
| `api.editor:open(path, position)` | Открыть файл с диска, вернуть индекс вкладки |
|
||||||
|
| `api.editor:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта |
|
||||||
|
| `api.editor:focus()` | Перевести фокус в редактор, не меняя вкладку и курсор |
|
||||||
|
|
||||||
|
Если файла нет, `open` создаёт пустой буфер с таким именем — как в vim.
|
||||||
|
Язык подсветки определяется по расширению, если для него есть грамматика.
|
||||||
|
Первый аргумент принимает и строку `"src/main.rs"`, и массив сегментов
|
||||||
|
`{ "src", "main.rs" }`.
|
||||||
|
|
||||||
|
Второй аргумент `open` необязателен:
|
||||||
|
|
||||||
|
| Значение | Поведение |
|
||||||
|
| --- | --- |
|
||||||
|
| нет аргумента / `nil` | Открыть вкладку, не трогая фокус |
|
||||||
|
| `{}` | Перевести фокус в редактор, курсор оставить на месте |
|
||||||
|
| `{line, column}` | Перевести фокус и поставить курсор (с нуля) |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.editor:open("src/main.rs") -- открыть, не трогая фокус
|
||||||
|
api.editor:open("src/main.rs", {}) -- фокус в редактор, курсор на месте
|
||||||
|
api.editor:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор на строку 4, столбец 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Строка и столбец нумеруются с нуля, как в диагностике LSP; столбец считается
|
||||||
|
в UTF-16.
|
||||||
|
|
||||||
|
## Иконки вкладок
|
||||||
|
|
||||||
|
Слева от названия вкладки показывается иконка; по умолчанию это `file`.
|
||||||
|
Имя — название иконки [Lucide](https://lucide.dev/icons/), регистр не важен.
|
||||||
|
Подчёркивания и пробелы считаются дефисом, а `icons/` и `.svg` можно не
|
||||||
|
писать: `file_code`, `file-code` и `icons/file-code.svg` равнозначны.
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `api.editor:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна |
|
||||||
|
| `api.editor:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) |
|
||||||
|
| `api.editor:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.editor:set_tab_icon(0, "file-code")
|
||||||
|
api.editor:set_tab_icon(1, "book-open")
|
||||||
|
api.editor:clear_tab_icon(1) -- снова "file"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Иконки на полях редактора (gutter)
|
||||||
|
|
||||||
|
Слева от редактора есть узкая колонка с иконками. Методы действуют на
|
||||||
|
активную вкладку, а номера строк нумеруются с нуля, как в LSP. На одной строке
|
||||||
|
живёт одна иконка; повторный вызов заменяет прежнюю.
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `api.editor:set_gutter_icon(line, icon, color)` | Поставить иконку на строку; `color` необязателен (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`). Без цвета иконка рисуется цветом темы. `false` — иконка или цвет неизвестны |
|
||||||
|
| `api.editor:gutter_icon(line)` | Имя иконки на строке или `nil` |
|
||||||
|
| `api.editor:gutter_color(line)` | Цвет иконки или `nil`, если цвет не задан |
|
||||||
|
| `api.editor:clear_gutter_icon(line)` | Убрать иконку со строки; `false`, если её не было |
|
||||||
|
| `api.editor:clear_gutter()` | Убрать все иконки активной вкладки |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.editor:set_gutter_icon(4, "circle", "#e5484d") -- красная точка на 5-й строке
|
||||||
|
api.editor:set_gutter_icon(9, "play") -- цвет темы
|
||||||
|
api.editor:clear_gutter_icon(9)
|
||||||
|
```
|
||||||
|
|
||||||
|
Иконки привязаны к номерам строк и автоматически не сдвигаются при правке
|
||||||
|
текста: сценарий сам решает, когда их переставить. При прокрутке колонка
|
||||||
|
двигается вместе с редактором.
|
||||||
|
|
||||||
|
## Текст
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `api.editor:active_text()` | Текст активной вкладки |
|
||||||
|
| `api.editor:set_active_text(text)` | Заменить текст активной вкладки |
|
||||||
|
|
||||||
|
## Сохранение
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `api.editor:save()` | Сохранить активную вкладку в её файл |
|
||||||
|
|
||||||
|
Путь считается относительно текущего рабочего каталога процесса. При успешной
|
||||||
|
записи с вкладки снимается маркер несохранённых изменений.
|
||||||
|
|
||||||
|
## Подсветка синтаксиса
|
||||||
|
|
||||||
|
Подсветка по умолчанию выключена. Поддерживаемые языки: `rust`, `go`, `js`,
|
||||||
|
`json`, `python`, `lua`, `bash`, `yaml`, `make`, `dockerfile`, `sql`,
|
||||||
|
`markdown`, `css`, `c`, `cpp`, `html`, `xml`. Для `set_language` принимаются
|
||||||
|
и короткие имена (`rs`, `golang`, `javascript`, `py`, `sh`, `yml`, `makefile`,
|
||||||
|
`md`, `c++`…).
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `api.editor:set_language(index, "rust")` | Включить подсветку для вкладки; `false`, если вкладки нет или язык не поддерживается |
|
||||||
|
| `api.editor:language(index)` | Язык подсветки вкладки или `nil` |
|
||||||
|
| `api.editor:clear_language(index)` | Выключить подсветку для вкладки |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.editor:set_language(0, "rust")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Привязки редактора
|
||||||
|
|
||||||
|
`api.editor:map_key`, `api.editor:set_mode` и `api.editor:mode` работают так
|
||||||
|
же, как глобальные привязки из [keymap.md](keymap.md), но срабатывают
|
||||||
|
дополнительно к глобальным и только когда фокус находится в редакторе.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.editor:map_key("", "<A-r>", function()
|
||||||
|
api.editor:set_active_text("editor mode")
|
||||||
|
end)
|
||||||
|
```
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
# Привязки клавиш — `api.keymap`
|
||||||
|
|
||||||
|
Привязки живут у трёх видов объектов:
|
||||||
|
|
||||||
|
- у приложения — `api.keymap` (глобальные привязки);
|
||||||
|
- у редактора — `api.editor` (срабатывают, когда фокус в редакторе);
|
||||||
|
- у каждого блока панели — `tree` (срабатывают, когда фокус на этом блоке).
|
||||||
|
|
||||||
|
Встроенные сочетания можно заменить или удалить: обработчиком принимается
|
||||||
|
Lua-функция, имя встроенной команды или `nil`.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.keymap:map_key(modes, keys, handler) -- глобальные
|
||||||
|
api.editor:map_key(modes, keys, handler) -- только когда фокус в редакторе
|
||||||
|
api.keymap:map_completion_key(keys, handler) -- список автодополнения
|
||||||
|
```
|
||||||
|
|
||||||
|
Слой автодополнения режимами не управляется: `map_completion_key` принимает
|
||||||
|
только клавишу и обработчик.
|
||||||
|
|
||||||
|
Порядок обработки всегда такой: сначала глобальные привязки, затем — если
|
||||||
|
фокус в редакторе — привязки редактора, а если фокус на блоке панели —
|
||||||
|
привязки этого блока. Привязки неактивного объекта не срабатывают.
|
||||||
|
|
||||||
|
## Запись клавиши
|
||||||
|
|
||||||
|
| Запись | Значение |
|
||||||
|
| --- | --- |
|
||||||
|
| `"a"` | нажатие A |
|
||||||
|
| `"1"` | нажатие 1 |
|
||||||
|
| `"!a"` | отпускание A |
|
||||||
|
| `"<S-c>"` | C при зажатом Shift |
|
||||||
|
| `"<C-z>"` | Z при зажатом Ctrl |
|
||||||
|
| `"<A-!r>"` | отпускание R при зажатом Alt |
|
||||||
|
| `"<D-s>"` | S при зажатом Cmd на macOS или Super на Linux |
|
||||||
|
|
||||||
|
Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux
|
||||||
|
и Windows буквы берутся по аппаратному keycode, поэтому `<C-q>` работает
|
||||||
|
и на русской раскладке.
|
||||||
|
|
||||||
|
## Режимы
|
||||||
|
|
||||||
|
У каждого объекта своя текущая режимная метка: один символ (`n`, `i`, `v`, `r`
|
||||||
|
или любой другой) либо `-` — режим по умолчанию. Режимы динамические: заранее
|
||||||
|
создавать их не нужно, достаточно переключить метку.
|
||||||
|
|
||||||
|
| Значение `modes` | Когда срабатывает |
|
||||||
|
| --- | --- |
|
||||||
|
| `""` | во всех режимах |
|
||||||
|
| `"-"` | только в режиме по умолчанию |
|
||||||
|
| `"niv"` | в режимах `n`, `i`, `v` |
|
||||||
|
| `"-iv"` | в режиме по умолчанию, а также `i` и `v` |
|
||||||
|
|
||||||
|
Текущий режим меняется методами `set_mode(mode)` и читается методом `mode()`.
|
||||||
|
Режимы у приложения, редактора и каждого блока независимы.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.keymap:set_mode("n")
|
||||||
|
api.editor:set_mode("i")
|
||||||
|
|
||||||
|
api.keymap:map_key("n", "<C-n>", "next_tab") -- только в режиме n
|
||||||
|
api.editor:map_key("i", "<C-s>", "save") -- только в режиме i редактора
|
||||||
|
api.editor:map_key("", "<C-space>", function() end) -- в любом режиме
|
||||||
|
```
|
||||||
|
|
||||||
|
## Встроенные команды
|
||||||
|
|
||||||
|
| Имя | Действие |
|
||||||
|
| --- | --- |
|
||||||
|
| `"save"` | Сохранить активную вкладку |
|
||||||
|
| `"next_tab"` | Следующая вкладка |
|
||||||
|
| `"previous_tab"` | Предыдущая вкладка |
|
||||||
|
| `"open_tab"` | Новая пустая вкладка |
|
||||||
|
| `"close_tab"` | Закрыть активную вкладку |
|
||||||
|
| `"toggle_left_panel"` | Переключить левую панель |
|
||||||
|
| `"toggle_right_panel"` | Переключить правую панель |
|
||||||
|
| `"toggle_bottom_panel"` | Переключить нижнюю панель |
|
||||||
|
|
||||||
|
Встроенные команды списка автодополнения:
|
||||||
|
|
||||||
|
| Имя | Действие |
|
||||||
|
| --- | --- |
|
||||||
|
| `"completion_trigger"` | Открыть список |
|
||||||
|
| `"completion_next"` / `"completion_previous"` | Сдвинуть выделение |
|
||||||
|
| `"completion_page_down"` / `"completion_page_up"` | Пролистать список |
|
||||||
|
| `"completion_accept"` | Вставить выбранный элемент |
|
||||||
|
| `"completion_cancel"` | Закрыть список |
|
||||||
|
| `"completion_dismiss"` | Закрыть список и передать клавишу редактору |
|
||||||
|
|
||||||
|
При запуске уже назначены:
|
||||||
|
|
||||||
|
- `Ctrl/Cmd+S` → `save` во всех режимах;
|
||||||
|
- `Ctrl/Cmd+Space` → `completion_trigger`;
|
||||||
|
- `Enter`/`Tab`, `Esc`, `↑`/`↓`, `PageUp`/`PageDown`, `←`/`→`, `Home`/`End`
|
||||||
|
→ команды слоя автодополнения.
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Переопределить сохранение своей функцией во всех режимах.
|
||||||
|
api.keymap:map_key("", "<C-s>", function() api.editor:save() end)
|
||||||
|
|
||||||
|
-- Убрать встроенную привязку.
|
||||||
|
api.keymap:map_key("", "<C-s>", nil)
|
||||||
|
|
||||||
|
-- Назначить встроенную команду на другую клавишу.
|
||||||
|
api.keymap:map_key("", "<C-n>", "next_tab")
|
||||||
|
api.keymap:map_key("", "<C-b>", "toggle_left_panel")
|
||||||
|
|
||||||
|
-- Навигация по списку автодополнения на Ctrl+N / Ctrl+P.
|
||||||
|
api.keymap:map_completion_key("<C-n>", "completion_next")
|
||||||
|
api.keymap:map_completion_key("<C-p>", "completion_previous")
|
||||||
|
|
||||||
|
-- Привязка, которая срабатывает только при фокусе в редакторе.
|
||||||
|
api.editor:map_key("", "<A-r>", function()
|
||||||
|
api.editor:set_active_text("editor mode")
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Привязка блока дерева: работает, когда фокус на этом дереве.
|
||||||
|
tree:map_key("v", "<A-f>", function() print("tree v") end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Пока список автодополнения открыт, его слой проверяется раньше действий
|
||||||
|
редактора. Сочетания с модификаторами и горизонтальные стрелки закрывают
|
||||||
|
список, даже если для них нет своей привязки, — так `Shift+↑` продолжает
|
||||||
|
выделять текст.
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
# LSP-серверы — `api.editor`
|
||||||
|
|
||||||
|
Запуск серверов целиком настраивается Lua-сценарием. Редактор предоставляет
|
||||||
|
только клиент по stdio, синхронизацию документов и доставку диагностики.
|
||||||
|
|
||||||
|
## Запуск сервера
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.editor:run_lsp({
|
||||||
|
name = "gopls", -- условное имя для stop/restart
|
||||||
|
cmd = "gopls", -- путь к бинарнику или имя в PATH
|
||||||
|
args = { "serve" }, -- аргументы процесса
|
||||||
|
env = { GOFLAGS = "-mod=mod" }, -- переменные окружения
|
||||||
|
languages = { "go" }, -- какие languageId обслуживает
|
||||||
|
watcher = "**/*.go", -- шаблон файлового вотчера
|
||||||
|
root = ".", -- корень workspace (по умолчанию cwd)
|
||||||
|
initialization_options = {}, -- initializationOptions сервера
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `api.editor:run_lsp(config)` | Запустить сервер по описанию |
|
||||||
|
| `api.editor:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` |
|
||||||
|
| `api.editor:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен |
|
||||||
|
| `api.editor:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками |
|
||||||
|
| `api.editor:on_lsp_diagnostics(delay_ms, fn)` | Обработчик диагностики всех серверов; `nil` вместо `fn` удаляет его |
|
||||||
|
|
||||||
|
`cmd` обязателен. Если `root` не задан или относителен, он отсчитывается от
|
||||||
|
текущего рабочего каталога процесса. Поле `watcher` принимает строку, таблицу
|
||||||
|
или boolean:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
watcher = "**/*.go"
|
||||||
|
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }
|
||||||
|
watcher = true -- все файлы, игнорируя только стандартные каталоги сборки
|
||||||
|
```
|
||||||
|
|
||||||
|
> `--config` меняет только каталог `init.lua`, поэтому `root = "."` указывает
|
||||||
|
> на текущий каталог запуска — проект должен быть текущим рабочим каталогом.
|
||||||
|
|
||||||
|
## Автодополнение и подсказки
|
||||||
|
|
||||||
|
Автодополнение включается само при вводе идентификатора; `Ctrl/Cmd+Space`
|
||||||
|
открывает список вручную. Поведение клавиш описано в [keymap.md](keymap.md) и
|
||||||
|
может быть переопределено через `api.keymap:map_completion_key`.
|
||||||
|
Документация выбранного элемента показывается второй всплывающей панелью
|
||||||
|
справа от списка. Наведение мыши показывает Markdown-документацию символа.
|
||||||
|
|
||||||
|
Диагностику, которую сервер присылает после правок, редактор подчёркивает
|
||||||
|
волнистой линией: ошибки — красной, предупреждения и остальные сообщения —
|
||||||
|
жёлтой. Подчёркивание работает и при выключенной подсветке синтаксиса.
|
||||||
|
|
||||||
|
## Обработчик диагностики
|
||||||
|
|
||||||
|
`api.editor:on_lsp_diagnostics(delay_ms, fn)` регистрирует один обработчик на
|
||||||
|
все LSP-серверы. `delay_ms` — пауза тишины: если сообщения приходят чаще,
|
||||||
|
вызовы пропускаются, а обработчик получает последний снимок после того, как
|
||||||
|
обновления прекратились. Если зарегистрировать обработчик, когда диагностика
|
||||||
|
уже накоплена, он сработает сразу после этой паузы.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.editor:on_lsp_diagnostics(1000, function(servers)
|
||||||
|
for _, entry in ipairs(servers) do
|
||||||
|
print(entry.server, entry.path, #entry.diagnostics)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
`servers` — массив записей по одной на пару «документ, сервер»:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
server = "gopls", -- имя сервера, приславшего диагностику
|
||||||
|
uri = "file:///project/main.go", -- URI документа
|
||||||
|
path = "/project/main.go", -- путь файла; nil для не-file URI
|
||||||
|
diagnostics = {
|
||||||
|
{
|
||||||
|
line = 0, character = 2, -- начало (с нуля, как в LSP)
|
||||||
|
end_line = 0, end_character = 8,
|
||||||
|
severity = 1, -- 1 ошибка, 2 предупреждение, 3 info, 4 hint
|
||||||
|
severity_name = "error", -- error | warning | information | hint
|
||||||
|
message = "undefined: foo",
|
||||||
|
source = "gopls",
|
||||||
|
code = "UndeclaredName",
|
||||||
|
tags = {}, -- unnecessary, deprecated
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Позиции нумеруются с нуля; для отображения прибавляйте единицу к `line` и
|
||||||
|
`character`. Остановка сервера удаляет его сообщения из следующих снимков.
|
||||||
|
|
||||||
|
Пример из `init.lua` с защитой от ошибки запуска:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local gopls_ok, gopls_err = pcall(function()
|
||||||
|
api.editor:run_lsp({
|
||||||
|
name = "gopls",
|
||||||
|
cmd = "gopls",
|
||||||
|
args = { "serve" },
|
||||||
|
root = ".",
|
||||||
|
languages = { "go" },
|
||||||
|
watcher = { pattern = "**/*.go", ignore = { "vendor/**" } },
|
||||||
|
initialization_options = {
|
||||||
|
usePlaceholders = true,
|
||||||
|
completeUnimported = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
if not gopls_ok then
|
||||||
|
print("[lsp] gopls не запущен: " .. tostring(gopls_err))
|
||||||
|
end
|
||||||
|
```
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
# Панели — `api.panels`
|
||||||
|
|
||||||
|
Группа `api.panels` управляет панелями окна и верхней панелью редактора:
|
||||||
|
|
||||||
|
| Объект | Назначение |
|
||||||
|
| --- | --- |
|
||||||
|
| `api.panels.left` | Левая панель |
|
||||||
|
| `api.panels.right` | Правая панель |
|
||||||
|
| `api.panels.bottom` | Нижняя панель |
|
||||||
|
| `api.panels.toolbar` | Верхняя панель редактора: лейблы и кнопки |
|
||||||
|
|
||||||
|
По умолчанию панели закрыты, размер меняется перетаскиванием разделителя.
|
||||||
|
Боковые панели рисуют все свои блоки подряд и не показывают заголовки; блоки
|
||||||
|
нижней панели показываются вкладками.
|
||||||
|
|
||||||
|
Если у правой панели нет блоков, она показывает состояние LSP-серверов.
|
||||||
|
|
||||||
|
## Панели
|
||||||
|
|
||||||
|
Для `api.panels.left`, `api.panels.right` и `api.panels.bottom` доступны
|
||||||
|
одинаковые методы:
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `api.panels.left:open()` | Открыть панель |
|
||||||
|
| `api.panels.left:close()` | Закрыть панель |
|
||||||
|
| `api.panels.left:toggle()` | Переключить панель; вернуть `true`, если панель открыта |
|
||||||
|
| `api.panels.left:is_open()` | Открыта ли панель |
|
||||||
|
| `api.panels.left:add_tree(title)` | Добавить блок-дерево и вернуть его API |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.panels.left:open()
|
||||||
|
api.panels.right:toggle()
|
||||||
|
api.panels.bottom:close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Верхняя панель редактора
|
||||||
|
|
||||||
|
Полоса между вкладками и редактором. У панели две области: элементы левой
|
||||||
|
выстраиваются от левого края вправо, элементы правой — от правого края влево.
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `api.panels.toolbar:add_label(text, icon, background, id, side)` | Добавить лейбл |
|
||||||
|
| `api.panels.toolbar:add_button(text, icon, background, id, side, fn)` | Добавить кнопку |
|
||||||
|
| `api.panels.toolbar:hide(id)` | Скрыть элемент; `false`, если элемента с таким `id` нет |
|
||||||
|
| `api.panels.toolbar:show(id)` | Снова показать скрытый элемент |
|
||||||
|
| `api.panels.toolbar:clear()` | Удалить все элементы панели |
|
||||||
|
|
||||||
|
- `icon` — имя иконки Lucide (`play`, `settings`, `file`, ...) или `nil`.
|
||||||
|
- `background` — `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` или `nil` (прозрачный
|
||||||
|
фон). Цвет текста подбирается автоматически: тёмный на светлом фоне, светлый
|
||||||
|
на тёмном.
|
||||||
|
- `id` — идентификатор элемента для `hide` и `show`. Повторное добавление с тем
|
||||||
|
же `id` заменяет элемент, а не создаёт дубликат.
|
||||||
|
- `side` — `"left"` или `"right"`.
|
||||||
|
- `fn` — обработчик нажатия; вызывается без аргументов.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
api.panels.toolbar:add_label("Готово", nil, nil, "status", "left")
|
||||||
|
api.panels.toolbar:add_label("UTF-8", nil, "#3f3f46", "encoding", "right")
|
||||||
|
api.panels.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
|
||||||
|
print("Запуск")
|
||||||
|
end)
|
||||||
|
|
||||||
|
api.panels.toolbar:hide("run")
|
||||||
|
api.panels.toolbar:show("run")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Блоки панелей
|
||||||
|
|
||||||
|
Панель может содержать блоки. Блок создаётся через API панели и живёт вместе
|
||||||
|
с ней; сейчас поддерживается один вид блока — абстрактное дерево.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local tree = api.panels.left:add_tree()
|
||||||
|
local diagnostics = api.panels.bottom:add_tree("Диагностика")
|
||||||
|
```
|
||||||
|
|
||||||
|
Необязательный аргумент `add_tree` — заголовок вкладки. В нижней панели он
|
||||||
|
показывается на вкладке; в боковых панелях заголовок сохраняется, но не
|
||||||
|
рисуется. Без заголовка используется «Дерево». Новый блок становится
|
||||||
|
активной вкладкой нижней панели.
|
||||||
|
|
||||||
|
Редактор отвечает только за отрисовку дерева. Что означает каждый узел,
|
||||||
|
решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно.
|
||||||
|
Файловое дерево проекта в `init.lua` собирается из `api.path:list`, но сам
|
||||||
|
редактор о файловой системе в блоке ничего не знает.
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `tree:append(nodes, parent)` | Добавить узлы к `parent`; `parent = ""` — корень |
|
||||||
|
| `tree:clear()` | Удалить все узлы и сбросить выбор |
|
||||||
|
| `tree:remove(id)` | Удалить узел и всех потомков рекурсивно; `false`, если узла нет |
|
||||||
|
| `tree:on_toggle(fn)` | Обработчик раскрытия: `fn(expanded, id, loaded)` |
|
||||||
|
| `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` |
|
||||||
|
| `tree:selected()` | Идентификатор выбранной строки или `nil` |
|
||||||
|
| `tree:focus(id)` | Раскрыть всех родителей, выбрать узел и передать фокус дереву |
|
||||||
|
| `tree:focus()` | Передать фокус дереву, не меняя выбранную строку |
|
||||||
|
| `tree:has_focus()` | Владеет ли дерево клавиатурным фокусом прямо сейчас |
|
||||||
|
| `tree:map_key(modes, keys, handler)` | Привязка, которая действует, когда фокус на этом блоке |
|
||||||
|
| `tree:set_mode(mode)` | Переключить режим привязок блока |
|
||||||
|
| `tree:mode()` | Текущий режим привязок блока |
|
||||||
|
|
||||||
|
Узел — таблица:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
id = "src/main.rs", -- строковый идентификатор (путь файла)
|
||||||
|
label = "main.rs", -- отображаемый текст
|
||||||
|
icon = "file-code", -- имя иконки Lucide (необязательно)
|
||||||
|
expandable = false, -- раскрываемая ли нода (папка)
|
||||||
|
expanded = false, -- раскрыта ли она сейчас
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`append` добавляет узлы, а не заменяет дерево. Повторная передача того же `id`
|
||||||
|
обновляет описание узла, но не создаёт копию. `clear` удаляет всё дерево;
|
||||||
|
обработчики `on_toggle` и `on_click` продолжают действовать, поэтому дерево
|
||||||
|
удобно строить заново на каждое обновление данных.
|
||||||
|
|
||||||
|
Дети каждого уровня сортируются по `label` без учёта регистра. Сначала идут
|
||||||
|
раскрываемые узлы (`expandable = true`), затем конечные; внутри каждой группы —
|
||||||
|
по алфавиту.
|
||||||
|
|
||||||
|
В обработчик `on_toggle` приходит `loaded = true`, если дочерние узлы уже
|
||||||
|
добавляли в эту ноду; при первом раскрытии — `false`.
|
||||||
|
|
||||||
|
Клик по строке выбирает её. Выбранную строку можно перемещать стрелками
|
||||||
|
`↑`/`↓`; `→` раскрывает папку или переходит к первому ребёнку, `←` сворачивает
|
||||||
|
папку или выбирает родителя, `Enter` раскрывает папку или вызывает `on_click`
|
||||||
|
для конечной ноды. Выбор сохраняется, пока панель закрыта; при повторном
|
||||||
|
открытии панели фокус возвращается на выбранную строку. `tree:focus()` без
|
||||||
|
аргумента передаёт фокус дереву, даже если ни одна строка ещё не выбрана.
|
||||||
|
`tree:has_focus()` возвращает состояние фокуса на момент последнего события
|
||||||
|
клавиатуры — его удобно проверять в глобальных обработчиках.
|
||||||
|
|
||||||
|
## Пример отложенной загрузки дерева
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local tree = api.panels.left:add_tree()
|
||||||
|
|
||||||
|
local function load_dir(path)
|
||||||
|
local nodes = {}
|
||||||
|
for _, entry in ipairs(api.path:list(path)) do
|
||||||
|
nodes[#nodes + 1] = {
|
||||||
|
id = entry.id,
|
||||||
|
label = entry.name,
|
||||||
|
icon = entry.is_dir and "folder" or "file",
|
||||||
|
expandable = entry.is_dir,
|
||||||
|
expanded = false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
tree:append(nodes, path)
|
||||||
|
end
|
||||||
|
|
||||||
|
tree:on_toggle(function(expanded, id, loaded)
|
||||||
|
if expanded and not loaded then
|
||||||
|
load_dir(id)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
tree:on_click(function(id)
|
||||||
|
-- Фокус в редактор, курсор остаётся на прежнем месте.
|
||||||
|
api.editor:open(id, {})
|
||||||
|
end)
|
||||||
|
|
||||||
|
api.panels.left:open()
|
||||||
|
load_dir("")
|
||||||
|
|
||||||
|
-- При открытии панели вернуть фокус на последнюю выбранную строку.
|
||||||
|
api.keymap:map_key("", "<C-b>", function()
|
||||||
|
if api.panels.left:toggle() then
|
||||||
|
local selected = tree:selected()
|
||||||
|
if selected then
|
||||||
|
tree:focus(selected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пример дерева диагностики
|
||||||
|
|
||||||
|
На первом уровне — файлы с полным путём, на втором — сообщения с иконкой
|
||||||
|
уровня. Сообщения с одинаковыми началом и концом диапазона объединяются в одну
|
||||||
|
запись; клик открывает файл на нужной позиции. Источник данных — обработчик
|
||||||
|
`api.editor:on_lsp_diagnostics`, описанный в [lsp.md](lsp.md).
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local diagnostics = api.panels.bottom:add_tree("Диагностика")
|
||||||
|
|
||||||
|
local icons = {
|
||||||
|
error = "circle-x",
|
||||||
|
warning = "triangle-alert",
|
||||||
|
information = "circle-alert",
|
||||||
|
hint = "lightbulb",
|
||||||
|
}
|
||||||
|
|
||||||
|
api.editor:on_lsp_diagnostics(1000, function(servers)
|
||||||
|
diagnostics:clear()
|
||||||
|
|
||||||
|
local files = {}
|
||||||
|
local order = {}
|
||||||
|
for _, entry in ipairs(servers) do
|
||||||
|
local path = entry.path or entry.uri
|
||||||
|
if not files[path] then
|
||||||
|
files[path] = {}
|
||||||
|
order[#order + 1] = path
|
||||||
|
end
|
||||||
|
for _, item in ipairs(entry.diagnostics) do
|
||||||
|
table.insert(files[path], item)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, path in ipairs(order) do
|
||||||
|
local groups = {}
|
||||||
|
local keys = {}
|
||||||
|
for _, item in ipairs(files[path]) do
|
||||||
|
local key = string.format("%d:%d:%d:%d", item.line, item.character,
|
||||||
|
item.end_line, item.end_character)
|
||||||
|
if not groups[key] then
|
||||||
|
groups[key] = { item = item, messages = {}, seen = {} }
|
||||||
|
keys[#keys + 1] = key
|
||||||
|
end
|
||||||
|
local group = groups[key]
|
||||||
|
if not group.seen[item.message] then
|
||||||
|
group.seen[item.message] = true
|
||||||
|
group.messages[#group.messages + 1] = item.message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
diagnostics:append({
|
||||||
|
{ id = path, label = path, icon = "file",
|
||||||
|
expandable = true, expanded = true },
|
||||||
|
}, "")
|
||||||
|
|
||||||
|
local errors = {}
|
||||||
|
for index, key in ipairs(keys) do
|
||||||
|
local group = groups[key]
|
||||||
|
local item = group.item
|
||||||
|
local severity = item.severity_name or "error"
|
||||||
|
errors[#errors + 1] = {
|
||||||
|
id = string.format("diag:%s:%d:%d:%d", path, item.line,
|
||||||
|
item.character, index),
|
||||||
|
label = string.format("%d:%d %s", item.line + 1,
|
||||||
|
item.character + 1,
|
||||||
|
table.concat(group.messages, "; ")),
|
||||||
|
icon = icons[severity] or "circle-alert",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
diagnostics:append(errors, path)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
diagnostics:on_click(function(id)
|
||||||
|
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
|
||||||
|
if path then
|
||||||
|
api.editor:open(path, { tonumber(line), tonumber(character) })
|
||||||
|
else
|
||||||
|
api.editor:open(id, {})
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
api.panels.bottom:open()
|
||||||
|
```
|
||||||
+125
@@ -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")
|
||||||
|
```
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
```
|
||||||
@@ -10,15 +10,31 @@
|
|||||||
--
|
--
|
||||||
-- В каталоге --config должны лежать init.lua и подключаемые им модули.
|
-- В каталоге --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 выполнен")
|
print("[lua] init.lua выполнен")
|
||||||
|
|
||||||
-- Объект app: окно, панели и глобальные привязки клавиш:
|
-- qode — конструктор IDE: Rust даёт универсальные примитивы (окно, вкладки,
|
||||||
-- app:list_dir(path) -- {{ id = "src/main.rs", name = "main.rs",
|
-- редактор, панели, дерево, 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 }, ...}; пустой путь —
|
-- -- is_dir = false }, ...}; пустой путь —
|
||||||
-- -- текущий каталог, id — путь от него
|
-- -- текущий каталог, 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 (удалить)
|
-- -- функция, имя команды или nil (удалить)
|
||||||
-- -- modes — строка режимов:
|
-- -- modes — строка режимов:
|
||||||
@@ -40,20 +56,20 @@ print("[lua] init.lua выполнен")
|
|||||||
-- -- Caps Lock не важен; на Linux и Windows буквы
|
-- -- Caps Lock не важен; на Linux и Windows буквы
|
||||||
-- -- берутся по аппаратному keycode, поэтому
|
-- -- берутся по аппаратному keycode, поэтому
|
||||||
-- -- <C-q> работает и на русской раскладке
|
-- -- <C-q> работает и на русской раскладке
|
||||||
-- app:map_completion_key(keys, handler)
|
-- api.keymap:map_completion_key(keys, handler)
|
||||||
-- -- то же для списка автодополнения; команды:
|
-- -- то же для списка автодополнения; команды:
|
||||||
-- -- completion_trigger, completion_next,
|
-- -- completion_trigger, completion_next,
|
||||||
-- -- completion_previous, completion_page_up,
|
-- -- completion_previous, completion_page_up,
|
||||||
-- -- completion_page_down, completion_accept,
|
-- -- completion_page_down, completion_accept,
|
||||||
-- -- completion_cancel, completion_dismiss
|
-- -- completion_cancel, completion_dismiss
|
||||||
-- app:set_mode(mode) -- режим глобальных привязок (один символ)
|
-- api.keymap:set_mode(mode) -- режим глобальных привязок (один символ)
|
||||||
-- app:mode() -- текущий режим глобальных привязок
|
-- api.keymap:mode() -- текущий режим глобальных привязок
|
||||||
-- app:close() -- закрыть окно
|
-- api.window:close() -- закрыть окно
|
||||||
-- app:toggle_maximize()
|
-- api.window:toggle_maximize()
|
||||||
-- app:set_maximized(true/false)
|
-- api.window:set_maximized(true/false)
|
||||||
-- app:minimize()
|
-- api.window:minimize()
|
||||||
-- app:is_maximized()
|
-- api.window:is_maximized()
|
||||||
-- app:open_input(options) -- плавающее поле ввода чуть выше центра;
|
-- api.window:open_input(options) -- плавающее поле ввода чуть выше центра;
|
||||||
-- -- options = { title = "...",
|
-- -- options = { title = "...",
|
||||||
-- -- placeholder = "...",
|
-- -- placeholder = "...",
|
||||||
-- -- callback = function(text) ... end };
|
-- -- callback = function(text) ... end };
|
||||||
@@ -61,52 +77,56 @@ print("[lua] init.lua выполнен")
|
|||||||
-- -- Esc или клик вне окна закрывают без него;
|
-- -- Esc или клик вне окна закрывают без него;
|
||||||
-- -- пока окно открыто, привязки клавиш молчат
|
-- -- пока окно открыто, привязки клавиш молчат
|
||||||
--
|
--
|
||||||
-- Редактор app.editor (номера вкладок — с нуля):
|
-- Редактор api.editor (номера вкладок — с нуля):
|
||||||
-- app.editor:tabs() -- {{ index = 0, title = "main.rs",
|
-- api.editor:tabs() -- {{ index = 0, title = "main.rs",
|
||||||
-- -- icon = "file" }, ...}
|
-- -- icon = "file" }, ...}
|
||||||
-- app.editor:active_tab() -- индекс активной вкладки
|
-- api.editor:active_tab() -- индекс активной вкладки
|
||||||
-- app.editor:set_active_tab(index) -- true, если вкладка существует
|
-- api.editor:set_active_tab(index) -- true, если вкладка существует
|
||||||
-- app.editor:next_tab() -- переключить на следующую, вернуть индекс
|
-- api.editor:next_tab() -- переключить на следующую, вернуть индекс
|
||||||
-- app.editor:previous_tab() -- переключить на предыдущую, вернуть индекс
|
-- api.editor:previous_tab() -- переключить на предыдущую, вернуть индекс
|
||||||
-- app.editor:open_tab() -- открыть пустую вкладку, вернуть индекс
|
-- api.editor:open_tab() -- открыть пустую вкладку, вернуть индекс
|
||||||
-- app.editor:open(path) -- открыть файл с диска, вернуть индекс вкладки;
|
-- 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 (с нуля)
|
-- -- курсор на строку m и столбец n (с нуля)
|
||||||
-- app.editor:close_tab(index) -- true, если вкладка закрыта
|
-- api.editor:close_tab(index) -- true, если вкладка закрыта
|
||||||
-- app.editor:set_tab_icon(i, name) -- иконка вкладки из каталога Lucide
|
-- api.editor:set_tab_icon(i, name) -- иконка вкладки из каталога Lucide
|
||||||
-- -- (file, file-code, book-open, ...);
|
-- -- (file, file-code, book-open, ...);
|
||||||
-- -- false — нет вкладки или иконки
|
-- -- false — нет вкладки или иконки
|
||||||
-- app.editor:clear_tab_icon(i) -- вернуть иконку по умолчанию (file)
|
-- api.editor:clear_tab_icon(i) -- вернуть иконку по умолчанию (file)
|
||||||
-- app.editor:tab_icon(i) -- имя иконки вкладки или nil
|
-- api.editor:tab_icon(i) -- имя иконки вкладки или nil
|
||||||
-- app.editor:set_gutter_icon(line, name[, color])
|
-- api.editor:set_gutter_icon(line, name[, color])
|
||||||
-- -- иконка на поле слева от редактора
|
-- -- иконка на поле слева от редактора
|
||||||
-- -- (активная вкладка, строка с нуля);
|
-- -- (активная вкладка, строка с нуля);
|
||||||
-- -- color — #RGB/#RGBA/#RRGGBB/#RRGGBBAA;
|
-- -- color — #RGB/#RGBA/#RRGGBB/#RRGGBBAA;
|
||||||
-- -- false — неизвестная иконка или цвет
|
-- -- false — неизвестная иконка или цвет
|
||||||
-- app.editor:gutter_icon(line) -- имя иконки gutter или nil
|
-- api.editor:gutter_icon(line) -- имя иконки gutter или nil
|
||||||
-- app.editor:gutter_color(line) -- цвет иконки gutter или nil
|
-- api.editor:gutter_color(line) -- цвет иконки gutter или nil
|
||||||
-- app.editor:clear_gutter_icon(line) -- убрать иконку со строки
|
-- api.editor:clear_gutter_icon(line) -- убрать иконку со строки
|
||||||
-- app.editor:clear_gutter() -- убрать все иконки активной вкладки
|
-- api.editor:clear_gutter() -- убрать все иконки активной вкладки
|
||||||
-- app.editor:active_text() -- текст активной вкладки
|
-- api.editor:active_text() -- текст активной вкладки
|
||||||
-- app.editor:set_active_text(text) -- заменить текст активной вкладки
|
-- api.editor:set_active_text(text) -- заменить текст активной вкладки
|
||||||
-- app.editor:set_language(i, "rust") -- включить подсветку
|
-- api.editor:set_language(i, "rust") -- включить подсветку
|
||||||
-- -- (rust|go|js|json|python) для вкладки i;
|
-- -- (rust|go|js|json|python|lua|bash|yaml|
|
||||||
|
-- -- make|dockerfile|sql|markdown|css|c|
|
||||||
|
-- -- cpp|html|xml) для вкладки i;
|
||||||
-- -- false — нет вкладки/языка
|
-- -- false — нет вкладки/языка
|
||||||
-- app.editor:language(i) -- язык подсветки вкладки или nil
|
-- api.editor:language(i) -- язык подсветки вкладки или nil
|
||||||
-- app.editor:clear_language(i) -- выключить подсветку для вкладки
|
-- api.editor:clear_language(i) -- выключить подсветку для вкладки
|
||||||
-- app.editor:save() -- сохранить активную вкладку в её файл
|
-- api.editor:save() -- сохранить активную вкладку в её файл
|
||||||
-- app.editor:map_key(modes, keys, handler)
|
-- api.editor:map_key(modes, keys, handler)
|
||||||
-- -- привязка, которая работает дополнительно
|
-- -- привязка, которая работает дополнительно
|
||||||
-- -- к глобальной, когда фокус в редакторе;
|
-- -- к глобальной, когда фокус в редакторе;
|
||||||
-- -- синтаксис modes/keys/handler как у app:map_key
|
-- -- синтаксис modes/keys/handler как у api.keymap:map_key
|
||||||
-- app.editor:set_mode(mode) -- режим привязок редактора
|
-- api.editor:set_mode(mode) -- режим привязок редактора
|
||||||
-- app.editor:mode() -- текущий режим привязок редактора
|
-- api.editor:mode() -- текущий режим привязок редактора
|
||||||
|
-- api.editor:focus() -- перевести фокус в редактор, не меняя
|
||||||
|
-- -- активную вкладку и курсор
|
||||||
--
|
--
|
||||||
-- LSP-серверы (запуск целиком настраивается этим сценарием):
|
-- LSP-серверы (запуск целиком настраивается этим сценарием):
|
||||||
-- app.editor:run_lsp({
|
-- api.editor:run_lsp({
|
||||||
-- name = "gopls", -- условное имя для stop/restart
|
-- name = "gopls", -- условное имя для stop/restart
|
||||||
-- cmd = "gopls", -- путь к бинарнику или имя в PATH
|
-- cmd = "gopls", -- путь к бинарнику или имя в PATH
|
||||||
-- args = { "serve" }, -- аргументы процесса
|
-- args = { "serve" }, -- аргументы процесса
|
||||||
@@ -116,10 +136,10 @@ print("[lua] init.lua выполнен")
|
|||||||
-- root = ".", -- корень workspace (по умолчанию cwd)
|
-- root = ".", -- корень workspace (по умолчанию cwd)
|
||||||
-- initialization_options = {}, -- initializationOptions сервера
|
-- initialization_options = {}, -- initializationOptions сервера
|
||||||
-- })
|
-- })
|
||||||
-- app.editor:lsp_servers() -- { "gopls", ... }
|
-- api.editor:lsp_servers() -- { "gopls", ... }
|
||||||
-- app.editor:stop_lsp("gopls") -- true, если сервер был запущен
|
-- api.editor:stop_lsp("gopls") -- true, если сервер был запущен
|
||||||
-- app.editor:restart_lsp("gopls") -- перезапуск с прежними настройками
|
-- api.editor:restart_lsp("gopls") -- перезапуск с прежними настройками
|
||||||
-- app.editor:on_lsp_diagnostics(delay_ms, fn)
|
-- api.editor:on_lsp_diagnostics(delay_ms, fn)
|
||||||
-- -- fn(servers) вызывается один раз
|
-- -- fn(servers) вызывается один раз
|
||||||
-- -- после затишья delay_ms; nil вместо
|
-- -- после затишья 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" })
|
-- 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" })
|
-- languages = { "rust" }, watcher = "**/*.rs" })
|
||||||
--
|
--
|
||||||
-- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием):
|
-- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием):
|
||||||
-- app.left_panel:open()
|
-- api.panels.left:open()
|
||||||
-- app.left_panel:close()
|
-- api.panels.left:close()
|
||||||
-- app.left_panel:toggle() -- вернуть true, если панель открыта
|
-- api.panels.left:toggle() -- вернуть true, если панель открыта
|
||||||
-- app.left_panel:is_open()
|
-- api.panels.left:is_open()
|
||||||
-- app.right_panel:open() -- те же методы, что у левой панели
|
-- api.panels.right:open() -- те же методы, что у левой панели
|
||||||
-- app.right_panel:close()
|
-- api.panels.right:close()
|
||||||
-- app.right_panel:toggle()
|
-- api.panels.right:toggle()
|
||||||
-- app.right_panel:is_open()
|
-- api.panels.right:is_open()
|
||||||
-- app.bottom_panel:open() -- те же методы, что у левой панели
|
-- api.panels.bottom:open() -- те же методы, что у левой панели
|
||||||
-- app.bottom_panel:close()
|
-- api.panels.bottom:close()
|
||||||
-- app.bottom_panel:toggle()
|
-- api.panels.bottom:toggle()
|
||||||
-- app.bottom_panel:is_open()
|
-- api.panels.bottom:is_open()
|
||||||
--
|
--
|
||||||
-- Верхняя панель редактора (полоса между вкладками и редактором).
|
-- Верхняя панель редактора (полоса между вкладками и редактором).
|
||||||
-- Элементы левой области идут от левого края вправо, правой — от правого
|
-- Элементы левой области идут от левого края вправо, правой — от правого
|
||||||
-- края влево. id нужен, чтобы скрыть или снова показать элемент.
|
-- края влево. id нужен, чтобы скрыть или снова показать элемент.
|
||||||
-- app.toolbar:add_label(text, icon, background, id, side)
|
-- api.panels.toolbar:add_label(text, icon, background, id, side)
|
||||||
-- app.toolbar:add_button(text, icon, background, id, side, fn)
|
-- api.panels.toolbar:add_button(text, icon, background, id, side, fn)
|
||||||
-- -- fn() вызывается при нажатии;
|
-- -- fn() вызывается при нажатии;
|
||||||
-- -- background — "#RRGGBB", "#RRGGBBAA" или nil;
|
-- -- background — "#RRGGBB", "#RRGGBBAA" или nil;
|
||||||
-- -- icon — имя иконки Lucide или nil;
|
-- -- icon — имя иконки Lucide или nil;
|
||||||
-- -- side — "left" или "right"
|
-- -- side — "left" или "right"
|
||||||
-- app.toolbar:hide(id) -- true, если элемент найден
|
-- api.panels.toolbar:hide(id) -- true, если элемент найден
|
||||||
-- app.toolbar:show(id) -- снова показать скрытый элемент
|
-- api.panels.toolbar:show(id) -- снова показать скрытый элемент
|
||||||
-- app.toolbar:clear() -- удалить все элементы
|
-- api.panels.toolbar:clear() -- удалить все элементы
|
||||||
--
|
--
|
||||||
-- Блоки панелей. Сценарий сам решает, что показывает блок: редактор умеет
|
-- Блоки панелей. Сценарий сам решает, что показывает блок: редактор умеет
|
||||||
-- рисовать только абстрактное дерево.
|
-- рисовать только абстрактное дерево.
|
||||||
-- local tree = app.left_panel:add_tree()
|
-- local tree = api.panels.left:add_tree()
|
||||||
-- local tab = app.bottom_panel:add_tree("Диагностика")
|
-- local tab = api.panels.bottom:add_tree("Диагностика")
|
||||||
-- -- title показывается вкладкой в нижней
|
-- -- title показывается вкладкой в нижней
|
||||||
-- -- панели; боковые панели его не рисуют
|
-- -- панели; боковые панели его не рисуют
|
||||||
-- tree:append(nodes, parent_id) -- nodes = { { id=..., label=..., icon=...,
|
-- tree:append(nodes, parent_id) -- nodes = { { id=..., label=..., icon=...,
|
||||||
@@ -197,28 +217,32 @@ print("[lua] init.lua выполнен")
|
|||||||
-- tree:on_click(fn) -- fn(id) — клик по конечной ноде
|
-- tree:on_click(fn) -- fn(id) — клик по конечной ноде
|
||||||
-- tree:selected() -- id выбранной строки или nil
|
-- tree:selected() -- id выбранной строки или nil
|
||||||
-- tree:focus(id) -- раскрыть родителей, выбрать и дать фокус
|
-- tree:focus(id) -- раскрыть родителей, выбрать и дать фокус
|
||||||
|
-- tree:focus() -- только дать фокус, не меняя выбор
|
||||||
|
-- tree:has_focus() -- владеет ли дерево фокусом сейчас
|
||||||
-- tree:map_key(modes, keys, handler)
|
-- tree:map_key(modes, keys, handler)
|
||||||
-- -- привязка, которая работает дополнительно
|
-- -- привязка, которая работает дополнительно
|
||||||
-- -- к глобальной, когда фокус на этом дереве
|
-- -- к глобальной, когда фокус на этом дереве
|
||||||
-- tree:set_mode(mode) -- режим привязок блока (один символ)
|
-- tree:set_mode(mode) -- режим привязок блока (один символ)
|
||||||
-- tree:mode() -- текущий режим привязок блока
|
-- tree:mode() -- текущий режим привязок блока
|
||||||
--
|
--
|
||||||
-- Методы добавляются в src/api.rs: всё, что есть в impl AppApi или
|
-- Методы добавляются в src/api.rs. Публичные методы impl-блока с
|
||||||
-- impl EditorApi, помеченном #[mlua::userdata_impl], автоматически доступно
|
-- #[mlua::userdata_impl] автоматически доступны из Lua: методы AppApi — у api,
|
||||||
-- из Lua как app:method(...) или app.editor:method(...).
|
-- EditorApi — у api.editor, PanelApi — у api.panels.left/.right/.bottom,
|
||||||
|
-- ToolbarApi — у api.panels.toolbar, PathApi — у api.path.
|
||||||
|
-- Чтобы не экспортировать метод, его помечают #[lua(skip)].
|
||||||
--
|
--
|
||||||
-- Другие Lua-файлы можно подключать из каталога сценариев:
|
-- Другие Lua-файлы можно подключать из каталога сценариев:
|
||||||
-- include("tools.lua") -- выполнить файл сразу
|
-- include("tools.lua") -- выполнить файл сразу
|
||||||
-- require("tools") -- выполнить один раз (как обычный Lua-модуль)
|
-- require("tools") -- выполнить один раз (как обычный Lua-модуль)
|
||||||
--
|
--
|
||||||
-- Подсветка синтаксиса по умолчанию выключена. Включить её можно так:
|
-- Подсветка синтаксиса по умолчанию выключена. Включить её можно так:
|
||||||
-- app.editor:set_language(0, "rust")
|
-- api.editor:set_language(0, "rust")
|
||||||
|
|
||||||
-- gopls запускается автоматически при старте редактора.
|
-- gopls запускается автоматически при старте редактора.
|
||||||
-- Сервер обслуживает только документы с languageId "go" и получает изменения
|
-- Сервер обслуживает только документы с languageId "go" и получает изменения
|
||||||
-- всех .go-файлов проекта (кроме vendor/) через файловый вотчер.
|
-- всех .go-файлов проекта (кроме vendor/) через файловый вотчер.
|
||||||
local gopls_ok, gopls_err = pcall(function()
|
local gopls_ok, gopls_err = pcall(function()
|
||||||
app.editor:run_lsp({
|
api.editor:run_lsp({
|
||||||
name = "gopls",
|
name = "gopls",
|
||||||
cmd = "gopls",
|
cmd = "gopls",
|
||||||
args = { "serve" },
|
args = { "serve" },
|
||||||
@@ -242,75 +266,209 @@ if not gopls_ok then
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно.
|
-- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно.
|
||||||
-- app:map_key("", "<C-s>", function() app.editor:save() end)
|
-- api.keymap:map_key("", "<C-s>", function() api.editor:save() end)
|
||||||
-- app:map_key("", "<!Esc>", function() app:close() end)
|
-- api.keymap:map_key("", "<!Esc>", function() api.window:close() end)
|
||||||
-- Убрать встроенную привязку можно так:
|
-- Убрать встроенную привязку можно так:
|
||||||
-- app:map_key("", "<C-s>", nil)
|
-- api.keymap:map_key("", "<C-s>", nil)
|
||||||
-- app:map_completion_key("<C-n>", "completion_next")
|
-- api.keymap:map_completion_key("<C-n>", "completion_next")
|
||||||
-- app:map_completion_key("<C-p>", "completion_previous")
|
-- api.keymap:map_completion_key("<C-p>", "completion_previous")
|
||||||
|
|
||||||
-- Файловое дерево проекта.
|
-- Файловое дерево проекта.
|
||||||
--
|
--
|
||||||
-- Редактор рисует абстрактное дерево; смысл узлов (файлы, папки, что угодно)
|
-- Редактор рисует абстрактное дерево; смысл узлов (файлы, папки, что угодно)
|
||||||
-- задаёт этот сценарий. Здесь корень — текущий каталог, а каждая папка
|
-- задаёт этот сценарий. Здесь корень — текущий каталог, а каждая папка
|
||||||
-- дозагружается при первом раскрытии.
|
-- дозагружается при первом раскрытии.
|
||||||
local project_tree = app.left_panel:add_tree()
|
local project_tree = api.panels.left:add_tree()
|
||||||
|
|
||||||
|
-- Какие узлы являются каталогами. Нужно при создании пути: новый файл
|
||||||
|
-- появляется внутри выбранной папки или рядом с выбранным файлом.
|
||||||
|
local node_is_dir = {}
|
||||||
|
-- Какие папки раскрыты. `load_dir` обновляет узлы, и без этого списка
|
||||||
|
-- повторная загрузка каталога сворачивала бы уже открытые папки.
|
||||||
|
local expanded_nodes = {}
|
||||||
|
|
||||||
|
local function split_path(path)
|
||||||
|
local parts = {}
|
||||||
|
for part in path:gmatch("[^/]+") do
|
||||||
|
parts[#parts + 1] = part
|
||||||
|
end
|
||||||
|
return parts
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parent_path(path)
|
||||||
|
return path:match("^(.*)/[^/]*$") or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local function merge_path(base, parts)
|
||||||
|
local segments = {}
|
||||||
|
for _, part in ipairs(base) do
|
||||||
|
segments[#segments + 1] = part
|
||||||
|
end
|
||||||
|
for _, part in ipairs(parts) do
|
||||||
|
segments[#segments + 1] = part
|
||||||
|
end
|
||||||
|
return segments
|
||||||
|
end
|
||||||
|
|
||||||
|
local function join_path(segments)
|
||||||
|
return table.concat(segments, "/")
|
||||||
|
end
|
||||||
|
|
||||||
local function load_dir(tree, path)
|
local function load_dir(tree, path)
|
||||||
local nodes = {}
|
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] = {
|
nodes[#nodes + 1] = {
|
||||||
id = entry.id,
|
id = entry.id,
|
||||||
label = entry.name,
|
label = entry.name,
|
||||||
icon = entry.is_dir and "folder" or "file",
|
icon = entry.is_dir and "folder" or "file",
|
||||||
expandable = entry.is_dir,
|
expandable = entry.is_dir,
|
||||||
expanded = false,
|
expanded = entry.is_dir and expanded_nodes[entry.id] == true or false,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
tree:append(nodes, path)
|
tree:append(nodes, path)
|
||||||
end
|
end
|
||||||
|
|
||||||
project_tree:on_toggle(function(expanded, id, loaded)
|
project_tree:on_toggle(function(expanded, id, loaded)
|
||||||
|
expanded_nodes[id] = expanded or nil
|
||||||
-- loaded == false только при первом раскрытии: позже дети уже в дереве.
|
-- loaded == false только при первом раскрытии: позже дети уже в дереве.
|
||||||
if expanded and not loaded then
|
if expanded and not loaded then
|
||||||
load_dir(project_tree, id)
|
load_dir(project_tree, id)
|
||||||
end
|
end
|
||||||
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)
|
project_tree:on_click(function(id)
|
||||||
-- Пустая таблица: фокус уходит в редактор, курсор остаётся на месте.
|
-- Пустая таблица: фокус уходит в редактор, курсор остаётся на месте.
|
||||||
app.editor:open(id, {})
|
api.editor:open(id, {})
|
||||||
end)
|
end)
|
||||||
|
|
||||||
app.left_panel:open()
|
api.panels.left:open()
|
||||||
load_dir(project_tree, "")
|
load_dir(project_tree, "")
|
||||||
|
|
||||||
-- Открытие/закрытие панели с деревом. При открытии возвращаем фокус на
|
-- Каталог, от которого отсчитывается новый путь: выбранная папка или
|
||||||
-- последнюю выбранную строку, чтобы можно было сразу продолжить навигацию.
|
-- родитель выбранного файла.
|
||||||
app:map_key("", "<C-b>", function()
|
local function creation_base()
|
||||||
local opened = app.left_panel:toggle()
|
local id = project_tree:selected()
|
||||||
if opened then
|
if not id or id == "" then
|
||||||
local selected = project_tree:selected()
|
return {}
|
||||||
if selected then
|
|
||||||
project_tree:focus(selected)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end)
|
if node_is_dir[id] then
|
||||||
|
return split_path(id)
|
||||||
|
end
|
||||||
|
return split_path(parent_path(id))
|
||||||
|
end
|
||||||
|
|
||||||
app:map_key("", "<C-q>", function() app:close() end)
|
-- Создаёт путь, введённый в поле. Последний сегмент — файл, если строка не
|
||||||
|
-- заканчивается на "/"; иначе создаётся папка. Все промежуточные сегменты —
|
||||||
|
-- папки. Путь отсчитывается от текущей позиции в дереве.
|
||||||
|
local function create_path(text)
|
||||||
|
text = text:match("^%s*(.-)%s*$")
|
||||||
|
if text == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- Плавающее поле ввода. Пока оно открыто, сочетания клавиш приложения
|
local base = creation_base()
|
||||||
-- заблокированы; Enter вызывает callback, Esc и клик вне окна — отменяют.
|
local parts = split_path(text)
|
||||||
app:map_key("", "<C-g>", function()
|
if #parts == 0 then
|
||||||
app:open_input({
|
return
|
||||||
-- title = "Открыть файл",
|
end
|
||||||
placeholder = "src/main.rs",
|
|
||||||
callback = function(text) app.editor:open(text, {}) 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)
|
end)
|
||||||
|
|
||||||
app:map_key("", "<C-e>", "toggle_left_panel")
|
-- Ctrl+E открывает панель и переводит фокус на дерево, а если панель уже
|
||||||
app:map_key("", "<C-t>", "toggle_bottom_panel")
|
-- открыта — только переводит фокус. На дереве Ctrl+Q закрывает панель,
|
||||||
|
-- в остальных местах — по-прежнему закрывает приложение.
|
||||||
|
api.keymap:map_key("", "<C-e>", function()
|
||||||
|
api.panels.left:open()
|
||||||
|
project_tree:focus()
|
||||||
|
end)
|
||||||
|
|
||||||
|
api.keymap:map_key("", "<C-q>", function()
|
||||||
|
if project_tree:has_focus() then
|
||||||
|
api.panels.left:close()
|
||||||
|
api.editor:focus()
|
||||||
|
else
|
||||||
|
api.window:close()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Плавающее поле ввода. Пока оно открыто, сочетания клавиш приложения
|
||||||
|
-- заблокированы; Enter вызывает callback, Esc и клик вне окна — отменяют.
|
||||||
|
api.keymap:map_key("", "<C-g>", function()
|
||||||
|
api.window:open_input({
|
||||||
|
-- title = "Открыть файл",
|
||||||
|
placeholder = "src/main.rs",
|
||||||
|
callback = function(text) api.editor:open(text, {}) end,
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
api.keymap:map_key("", "<C-t>", "toggle_bottom_panel")
|
||||||
|
|
||||||
-- Дерево диагностики в нижней панели.
|
-- Дерево диагностики в нижней панели.
|
||||||
--
|
--
|
||||||
@@ -318,7 +476,7 @@ app:map_key("", "<C-t>", "toggle_bottom_panel")
|
|||||||
-- тишины: если серверы присылают диагностику чаще, промежуточные вызовы
|
-- тишины: если серверы присылают диагностику чаще, промежуточные вызовы
|
||||||
-- пропускаются, а последняя остаётся и обрабатывается после затишья.
|
-- пропускаются, а последняя остаётся и обрабатывается после затишья.
|
||||||
-- При каждом вызове дерево очищается целиком и строится заново.
|
-- При каждом вызове дерево очищается целиком и строится заново.
|
||||||
local diagnostics_tree = app.bottom_panel:add_tree("Диагностика")
|
local diagnostics_tree = api.panels.bottom:add_tree("Диагностика")
|
||||||
|
|
||||||
local severity_icons = {
|
local severity_icons = {
|
||||||
error = "circle-x",
|
error = "circle-x",
|
||||||
@@ -346,8 +504,8 @@ end
|
|||||||
local diagnostic_gutter = {}
|
local diagnostic_gutter = {}
|
||||||
|
|
||||||
local function active_tab_title()
|
local function active_tab_title()
|
||||||
local active = app.editor:active_tab()
|
local active = api.editor:active_tab()
|
||||||
for _, tab in ipairs(app.editor:tabs()) do
|
for _, tab in ipairs(api.editor:tabs()) do
|
||||||
if tab.index == active then
|
if tab.index == active then
|
||||||
return tab.title
|
return tab.title
|
||||||
end
|
end
|
||||||
@@ -370,14 +528,14 @@ local function path_matches_title(path, title)
|
|||||||
return #path > #suffix and path:sub(-#suffix) == suffix
|
return #path > #suffix and path:sub(-#suffix) == suffix
|
||||||
end
|
end
|
||||||
|
|
||||||
app.editor:on_lsp_diagnostics(100, function(servers)
|
api.editor:on_lsp_diagnostics(100, function(servers)
|
||||||
diagnostics_tree:clear()
|
diagnostics_tree:clear()
|
||||||
|
|
||||||
-- Иконки прошлого обновления активной вкладки могли устареть.
|
-- Иконки прошлого обновления активной вкладки могли устареть.
|
||||||
local title = active_tab_title()
|
local title = active_tab_title()
|
||||||
if title and diagnostic_gutter[title] then
|
if title and diagnostic_gutter[title] then
|
||||||
for line in pairs(diagnostic_gutter[title]) do
|
for line in pairs(diagnostic_gutter[title]) do
|
||||||
app.editor:clear_gutter_icon(line)
|
api.editor:clear_gutter_icon(line)
|
||||||
end
|
end
|
||||||
diagnostic_gutter[title] = nil
|
diagnostic_gutter[title] = nil
|
||||||
end
|
end
|
||||||
@@ -449,7 +607,7 @@ app.editor:on_lsp_diagnostics(100, function(servers)
|
|||||||
local group = groups[key]
|
local group = groups[key]
|
||||||
local icon = severity_icons[group.severity] or "circle-alert"
|
local icon = severity_icons[group.severity] or "circle-alert"
|
||||||
if active then
|
if active then
|
||||||
app.editor:set_gutter_icon(group.line, icon)
|
api.editor:set_gutter_icon(group.line, icon)
|
||||||
gutter_lines[group.line] = true
|
gutter_lines[group.line] = true
|
||||||
end
|
end
|
||||||
errors[#errors + 1] = {
|
errors[#errors + 1] = {
|
||||||
@@ -498,21 +656,21 @@ end)
|
|||||||
diagnostics_tree:on_click(function(id)
|
diagnostics_tree:on_click(function(id)
|
||||||
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
|
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
|
||||||
if path then
|
if path then
|
||||||
app.editor:open(path, { tonumber(line), tonumber(character) })
|
api.editor:open(path, { tonumber(line), tonumber(character) })
|
||||||
else
|
else
|
||||||
-- Клик по файлу: фокус в редактор, курсор не переставляем.
|
-- Клик по файлу: фокус в редактор, курсор не переставляем.
|
||||||
app.editor:open(id, {})
|
api.editor:open(id, {})
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
app.bottom_panel:open()
|
api.panels.bottom:open()
|
||||||
|
|
||||||
-- Верхняя панель: пример лейблов и кнопок.
|
-- Верхняя панель: пример лейблов и кнопок.
|
||||||
-- app.toolbar:add_label("Готово", nil, nil, "status", "left")
|
-- api.panels.toolbar:add_label("Готово", nil, nil, "status", "left")
|
||||||
app.toolbar:add_label("UTF-8", nil, "#efefe6", "encoding", "right")
|
api.panels.toolbar:add_label("UTF-8", nil, "#efefe6", "encoding", "right")
|
||||||
app.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
|
api.panels.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
|
||||||
print("[toolbar] кнопка «Запустить» нажата")
|
print("[toolbar] кнопка «Запустить» нажата")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
app.editor:set_gutter_icon(2, "play", "#efefe6")
|
api.editor:set_gutter_icon(2, "play", "#efefe6")
|
||||||
app.editor:set_gutter_icon(90, "play")
|
api.editor:set_gutter_icon(90, "play")
|
||||||
|
|||||||
+583
-207
@@ -2,7 +2,11 @@
|
|||||||
//!
|
//!
|
||||||
//! Единственная точка входа для команд управления окном и редактором: UI-код
|
//! Единственная точка входа для команд управления окном и редактором: UI-код
|
||||||
//! и Lua-сценарии не трогают `Window` и состояние вида напрямую, а вызывают
|
//! и Lua-сценарии не трогают `Window` и состояние вида напрямую, а вызывают
|
||||||
//! методы [`AppApi`] или [`EditorApi`] (`app.editor`).
|
//! методы объектов [`AppApi`].
|
||||||
|
//!
|
||||||
|
//! Для Lua `AppApi` — это объект `api`, разбитый на группы: `api.editor`,
|
||||||
|
//! `api.panels`, `api.path`, `api.window` и `api.keymap`. Сами методы живут в
|
||||||
|
//! API своей группы, а [`AppApi`] отдаёт только их.
|
||||||
//!
|
//!
|
||||||
//! Команды окна не выполняются в момент вызова: Lua-обработчик выполняется вне
|
//! Команды окна не выполняются в момент вызова: Lua-обработчик выполняется вне
|
||||||
//! контекста `App`/`Window`, поэтому [`AppApi`] складывает их в очередь, а вид
|
//! контекста `App`/`Window`, поэтому [`AppApi`] складывает их в очередь, а вид
|
||||||
@@ -10,6 +14,7 @@
|
|||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -41,7 +46,7 @@ thread_local! {
|
|||||||
static WINDOW_COMMANDS: RefCell<VecDeque<WindowCommand>> = const { RefCell::new(VecDeque::new()) };
|
static WINDOW_COMMANDS: RefCell<VecDeque<WindowCommand>> = const { RefCell::new(VecDeque::new()) };
|
||||||
|
|
||||||
/// Развёрнуто ли окно. Вид обновляет значение в каждом кадре, чтобы
|
/// Развёрнуто ли окно. Вид обновляет значение в каждом кадре, чтобы
|
||||||
/// `app:is_maximized()` из Lua видел актуальное состояние.
|
/// `api.window:is_maximized()` из Lua видел актуальное состояние.
|
||||||
static WINDOW_MAXIMIZED: Cell<bool> = const { Cell::new(false) };
|
static WINDOW_MAXIMIZED: Cell<bool> = const { Cell::new(false) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,10 +87,10 @@ pub struct TabInfo {
|
|||||||
pub icon: String,
|
pub icon: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Элемент каталога, возвращаемый `app:list_dir(path)`.
|
/// Элемент каталога, возвращаемый `api.path:list(path)`.
|
||||||
///
|
///
|
||||||
/// `id` — путь относительно текущего каталога процесса: его можно сразу
|
/// `id` — путь относительно текущего каталога процесса: его можно сразу
|
||||||
/// положить в `id` узла дерева и передать в `app:open(id)`.
|
/// положить в `id` узла дерева и передать в `api.editor:open(id)`.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct DirEntryInfo {
|
pub struct DirEntryInfo {
|
||||||
/// Путь элемента относительно текущего каталога.
|
/// Путь элемента относительно текущего каталога.
|
||||||
@@ -108,13 +113,23 @@ struct DiagnosticsHandler {
|
|||||||
|
|
||||||
/// Глобальный объект с функциями управления приложением.
|
/// Глобальный объект с функциями управления приложением.
|
||||||
///
|
///
|
||||||
/// Использование: `AppApi::global().close()`, `AppApi::global().editor()`.
|
/// Использование: `AppApi::global()`, `AppApi::global().editor()`.
|
||||||
/// Экземпляр устанавливается один раз при создании приложения (см.
|
/// Экземпляр устанавливается один раз при создании приложения (см.
|
||||||
/// [`AppApi::install`]) и указывает на общие с видом документы и карты клавиш.
|
/// [`AppApi::install`]) и указывает на общие с видом документы и карты клавиш.
|
||||||
///
|
///
|
||||||
|
/// В Lua объект называется `api` и состоит из групп:
|
||||||
|
///
|
||||||
|
/// * `api.editor` — вкладки, текст, gutter, сохранение, LSP и привязки
|
||||||
|
/// редактора ([`EditorApi`]);
|
||||||
|
/// * `api.panels` — левая, правая и нижняя панели, а также верхняя панель
|
||||||
|
/// редактора ([`PanelsApi`]);
|
||||||
|
/// * `api.path` — файловая система ([`PathApi`]);
|
||||||
|
/// * `api.window` — окно и плавающее поле ввода ([`WindowApi`]);
|
||||||
|
/// * `api.keymap` — глобальные привязки клавиш ([`KeymapApi`]).
|
||||||
|
///
|
||||||
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
|
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
|
||||||
/// `#[mlua::userdata_impl]`: в сценариях они доступны как `app:method(...)`.
|
/// `#[mlua::userdata_impl]`. Чтобы не экспортировать метод, пометьте его
|
||||||
/// Чтобы не экспортировать метод, пометьте его `#[lua(skip)]`.
|
/// `#[lua(skip)]`.
|
||||||
#[derive(Clone, mlua::UserData)]
|
#[derive(Clone, mlua::UserData)]
|
||||||
pub struct AppApi {
|
pub struct AppApi {
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
@@ -145,7 +160,7 @@ pub struct AppApi {
|
|||||||
/// API редактора: вкладки, текст, gutter, сохранение, LSP и свои привязки
|
/// API редактора: вкладки, текст, gutter, сохранение, LSP и свои привязки
|
||||||
/// клавиш.
|
/// клавиш.
|
||||||
///
|
///
|
||||||
/// В Lua доступно как `app.editor`. Объект всегда смотрит на те же документы
|
/// В Lua доступно как `api.editor`. Объект всегда смотрит на те же документы
|
||||||
/// и LSP-менеджер, что и вид: второго состояния нет.
|
/// и LSP-менеджер, что и вид: второго состояния нет.
|
||||||
#[derive(Clone, mlua::UserData)]
|
#[derive(Clone, mlua::UserData)]
|
||||||
pub struct EditorApi {
|
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 одной панели окна.
|
/// API одной панели окна.
|
||||||
///
|
///
|
||||||
/// Один и тот же тип используется для `app.left_panel`, `app.right_panel` и
|
/// Один и тот же тип используется для `api.panels.left`, `api.panels.right` и
|
||||||
/// `app.bottom_panel`: отличается только состояние, на которое ссылается
|
/// `api.panels.bottom`: отличается только состояние, на которое ссылается
|
||||||
/// объект. Поэтому панели управляются одинаково: `app.left_panel:open()`,
|
/// объект. Поэтому панели управляются одинаково: `api.panels.left:open()`,
|
||||||
/// `app.bottom_panel:toggle()` и так далее.
|
/// `api.panels.bottom:toggle()` и так далее.
|
||||||
#[derive(mlua::UserData)]
|
#[derive(mlua::UserData)]
|
||||||
pub struct PanelApi {
|
pub struct PanelApi {
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
@@ -196,9 +245,9 @@ impl PanelApi {
|
|||||||
|
|
||||||
/// API верхней панели редактора.
|
/// API верхней панели редактора.
|
||||||
///
|
///
|
||||||
/// В Lua доступно как `app.toolbar`. Элементы добавляются в левую или правую
|
/// В Lua доступно как `api.panels.toolbar`. Элементы добавляются в левую или
|
||||||
/// область панели и опознаются по `id`, чтобы их можно было скрыть и снова
|
/// правую область панели и опознаются по `id`, чтобы их можно было скрыть и
|
||||||
/// показать.
|
/// снова показать.
|
||||||
#[derive(mlua::UserData)]
|
#[derive(mlua::UserData)]
|
||||||
pub struct ToolbarApi {
|
pub struct ToolbarApi {
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
@@ -211,9 +260,53 @@ impl ToolbarApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// API файловой системы.
|
||||||
|
///
|
||||||
|
/// В Lua доступно как `api.path`. Все методы, принимающие путь, понимают и
|
||||||
|
/// строку `"foo/bar"`, и массив сегментов `{ "foo", "bar" }`.
|
||||||
|
#[derive(Clone, Copy, Default, mlua::UserData)]
|
||||||
|
pub struct PathApi;
|
||||||
|
|
||||||
|
impl PathApi {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API окна и плавающего поля ввода.
|
||||||
|
///
|
||||||
|
/// В Lua доступно как `api.window`.
|
||||||
|
#[derive(Clone, mlua::UserData)]
|
||||||
|
pub struct WindowApi {
|
||||||
|
#[lua(skip)]
|
||||||
|
prompt_request: PromptRequestHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowApi {
|
||||||
|
fn new(prompt_request: PromptRequestHandle) -> Self {
|
||||||
|
Self { prompt_request }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API глобальных привязок клавиш.
|
||||||
|
///
|
||||||
|
/// В Lua доступно как `api.keymap`. Привязки редактора живут отдельно в
|
||||||
|
/// `api.editor:map_key`, а блоков панелей — в `tree:map_key`.
|
||||||
|
#[derive(Clone, mlua::UserData)]
|
||||||
|
pub struct KeymapApi {
|
||||||
|
#[lua(skip)]
|
||||||
|
keymap: Rc<RefCell<KeyMap>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeymapApi {
|
||||||
|
fn new(keymap: Rc<RefCell<KeyMap>>) -> Self {
|
||||||
|
Self { keymap }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// API одного блока дерева.
|
/// API одного блока дерева.
|
||||||
///
|
///
|
||||||
/// Объект возвращает `app.left_panel:add_tree()`. Редактор не знает, что
|
/// Объект возвращает `api.panels.left:add_tree()`. Редактор не знает, что
|
||||||
/// означают узлы: сценарий сам решает, будет это файловое дерево проекта или
|
/// означают узлы: сценарий сам решает, будет это файловое дерево проекта или
|
||||||
/// что-то ещё, и передаёт готовый список узлов в [`TreeApi::append`].
|
/// что-то ещё, и передаёт готовый список узлов в [`TreeApi::append`].
|
||||||
#[derive(mlua::UserData)]
|
#[derive(mlua::UserData)]
|
||||||
@@ -338,13 +431,75 @@ impl TreeApi {
|
|||||||
self.state.borrow().selected().map(str::to_owned)
|
self.state.borrow().selected().map(str::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Получает ли дерево клавиатурный фокус прямо сейчас.
|
||||||
|
///
|
||||||
|
/// Значение обновляется видом перед обработкой каждого события клавиатуры,
|
||||||
|
/// поэтому его можно проверять в глобальных обработчиках привязок.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn has_focus(&self) -> bool {
|
||||||
|
self.state.borrow().has_focus()
|
||||||
|
}
|
||||||
|
|
||||||
/// Раскрывает всех закрытых родителей узла `id`, выбирает его и передаёт
|
/// Раскрывает всех закрытых родителей узла `id`, выбирает его и передаёт
|
||||||
/// фокус дереву.
|
/// фокус дереву.
|
||||||
///
|
///
|
||||||
|
/// Без аргумента (`tree:focus()`) только передаёт дереву фокус, не меняя
|
||||||
|
/// выбранную строку.
|
||||||
|
///
|
||||||
/// Возвращает `false`, если узла с таким идентификатором нет.
|
/// Возвращает `false`, если узла с таким идентификатором нет.
|
||||||
#[lua(infallible)]
|
#[lua(infallible)]
|
||||||
pub fn focus(&self, id: String) -> bool {
|
pub fn focus(&self, id: Option<String>) -> bool {
|
||||||
self.state.borrow_mut().focus(&id)
|
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
|
/// ```lua
|
||||||
/// local tree = app.left_panel:add_tree()
|
/// local tree = api.panels.left:add_tree()
|
||||||
/// tree:append({ { id = "a", label = "a.txt" } }, "")
|
/// 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")]
|
#[lua(infallible, name = "add_tree")]
|
||||||
pub fn add_tree(&self, title: Option<String>) -> TreeApi {
|
pub fn add_tree(&self, title: Option<String>) -> TreeApi {
|
||||||
@@ -391,6 +546,305 @@ impl PanelApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[mlua::userdata_impl]
|
||||||
|
impl PathApi {
|
||||||
|
/// Читает содержимое каталога.
|
||||||
|
///
|
||||||
|
/// `path` — строка `"src"` или массив сегментов `{ "src", "main" }`;
|
||||||
|
/// пустая строка, `nil` или `{ }` означают текущий рабочий каталог.
|
||||||
|
///
|
||||||
|
/// Возвращает массив `{ id = "src/main.rs", name = "main.rs",
|
||||||
|
/// is_dir = false }`. Элементы отсортированы по имени без учёта регистра;
|
||||||
|
/// `id` — путь относительно текущего каталога, если `path` тоже
|
||||||
|
/// относительный.
|
||||||
|
#[lua(name = "list")]
|
||||||
|
pub fn list(&self, path: mlua::Value) -> mlua::Result<Vec<DirEntryInfo>> {
|
||||||
|
let path = parse_path_value(path)?;
|
||||||
|
let raw = path_to_string(&path);
|
||||||
|
let relative_to_cwd = raw.is_empty() || raw == ".";
|
||||||
|
let directory = if raw.is_empty() {
|
||||||
|
Path::new(".")
|
||||||
|
} else {
|
||||||
|
path.as_path()
|
||||||
|
};
|
||||||
|
|
||||||
|
let read_dir = std::fs::read_dir(directory).map_err(|err| {
|
||||||
|
mlua::Error::RuntimeError(format!(
|
||||||
|
"не удалось прочитать каталог {}: {err}",
|
||||||
|
path_label(&path)
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in read_dir {
|
||||||
|
let entry = entry.map_err(|err| {
|
||||||
|
mlua::Error::RuntimeError(format!(
|
||||||
|
"не удалось прочитать элемент каталога {}: {err}",
|
||||||
|
path_label(&path)
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false);
|
||||||
|
let id = if relative_to_cwd {
|
||||||
|
name.clone()
|
||||||
|
} else {
|
||||||
|
path.join(&name).to_string_lossy().replace('\\', "/")
|
||||||
|
};
|
||||||
|
entries.push(DirEntryInfo { id, name, is_dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
a.name
|
||||||
|
.to_lowercase()
|
||||||
|
.cmp(&b.name.to_lowercase())
|
||||||
|
.then_with(|| a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создаёт каталоги, включая все отсутствующие родительские.
|
||||||
|
///
|
||||||
|
/// `api.path:mkdir({ "foo", "bar" })` и `api.path:mkdir("foo/bar")`
|
||||||
|
/// создают `foo`, затем `foo/bar`. Путь отсчитывается от текущего рабочего
|
||||||
|
/// каталога процесса; пустые сегменты пропускаются.
|
||||||
|
///
|
||||||
|
/// Возвращает созданный путь с разделителями `/`. Для пустого пути
|
||||||
|
/// ничего не создаёт и возвращает пустую строку.
|
||||||
|
pub fn mkdir(&self, path: mlua::Value) -> mlua::Result<String> {
|
||||||
|
let path = parse_path_value(path)?;
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&path).map_err(|err| {
|
||||||
|
mlua::Error::RuntimeError(format!(
|
||||||
|
"не удалось создать каталог {}: {err}",
|
||||||
|
path_label(&path)
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(path_to_string(&path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаляет файл или каталог вместе со всем содержимым.
|
||||||
|
///
|
||||||
|
/// Возвращает `true`, если что-то было удалено, и `false`, если пути не
|
||||||
|
/// существовало. Пустой путь удалять нельзя — это ошибка Lua.
|
||||||
|
pub fn remove(&self, path: mlua::Value) -> mlua::Result<bool> {
|
||||||
|
let path = parse_path_value(path)?;
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return Err(mlua::Error::RuntimeError(
|
||||||
|
"нельзя удалить пустой путь".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = match std::fs::symlink_metadata(&path) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||||
|
Err(err) => {
|
||||||
|
return Err(mlua::Error::RuntimeError(format!(
|
||||||
|
"не удалось прочитать путь {}: {err}",
|
||||||
|
path_label(&path)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = if metadata.file_type().is_dir() {
|
||||||
|
std::fs::remove_dir_all(&path)
|
||||||
|
} else {
|
||||||
|
std::fs::remove_file(&path)
|
||||||
|
};
|
||||||
|
result.map_err(|err| {
|
||||||
|
mlua::Error::RuntimeError(format!("не удалось удалить {}: {err}", path_label(&path)))
|
||||||
|
})?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создаёт пустой файл, если его нет, и обновляет время изменения.
|
||||||
|
///
|
||||||
|
/// Содержимое существующего файла не меняется. Родительский каталог
|
||||||
|
/// должен существовать. Возвращает путь с разделителями `/`.
|
||||||
|
pub fn touch(&self, path: mlua::Value) -> mlua::Result<String> {
|
||||||
|
let path = parse_path_value(path)?;
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return Err(mlua::Error::RuntimeError(
|
||||||
|
"нельзя создать файл по пустому пути".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
.map_err(|err| {
|
||||||
|
mlua::Error::RuntimeError(format!(
|
||||||
|
"не удалось создать файл {}: {err}",
|
||||||
|
path_label(&path)
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
file.set_modified(std::time::SystemTime::now())
|
||||||
|
.map_err(|err| {
|
||||||
|
mlua::Error::RuntimeError(format!(
|
||||||
|
"не удалось обновить время файла {}: {err}",
|
||||||
|
path_label(&path)
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(path_to_string(&path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Читает содержимое файла в строку.
|
||||||
|
pub fn read(&self, path: mlua::Value) -> mlua::Result<String> {
|
||||||
|
let path = parse_path_value(path)?;
|
||||||
|
std::fs::read_to_string(&path).map_err(|err| {
|
||||||
|
mlua::Error::RuntimeError(format!(
|
||||||
|
"не удалось прочитать файл {}: {err}",
|
||||||
|
path_label(&path)
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Записывает строку в файл.
|
||||||
|
///
|
||||||
|
/// `append = true` дописывает содержимое в конец, `false` или `nil` —
|
||||||
|
/// перезаписывает файл с нуля. Файл создаётся, если его нет; родительский
|
||||||
|
/// каталог должен существовать. Возвращает путь с разделителями `/`.
|
||||||
|
pub fn write(
|
||||||
|
&self,
|
||||||
|
path: mlua::Value,
|
||||||
|
content: String,
|
||||||
|
append: Option<bool>,
|
||||||
|
) -> mlua::Result<String> {
|
||||||
|
let path = parse_path_value(path)?;
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return Err(mlua::Error::RuntimeError(
|
||||||
|
"нельзя записать файл по пустому пути".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut options = std::fs::OpenOptions::new();
|
||||||
|
options.create(true);
|
||||||
|
if append.unwrap_or(false) {
|
||||||
|
options.append(true);
|
||||||
|
} else {
|
||||||
|
options.write(true).truncate(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = options.open(&path).map_err(|err| {
|
||||||
|
mlua::Error::RuntimeError(format!(
|
||||||
|
"не удалось открыть файл {}: {err}",
|
||||||
|
path_label(&path)
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
file.write_all(content.as_bytes()).map_err(|err| {
|
||||||
|
mlua::Error::RuntimeError(format!(
|
||||||
|
"не удалось записать файл {}: {err}",
|
||||||
|
path_label(&path)
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(path_to_string(&path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mlua::userdata_impl]
|
||||||
|
impl WindowApi {
|
||||||
|
/// Закрывает окно приложения.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn close(&self) {
|
||||||
|
push_window_command(WindowCommand::Close);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разворачивает окно на весь экран или возвращает его прежний размер.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn toggle_maximize(&self) {
|
||||||
|
push_window_command(WindowCommand::ToggleMaximize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разворачивает (`true`) или восстанавливает (`false`) окно.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn set_maximized(&self, maximized: bool) {
|
||||||
|
push_window_command(WindowCommand::SetMaximized(maximized));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сворачивает окно.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn minimize(&self) {
|
||||||
|
push_window_command(WindowCommand::Minimize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Развёрнуто ли окно прямо сейчас.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn is_maximized(&self) -> bool {
|
||||||
|
WINDOW_MAXIMIZED.with(Cell::get)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Открывает плавающее поле ввода чуть выше центра окна.
|
||||||
|
///
|
||||||
|
/// `options` — таблица `{ title = "Имя", placeholder = "main.rs",
|
||||||
|
/// callback = function(text) ... end }`.
|
||||||
|
/// `title` и `placeholder` необязательны: без заголовка окно состоит из
|
||||||
|
/// одной строки ввода, а без placeholder подсказка не рисуется.
|
||||||
|
/// Пока окно открыто, сочетания клавиш приложения не работают.
|
||||||
|
/// `Enter` подтверждает ввод и вызывает `callback` уже после закрытия окна,
|
||||||
|
/// `Esc` и клик вне окна закрывают его без колбэка.
|
||||||
|
pub fn open_input(&self, options: Option<mlua::Table>) -> mlua::Result<()> {
|
||||||
|
*self.prompt_request.borrow_mut() = Some(PromptRequest::from_options(options)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mlua::userdata_impl]
|
||||||
|
impl KeymapApi {
|
||||||
|
/// Назначает глобальный обработчик на нажатие или отпускание клавиши.
|
||||||
|
///
|
||||||
|
/// Первый аргумент `modes` — строка режимов, в которых действует
|
||||||
|
/// сочетание: пустая строка — все режимы, `"-"` — только режим по
|
||||||
|
/// умолчанию, `"-iv"` — режим по умолчанию, `i` и `v`. `keys` записывается
|
||||||
|
/// в духе nvim:
|
||||||
|
///
|
||||||
|
/// * `a`, `1` — нажатие клавиши без модификаторов;
|
||||||
|
/// * `<S-c>`, `<C-z>`, `<A-r>`, `<D-s>` — нажатие с Shift, Ctrl, Alt или
|
||||||
|
/// платформенным модификатором (Cmd/Super);
|
||||||
|
/// * `!a` — отпускание клавиши;
|
||||||
|
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
|
||||||
|
///
|
||||||
|
/// Обработчик — Lua-функция, имя встроенной команды (`save`, `next_tab`,
|
||||||
|
/// `toggle_left_panel` и т. п.) или `nil`, чтобы удалить привязку. Повторное
|
||||||
|
/// назначение той же клавиши заменяет обработчик.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Возвращает ошибку Lua, если запись привязки или имя команды некорректны.
|
||||||
|
pub fn map_key(&self, modes: String, keys: String, handler: mlua::Value) -> mlua::Result<()> {
|
||||||
|
bind_key(&self.keymap, KeyContext::Global, &modes, &keys, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Переключает глобальные привязки в режим `mode`.
|
||||||
|
///
|
||||||
|
/// Режим — ровно один символ; `"-"` — режим по умолчанию. Режимы
|
||||||
|
/// динамические: заранее создавать их не нужно.
|
||||||
|
pub fn set_mode(&self, mode: String) -> mlua::Result<()> {
|
||||||
|
self.keymap
|
||||||
|
.borrow_mut()
|
||||||
|
.set_mode(&mode)
|
||||||
|
.map_err(mlua::Error::RuntimeError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Текущий режим глобальных привязок.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn mode(&self) -> String {
|
||||||
|
self.keymap.borrow().mode().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Назначает обработчик клавиши, пока открыт список автодополнения.
|
||||||
|
///
|
||||||
|
/// Синтаксис совпадает с [`KeymapApi::map_key`]. Встроенные команды этого
|
||||||
|
/// слоя: `completion_trigger`, `completion_next`, `completion_previous`,
|
||||||
|
/// `completion_page_up`, `completion_page_down`, `completion_accept`,
|
||||||
|
/// `completion_cancel`, `completion_dismiss`.
|
||||||
|
#[lua(name = "map_completion_key")]
|
||||||
|
pub fn map_completion_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
|
||||||
|
bind_key(&self.keymap, KeyContext::Completion, "", &keys, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[mlua::userdata_impl]
|
#[mlua::userdata_impl]
|
||||||
impl ToolbarApi {
|
impl ToolbarApi {
|
||||||
/// Добавляет текстовый лейбл в левую или правую область панели.
|
/// Добавляет текстовый лейбл в левую или правую область панели.
|
||||||
@@ -550,8 +1004,8 @@ impl AppApi {
|
|||||||
|
|
||||||
/// API редактора.
|
/// API редактора.
|
||||||
///
|
///
|
||||||
/// В Lua доступно как `app.editor`: `app.editor:open_tab()`,
|
/// В Lua доступно как `api.editor`: `api.editor:open_tab()`,
|
||||||
/// `app.editor:save()`, `app.editor:map_key("n", "<C-s>", "save")`.
|
/// `api.editor:save()`, `api.editor:map_key("n", "<C-s>", "save")`.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[lua(getter, infallible)]
|
#[lua(getter, infallible)]
|
||||||
pub fn editor(&self) -> EditorApi {
|
pub fn editor(&self) -> EditorApi {
|
||||||
@@ -563,193 +1017,45 @@ impl AppApi {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Читает содержимое каталога (Rust-версия `app:list_dir`).
|
/// API панелей окна.
|
||||||
///
|
///
|
||||||
/// Пустой путь или `"."` означают текущий рабочий каталог процесса.
|
/// В Lua доступно как `api.panels`.
|
||||||
/// Элементы сортируются по имени без учёта регистра. В `id` попадает путь
|
|
||||||
/// относительно текущего каталога, если `path` тоже относительный.
|
|
||||||
#[lua(skip)]
|
|
||||||
pub fn list_dir(&self, path: impl AsRef<Path>) -> std::io::Result<Vec<DirEntryInfo>> {
|
|
||||||
let raw = path.as_ref().as_os_str().to_string_lossy().into_owned();
|
|
||||||
let relative_to_cwd = raw.is_empty() || raw == ".";
|
|
||||||
let directory = if raw.is_empty() {
|
|
||||||
Path::new(".")
|
|
||||||
} else {
|
|
||||||
path.as_ref()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
for entry in std::fs::read_dir(directory)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let name = entry.file_name().to_string_lossy().into_owned();
|
|
||||||
let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false);
|
|
||||||
let id = if relative_to_cwd {
|
|
||||||
name.clone()
|
|
||||||
} else {
|
|
||||||
Path::new(&raw)
|
|
||||||
.join(&name)
|
|
||||||
.to_string_lossy()
|
|
||||||
.replace('\\', "/")
|
|
||||||
};
|
|
||||||
entries.push(DirEntryInfo { id, name, is_dir });
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.sort_by(|a, b| {
|
|
||||||
a.name
|
|
||||||
.to_lowercase()
|
|
||||||
.cmp(&b.name.to_lowercase())
|
|
||||||
.then_with(|| a.name.cmp(&b.name))
|
|
||||||
});
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Список содержимого каталога `path` относительно текущего каталога.
|
|
||||||
///
|
|
||||||
/// Каждый элемент — таблица `{ id = "src/main.rs", name = "main.rs",
|
|
||||||
/// is_dir = false }`. Пустая строка или `"."` — текущий каталог. Метод
|
|
||||||
/// предназначен для построения деревьев и списков из Lua.
|
|
||||||
#[lua(name = "list_dir")]
|
|
||||||
fn lua_list_dir(&self, path: Option<String>) -> mlua::Result<Vec<DirEntryInfo>> {
|
|
||||||
let path = path.unwrap_or_default();
|
|
||||||
self.list_dir(&path).map_err(|err| {
|
|
||||||
let shown = if path.is_empty() { "." } else { path.as_str() };
|
|
||||||
mlua::Error::RuntimeError(format!("не удалось прочитать каталог {shown}: {err}"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Левая панель окна.
|
|
||||||
///
|
|
||||||
/// В Lua доступна как `app.left_panel` с методами `open`, `close`,
|
|
||||||
/// `toggle` и `is_open`.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[lua(getter, infallible)]
|
#[lua(getter, infallible)]
|
||||||
pub fn left_panel(&self) -> PanelApi {
|
pub fn panels(&self) -> PanelsApi {
|
||||||
PanelApi::new(self.left_panel.clone())
|
PanelsApi::new(
|
||||||
|
self.left_panel.clone(),
|
||||||
|
self.right_panel.clone(),
|
||||||
|
self.bottom_panel.clone(),
|
||||||
|
self.toolbar.clone(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Правая панель окна.
|
/// API файловой системы.
|
||||||
///
|
///
|
||||||
/// В Lua доступна как `app.right_panel` с методами `open`, `close`,
|
/// В Lua доступно как `api.path`.
|
||||||
/// `toggle` и `is_open`.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[lua(getter, infallible)]
|
#[lua(getter, infallible)]
|
||||||
pub fn right_panel(&self) -> PanelApi {
|
pub fn path(&self) -> PathApi {
|
||||||
PanelApi::new(self.right_panel.clone())
|
PathApi::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Нижняя панель окна.
|
/// API окна и плавающего поля ввода.
|
||||||
///
|
///
|
||||||
/// В Lua доступна как `app.bottom_panel` с теми же методами, что и
|
/// В Lua доступно как `api.window`.
|
||||||
/// `app.right_panel`.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[lua(getter, infallible)]
|
#[lua(getter, infallible)]
|
||||||
pub fn bottom_panel(&self) -> PanelApi {
|
pub fn window(&self) -> WindowApi {
|
||||||
PanelApi::new(self.bottom_panel.clone())
|
WindowApi::new(self.prompt_request.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Верхняя панель редактора.
|
/// API глобальных привязок клавиш.
|
||||||
///
|
///
|
||||||
/// В Lua доступна как `app.toolbar` с методами `add_label`, `add_button`,
|
/// В Lua доступно как `api.keymap`.
|
||||||
/// `hide`, `show` и `clear`.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[lua(getter, infallible)]
|
#[lua(getter, infallible)]
|
||||||
pub fn toolbar(&self) -> ToolbarApi {
|
pub fn keymap(&self) -> KeymapApi {
|
||||||
ToolbarApi::new(self.toolbar.clone())
|
KeymapApi::new(self.keymap.clone())
|
||||||
}
|
|
||||||
|
|
||||||
/// Закрывает окно приложения.
|
|
||||||
#[lua(infallible)]
|
|
||||||
pub fn close(&self) {
|
|
||||||
push_window_command(WindowCommand::Close);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Разворачивает окно на весь экран или возвращает его прежний размер.
|
|
||||||
#[lua(infallible)]
|
|
||||||
pub fn toggle_maximize(&self) {
|
|
||||||
push_window_command(WindowCommand::ToggleMaximize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Разворачивает (`true`) или восстанавливает (`false`) окно.
|
|
||||||
#[lua(infallible)]
|
|
||||||
pub fn set_maximized(&self, maximized: bool) {
|
|
||||||
push_window_command(WindowCommand::SetMaximized(maximized));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Сворачивает окно.
|
|
||||||
#[lua(infallible)]
|
|
||||||
pub fn minimize(&self) {
|
|
||||||
push_window_command(WindowCommand::Minimize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Развёрнуто ли окно прямо сейчас.
|
|
||||||
#[lua(infallible)]
|
|
||||||
pub fn is_maximized(&self) -> bool {
|
|
||||||
WINDOW_MAXIMIZED.with(Cell::get)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Открывает плавающее поле ввода чуть выше центра окна.
|
|
||||||
///
|
|
||||||
/// `options` — таблица `{ title = "Имя", placeholder = "main.rs",
|
|
||||||
/// callback = function(text) ... end }`.
|
|
||||||
/// `title` и `placeholder` необязательны: без заголовка окно состоит из
|
|
||||||
/// одной строки ввода, а без placeholder подсказка не рисуется.
|
|
||||||
/// Пока окно открыто, сочетания клавиш приложения не работают.
|
|
||||||
/// `Enter` подтверждает ввод и вызывает `callback` уже после закрытия окна,
|
|
||||||
/// `Esc` и клик вне окна закрывают его без колбэка.
|
|
||||||
pub fn open_input(&self, options: Option<mlua::Table>) -> mlua::Result<()> {
|
|
||||||
*self.prompt_request.borrow_mut() = Some(PromptRequest::from_options(options)?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Назначает глобальный обработчик на нажатие или отпускание клавиши.
|
|
||||||
///
|
|
||||||
/// Первый аргумент `modes` — строка режимов, в которых действует
|
|
||||||
/// сочетание: пустая строка — все режимы, `"-"` — только режим по
|
|
||||||
/// умолчанию, `"-iv"` — режим по умолчанию, `i` и `v`. `keys` записывается
|
|
||||||
/// в духе nvim:
|
|
||||||
///
|
|
||||||
/// * `a`, `1` — нажатие клавиши без модификаторов;
|
|
||||||
/// * `<S-c>`, `<C-z>`, `<A-r>`, `<D-s>` — нажатие с Shift, Ctrl, Alt или
|
|
||||||
/// платформенным модификатором (Cmd/Super);
|
|
||||||
/// * `!a` — отпускание клавиши;
|
|
||||||
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
|
|
||||||
///
|
|
||||||
/// Обработчик — Lua-функция, имя встроенной команды (`save`, `next_tab`,
|
|
||||||
/// `toggle_left_panel` и т. п.) или `nil`, чтобы удалить привязку. Повторное
|
|
||||||
/// назначение той же клавиши заменяет обработчик.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// Возвращает ошибку Lua, если запись привязки или имя команды некорректны.
|
|
||||||
pub fn map_key(&self, modes: String, keys: String, handler: mlua::Value) -> mlua::Result<()> {
|
|
||||||
bind_key(&self.keymap, KeyContext::Global, &modes, &keys, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Переключает глобальные привязки в режим `mode`.
|
|
||||||
///
|
|
||||||
/// Режим — ровно один символ; `"-"` — режим по умолчанию. Режимы
|
|
||||||
/// динамические: заранее создавать их не нужно.
|
|
||||||
pub fn set_mode(&self, mode: String) -> mlua::Result<()> {
|
|
||||||
self.keymap
|
|
||||||
.borrow_mut()
|
|
||||||
.set_mode(&mode)
|
|
||||||
.map_err(mlua::Error::RuntimeError)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Текущий режим глобальных привязок.
|
|
||||||
#[lua(infallible)]
|
|
||||||
pub fn mode(&self) -> String {
|
|
||||||
self.keymap.borrow().mode().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Назначает обработчик клавиши, пока открыт список автодополнения.
|
|
||||||
///
|
|
||||||
/// Синтаксис совпадает с [`AppApi::map_key`]. Встроенные команды этого
|
|
||||||
/// слоя: `completion_trigger`, `completion_next`, `completion_previous`,
|
|
||||||
/// `completion_page_up`, `completion_page_down`, `completion_accept`,
|
|
||||||
/// `completion_cancel`, `completion_dismiss`.
|
|
||||||
#[lua(name = "map_completion_key")]
|
|
||||||
pub fn map_completion_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
|
|
||||||
bind_key(&self.keymap, KeyContext::Completion, "", &keys, handler)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,6 +1191,17 @@ impl EditorApi {
|
|||||||
self.keymap.borrow().mode().to_string()
|
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)]
|
#[lua(infallible)]
|
||||||
pub fn active_text(&self) -> String {
|
pub fn active_text(&self) -> String {
|
||||||
@@ -911,19 +1228,19 @@ impl EditorApi {
|
|||||||
workspace.active_index()
|
workspace.active_index()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Открывает файл с диска на редактирование (Rust-версия `app.editor:open`).
|
/// Открывает файл с диска на редактирование (Rust-версия `api.editor:open`).
|
||||||
///
|
///
|
||||||
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
|
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
|
||||||
/// Язык подсветки определяется по расширению, если для него есть грамматика.
|
/// Язык подсветки определяется по расширению, если для него есть грамматика.
|
||||||
/// Фокус редактора не меняется: для этого есть [`EditorApi::open_focused`]
|
/// Фокус редактора не меняется: для этого есть [`EditorApi::open_focused`]
|
||||||
/// и Lua-форма `app.editor:open(path, {...})`.
|
/// и Lua-форма `api.editor:open(path, {...})`.
|
||||||
#[lua(skip)]
|
#[lua(skip)]
|
||||||
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
|
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
|
||||||
self.open_impl(path, None, false)
|
self.open_impl(path, None, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Открывает файл и переводит фокус в редактор (Rust-версия
|
/// Открывает файл и переводит фокус в редактор (Rust-версия
|
||||||
/// `app:open(path, position)`).
|
/// `api.editor:open(path, position)`).
|
||||||
///
|
///
|
||||||
/// `position` — строка и столбец с нуля, как в диагностике LSP; столбец
|
/// `position` — строка и столбец с нуля, как в диагностике LSP; столбец
|
||||||
/// считается в UTF-16. `None` оставляет курсор там, где он был.
|
/// считается в UTF-16. `None` оставляет курсор там, где он был.
|
||||||
@@ -967,7 +1284,10 @@ impl EditorApi {
|
|||||||
Ok(index)
|
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.
|
||||||
#[lua(name = "open")]
|
#[lua(name = "open")]
|
||||||
fn lua_open(&self, path: String, position: mlua::Value) -> mlua::Result<usize> {
|
fn lua_open(&self, path: mlua::Value, position: mlua::Value) -> mlua::Result<usize> {
|
||||||
|
let path = parse_path_value(path)?;
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return Err(mlua::Error::RuntimeError(
|
||||||
|
"api.editor:open: путь не может быть пустым".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let name = path_to_string(&path);
|
||||||
match parse_open_focus(position)? {
|
match parse_open_focus(position)? {
|
||||||
OpenFocus::Keep => self.open(&path),
|
OpenFocus::Keep => self.open(&path),
|
||||||
OpenFocus::Editor(position) => self.open_focused(&path, position),
|
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`].
|
/// Делает вкладку активной по её номеру в списке [`EditorApi::tabs`].
|
||||||
@@ -1117,8 +1444,11 @@ impl EditorApi {
|
|||||||
|
|
||||||
/// Включает подсветку синтаксиса для вкладки `index`.
|
/// Включает подсветку синтаксиса для вкладки `index`.
|
||||||
///
|
///
|
||||||
/// `language` — название языка: `rust`, `go`, `js`, `json` или `python`
|
/// `language` — название языка: `rust`, `go`, `js`, `json`, `python`,
|
||||||
/// (принимаются также `rs`, `golang`, `javascript`, `py`; регистр не важен).
|
/// `lua`, `bash`, `yaml`, `make`, `dockerfile`, `sql`, `markdown`, `css`,
|
||||||
|
/// `c`, `cpp`, `html` или `xml` (принимаются также короткие имена вроде
|
||||||
|
/// `rs`, `golang`, `javascript`, `py`, `sh`, `yml`, `makefile`, `md`,
|
||||||
|
/// `c++`; регистр не важен).
|
||||||
/// Подсветка по умолчанию выключена и включается только этим методом.
|
/// Подсветка по умолчанию выключена и включается только этим методом.
|
||||||
///
|
///
|
||||||
/// Возвращает `false`, если вкладки нет или язык не поддерживается.
|
/// Возвращает `false`, если вкладки нет или язык не поддерживается.
|
||||||
@@ -1166,7 +1496,7 @@ impl EditorApi {
|
|||||||
/// Пример из Lua:
|
/// Пример из Lua:
|
||||||
///
|
///
|
||||||
/// ```lua
|
/// ```lua
|
||||||
/// app:run_lsp({
|
/// api.editor:run_lsp({
|
||||||
/// name = "gopls",
|
/// name = "gopls",
|
||||||
/// cmd = "/usr/local/bin/gopls",
|
/// cmd = "/usr/local/bin/gopls",
|
||||||
/// args = { "serve" },
|
/// args = { "serve" },
|
||||||
@@ -1223,7 +1553,7 @@ impl EditorApi {
|
|||||||
/// Передача `nil` вместо функции удаляет обработчик.
|
/// Передача `nil` вместо функции удаляет обработчик.
|
||||||
///
|
///
|
||||||
/// ```lua
|
/// ```lua
|
||||||
/// app:on_lsp_diagnostics(1000, function(servers)
|
/// api.editor:on_lsp_diagnostics(1000, function(servers)
|
||||||
/// for _, entry in ipairs(servers) do
|
/// for _, entry in ipairs(servers) do
|
||||||
/// print(entry.server, entry.path, #entry.diagnostics)
|
/// print(entry.server, entry.path, #entry.diagnostics)
|
||||||
/// end
|
/// end
|
||||||
@@ -1279,7 +1609,7 @@ impl EditorApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Второй аргумент `app.editor:open`.
|
/// Второй аргумент `api.editor:open`.
|
||||||
enum OpenFocus {
|
enum OpenFocus {
|
||||||
/// Фокус не меняется.
|
/// Фокус не меняется.
|
||||||
Keep,
|
Keep,
|
||||||
@@ -1287,14 +1617,15 @@ enum OpenFocus {
|
|||||||
Editor(Option<(u32, u32)>),
|
Editor(Option<(u32, u32)>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Разбирает необязательный второй аргумент `app.editor:open`.
|
/// Разбирает необязательный второй аргумент `api.editor:open`.
|
||||||
fn parse_open_focus(value: mlua::Value) -> mlua::Result<OpenFocus> {
|
fn parse_open_focus(value: mlua::Value) -> mlua::Result<OpenFocus> {
|
||||||
let table = match value {
|
let table = match value {
|
||||||
mlua::Value::Nil => return Ok(OpenFocus::Keep),
|
mlua::Value::Nil => return Ok(OpenFocus::Keep),
|
||||||
mlua::Value::Table(table) => table,
|
mlua::Value::Table(table) => table,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(mlua::Error::RuntimeError(
|
return Err(mlua::Error::RuntimeError(
|
||||||
"во втором аргументе app:open нужна таблица {строка, столбец} или {}".to_owned(),
|
"во втором аргументе api.editor:open нужна таблица {строка, столбец} или {}"
|
||||||
|
.to_owned(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1307,12 +1638,56 @@ fn parse_open_focus(value: mlua::Value) -> mlua::Result<OpenFocus> {
|
|||||||
Ok(OpenFocus::Editor(Some((line as u32, column as u32))))
|
Ok(OpenFocus::Editor(Some((line as u32, column as u32))))
|
||||||
}
|
}
|
||||||
_ => Err(mlua::Error::RuntimeError(
|
_ => Err(mlua::Error::RuntimeError(
|
||||||
"позиция в app:open — два неотрицательных числа: {строка, столбец}".to_owned(),
|
"позиция в api.editor:open — два неотрицательных числа: {строка, столбец}".to_owned(),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Разбирает Lua-таблицу, переданную в `app:run_lsp`.
|
/// Разбирает путь из Lua: строка `"foo/bar"` или массив сегментов
|
||||||
|
/// `{ "foo", "bar" }`. `nil` означает пустой путь (текущий каталог).
|
||||||
|
fn parse_path_value(value: mlua::Value) -> mlua::Result<PathBuf> {
|
||||||
|
match value {
|
||||||
|
mlua::Value::Nil => Ok(PathBuf::new()),
|
||||||
|
mlua::Value::String(value) => Ok(PathBuf::from(value.to_string_lossy())),
|
||||||
|
mlua::Value::Table(table) => {
|
||||||
|
let mut path = PathBuf::new();
|
||||||
|
for component in table.sequence_values::<mlua::Value>() {
|
||||||
|
let component = match component? {
|
||||||
|
mlua::Value::String(value) => value.to_string_lossy(),
|
||||||
|
mlua::Value::Nil => continue,
|
||||||
|
_ => {
|
||||||
|
return Err(mlua::Error::RuntimeError(
|
||||||
|
"элементы пути должны быть строками".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !component.is_empty() {
|
||||||
|
path.push(component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
_ => Err(mlua::Error::RuntimeError(
|
||||||
|
"путь должен быть строкой или массивом строк".to_owned(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Путь в записи с разделителями `/`.
|
||||||
|
fn path_to_string(path: &Path) -> String {
|
||||||
|
path.to_string_lossy().replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Путь для сообщения об ошибке: пустой путь показывается как текущий каталог.
|
||||||
|
fn path_label(path: &Path) -> String {
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
".".to_owned()
|
||||||
|
} else {
|
||||||
|
path_to_string(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбирает Lua-таблицу, переданную в `api.editor:run_lsp`.
|
||||||
fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
|
fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
|
||||||
let name: String = table
|
let name: String = table
|
||||||
.get("name")
|
.get("name")
|
||||||
@@ -1423,7 +1798,8 @@ fn bind_key(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Разбирает обработчик, переданный в `app:map_key` / `app:map_completion_key`.
|
/// Разбирает обработчик, переданный в `api.keymap:map_key` /
|
||||||
|
/// `api.keymap:map_completion_key`.
|
||||||
///
|
///
|
||||||
/// `nil` означает «удалить привязку».
|
/// `nil` означает «удалить привязку».
|
||||||
fn parse_key_handler(
|
fn parse_key_handler(
|
||||||
|
|||||||
+62
-17
@@ -190,13 +190,13 @@ pub struct MyApp {
|
|||||||
/// Привязки клавиш редактора: действуют дополнительно к глобальным,
|
/// Привязки клавиш редактора: действуют дополнительно к глобальным,
|
||||||
/// когда фокус находится в редакторе.
|
/// когда фокус находится в редакторе.
|
||||||
editor_keymap: Rc<RefCell<KeyMap>>,
|
editor_keymap: Rc<RefCell<KeyMap>>,
|
||||||
/// Состояние левой панели, общее с `app.left_panel` в Lua.
|
/// Состояние левой панели, общее с `api.panels.left` в Lua.
|
||||||
left_panel: PanelHandle,
|
left_panel: PanelHandle,
|
||||||
/// Состояние правой панели, общее с `app.right_panel` в Lua.
|
/// Состояние правой панели, общее с `api.panels.right` в Lua.
|
||||||
right_panel: PanelHandle,
|
right_panel: PanelHandle,
|
||||||
/// Состояние нижней панели, общее с `app.bottom_panel` в Lua.
|
/// Состояние нижней панели, общее с `api.panels.bottom` в Lua.
|
||||||
bottom_panel: PanelHandle,
|
bottom_panel: PanelHandle,
|
||||||
/// Элементы верхней панели, общие с `app.toolbar` в Lua.
|
/// Элементы верхней панели, общие с `api.panels.toolbar` в Lua.
|
||||||
toolbar: ToolbarHandle,
|
toolbar: ToolbarHandle,
|
||||||
/// Открытость панелей в предыдущем кадре: по переходу «закрыта →
|
/// Открытость панелей в предыдущем кадре: по переходу «закрыта →
|
||||||
/// открыта» вид возвращает фокус в дерево, которое было выбрано раньше.
|
/// открыта» вид возвращает фокус в дерево, которое было выбрано раньше.
|
||||||
@@ -212,7 +212,7 @@ pub struct MyApp {
|
|||||||
displayed_id: u64,
|
displayed_id: u64,
|
||||||
/// Курсор редактора ещё ни разу не получал фокус.
|
/// Курсор редактора ещё ни разу не получал фокус.
|
||||||
focused_once: bool,
|
focused_once: bool,
|
||||||
/// Явный запрос `app:open(path, ...)` на фокус в редакторе.
|
/// Явный запрос `api.editor:open(path, ...)` на фокус в редакторе.
|
||||||
///
|
///
|
||||||
/// Применяется повторно после отрисовки панелей: дерево могло отложить
|
/// Применяется повторно после отрисовки панелей: дерево могло отложить
|
||||||
/// собственный запрос фокуса и перехватить его в этом же кадре.
|
/// собственный запрос фокуса и перехватить его в этом же кадре.
|
||||||
@@ -805,7 +805,7 @@ impl MyApp {
|
|||||||
&& let Some(callback) = callback
|
&& let Some(callback) = callback
|
||||||
&& let Err(err) = callback.call::<()>(text)
|
&& let Err(err) = callback.call::<()>(text)
|
||||||
{
|
{
|
||||||
eprintln!("Lua: ошибка в обработчике app:open_input: {err}");
|
eprintln!("Lua: ошибка в обработчике api.window:open_input: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -1001,7 +1001,7 @@ impl MyApp {
|
|||||||
/// закрывают список и работают с курсором как обычно.
|
/// закрывают список и работают с курсором как обычно.
|
||||||
///
|
///
|
||||||
/// Все привязки этого слоя приходят из [`KeyMap`], поэтому их можно
|
/// Все привязки этого слоя приходят из [`KeyMap`], поэтому их можно
|
||||||
/// заменить через `app:map_completion_key`.
|
/// заменить через `api.keymap:map_completion_key`.
|
||||||
fn handle_completion_key(
|
fn handle_completion_key(
|
||||||
&mut self,
|
&mut self,
|
||||||
keystroke: &Keystroke,
|
keystroke: &Keystroke,
|
||||||
@@ -1420,7 +1420,7 @@ impl MyApp {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.child(
|
.child(
|
||||||
"Серверы LSP не запущены. Запустите их из init.lua через app:run_lsp(...).",
|
"Серверы LSP не запущены. Запустите их из init.lua через api.editor:run_lsp(...).",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -1504,6 +1504,9 @@ impl MyApp {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
|
// Тот же токен, что и у активной вкладки: содержимое
|
||||||
|
// блока продолжает её фон и сливается с полосой вкладок.
|
||||||
|
.bg(cx.theme().tokens.tab_active)
|
||||||
.child(self.render_tree_block(tree, window, cx)),
|
.child(self.render_tree_block(tree, window, cx)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1580,6 +1583,9 @@ impl MyApp {
|
|||||||
if focus_requested {
|
if focus_requested {
|
||||||
focus_handle.focus(window, cx);
|
focus_handle.focus(window, cx);
|
||||||
}
|
}
|
||||||
|
// Выделение делаем заметнее, когда фокус на дереве: так видно,
|
||||||
|
// какая панель сейчас принимает клавиатуру.
|
||||||
|
let focused = focus_handle.is_focused(window);
|
||||||
|
|
||||||
let entity = cx.entity();
|
let entity = cx.entity();
|
||||||
let tree_ptr = Rc::as_ptr(tree) as usize;
|
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 is_selected = selected.as_deref() == Some(row.id.as_str());
|
||||||
let hover_bg = cx.theme().list_hover;
|
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()
|
let mut line = h_flex()
|
||||||
.id(ElementId::Name(
|
.id(ElementId::Name(
|
||||||
@@ -1764,6 +1774,19 @@ impl MyApp {
|
|||||||
fn sync_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn sync_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let revision = self.workspace.borrow().revision();
|
let revision = self.workspace.borrow().revision();
|
||||||
if revision == self.synced_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;
|
return;
|
||||||
}
|
}
|
||||||
self.synced_revision = revision;
|
self.synced_revision = revision;
|
||||||
@@ -1782,7 +1805,7 @@ impl MyApp {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// `None` сохраняет прежнее поведение: любое внешнее изменение само
|
// `None` сохраняет прежнее поведение: любое внешнее изменение само
|
||||||
// возвращает фокус редактору. `app:open` без позиции это отменяет.
|
// возвращает фокус редактору. `api.editor:open` без позиции это отменяет.
|
||||||
let (focus_editor, focus_cursor) = match focus_after_sync {
|
let (focus_editor, focus_cursor) = match focus_after_sync {
|
||||||
Some(FocusAfterSync::Keep) => (false, None),
|
Some(FocusAfterSync::Keep) => (false, None),
|
||||||
Some(FocusAfterSync::Editor(cursor)) => (true, cursor),
|
Some(FocusAfterSync::Editor(cursor)) => (true, cursor),
|
||||||
@@ -1993,7 +2016,7 @@ impl MyApp {
|
|||||||
|
|
||||||
/// Полоса между вкладками и редактором.
|
/// Полоса между вкладками и редактором.
|
||||||
///
|
///
|
||||||
/// Элементы добавляются сценарием через `app.toolbar`. Левые элементы
|
/// Элементы добавляются сценарием через `api.panels.toolbar`. Левые элементы
|
||||||
/// выстраиваются от левого края вправо, правые — от правого края влево.
|
/// выстраиваются от левого края вправо, правые — от правого края влево.
|
||||||
fn render_editor_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_editor_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let entity = cx.entity();
|
let entity = cx.entity();
|
||||||
@@ -2224,7 +2247,7 @@ impl MyApp {
|
|||||||
|
|
||||||
impl Render for MyApp {
|
impl Render for MyApp {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
// Состояние окна нужно `app:is_maximized()` из Lua.
|
// Состояние окна нужно `api.window:is_maximized()` из Lua.
|
||||||
api::set_window_maximized(window.is_maximized());
|
api::set_window_maximized(window.is_maximized());
|
||||||
// Команды могли накопиться в Lua-обработчике или в стартовом сценарии.
|
// Команды могли накопиться в Lua-обработчике или в стартовом сценарии.
|
||||||
apply_window_commands(window);
|
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();
|
let workspace = self.render_workspace(window, cx).into_any_element();
|
||||||
if self.focus_editor_after_render {
|
if self.focus_editor_after_render {
|
||||||
self.focus_editor_after_render = false;
|
self.focus_editor_after_render = false;
|
||||||
@@ -2290,6 +2313,9 @@ impl Render for MyApp {
|
|||||||
if entity.read(cx).prompt.is_some() {
|
if entity.read(cx).prompt.is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Деревья должны знать о фокусе до глобальных обработчиков:
|
||||||
|
// Lua проверяет это через `tree:has_focus()`.
|
||||||
|
sync_tree_focus(&panels, window);
|
||||||
// Сначала всегда срабатывают глобальные привязки.
|
// Сначала всегда срабатывают глобальные привязки.
|
||||||
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, true);
|
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() {
|
if entity.read(cx).prompt.is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
sync_tree_focus(&panels, window);
|
||||||
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, false);
|
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, false);
|
||||||
let focused = run_focused_keymap(
|
let focused = run_focused_keymap(
|
||||||
&editor,
|
&editor,
|
||||||
@@ -2598,13 +2625,13 @@ fn run_app_command(command: AppCommand) {
|
|||||||
editor.close_tab(editor.active_tab_index());
|
editor.close_tab(editor.active_tab_index());
|
||||||
}
|
}
|
||||||
AppCommand::ToggleLeftPanel => {
|
AppCommand::ToggleLeftPanel => {
|
||||||
app.left_panel().toggle();
|
app.panels().left().toggle();
|
||||||
}
|
}
|
||||||
AppCommand::ToggleRightPanel => {
|
AppCommand::ToggleRightPanel => {
|
||||||
app.right_panel().toggle();
|
app.panels().right().toggle();
|
||||||
}
|
}
|
||||||
AppCommand::ToggleBottomPanel => {
|
AppCommand::ToggleBottomPanel => {
|
||||||
app.bottom_panel().toggle();
|
app.panels().bottom().toggle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2651,6 +2678,24 @@ fn run_focused_keymap(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Обновляет признак клавиатурного фокуса у всех деревьев панелей.
|
||||||
|
///
|
||||||
|
/// Глобальные Lua-обработчики выполняются раньше привязок блока, поэтому
|
||||||
|
/// `tree:has_focus()` должен видеть актуальное состояние уже на этом шаге.
|
||||||
|
fn sync_tree_focus(panels: &[PanelHandle; 3], window: &Window) {
|
||||||
|
for panel in panels {
|
||||||
|
let panel = panel.borrow();
|
||||||
|
for block in panel.blocks() {
|
||||||
|
let PanelBlock::Tree { tree, .. } = block;
|
||||||
|
let mut tree = tree.borrow_mut();
|
||||||
|
let focused = tree
|
||||||
|
.existing_focus_handle()
|
||||||
|
.is_some_and(|focus| focus.is_focused(window));
|
||||||
|
tree.set_focused(focused);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ищет карту клавиш блока, владеющего фокусом.
|
/// Ищет карту клавиш блока, владеющего фокусом.
|
||||||
///
|
///
|
||||||
/// Когда у панелей появятся другие виды блоков, каждый из них должен отдавать
|
/// Когда у панелей появятся другие виды блоков, каждый из них должен отдавать
|
||||||
@@ -2676,7 +2721,7 @@ fn focused_block_keymap(panels: &[PanelHandle; 3], window: &Window) -> Option<Rc
|
|||||||
///
|
///
|
||||||
/// Возвращает `true`, если хотя бы одна привязка сработала. Заём карты
|
/// Возвращает `true`, если хотя бы одна привязка сработала. Заём карты
|
||||||
/// заканчивается до вызова колбэков: обработчик может сам вызвать
|
/// заканчивается до вызова колбэков: обработчик может сам вызвать
|
||||||
/// `app:map_key` и изменить привязки.
|
/// `api.keymap:map_key` и изменить привязки.
|
||||||
fn run_keymap(
|
fn run_keymap(
|
||||||
keymap: &Rc<RefCell<KeyMap>>,
|
keymap: &Rc<RefCell<KeyMap>>,
|
||||||
context: KeyContext,
|
context: KeyContext,
|
||||||
|
|||||||
+107
-15
@@ -24,13 +24,26 @@ pub enum Language {
|
|||||||
JavaScript,
|
JavaScript,
|
||||||
Json,
|
Json,
|
||||||
Python,
|
Python,
|
||||||
|
Lua,
|
||||||
|
Bash,
|
||||||
|
Yaml,
|
||||||
|
Make,
|
||||||
|
Dockerfile,
|
||||||
|
Sql,
|
||||||
|
Markdown,
|
||||||
|
Css,
|
||||||
|
C,
|
||||||
|
Cpp,
|
||||||
|
Html,
|
||||||
|
Xml,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Language {
|
impl Language {
|
||||||
/// Разбирает название языка из API/сценария.
|
/// Разбирает название языка из API/сценария.
|
||||||
///
|
///
|
||||||
/// Основные имена — `rust`, `go`, `js`, `json`, `python`; короткие
|
/// Основные имена — [`Self::name`]; короткие (`rs`, `golang`,
|
||||||
/// (`rs`, `golang`, `javascript`, `py`) и регистр тоже принимаются.
|
/// `javascript`, `py`, `sh`, `yml`, `makefile`, `md`, `c++`, …)
|
||||||
|
/// и регистр тоже принимаются.
|
||||||
pub fn from_name(name: &str) -> Option<Self> {
|
pub fn from_name(name: &str) -> Option<Self> {
|
||||||
match name.trim().to_ascii_lowercase().as_str() {
|
match name.trim().to_ascii_lowercase().as_str() {
|
||||||
"rust" | "rs" => Some(Self::Rust),
|
"rust" | "rs" => Some(Self::Rust),
|
||||||
@@ -38,6 +51,18 @@ impl Language {
|
|||||||
"js" | "javascript" | "node" => Some(Self::JavaScript),
|
"js" | "javascript" | "node" => Some(Self::JavaScript),
|
||||||
"json" => Some(Self::Json),
|
"json" => Some(Self::Json),
|
||||||
"python" | "py" => Some(Self::Python),
|
"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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +75,18 @@ impl Language {
|
|||||||
Self::JavaScript => "js",
|
Self::JavaScript => "js",
|
||||||
Self::Json => "json",
|
Self::Json => "json",
|
||||||
Self::Python => "python",
|
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.
|
/// в Lua-API.
|
||||||
pub fn syntax_name(self) -> &'static str {
|
pub fn syntax_name(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Rust => "rust",
|
|
||||||
Self::Go => "go",
|
|
||||||
Self::JavaScript => "javascript",
|
Self::JavaScript => "javascript",
|
||||||
Self::Json => "json",
|
_ => self.name(),
|
||||||
Self::Python => "python",
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Идентификатор языка для LSP.
|
||||||
|
///
|
||||||
|
/// Совпадает с [`Self::syntax_name`], кроме Bash и Make: грамматики
|
||||||
|
/// называются `bash` и `make`, а `languageId` в LSP — `shellscript` и
|
||||||
|
/// `makefile`.
|
||||||
|
pub fn lsp_language_id(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Bash => "shellscript",
|
||||||
|
Self::Make => "makefile",
|
||||||
|
_ => self.syntax_name(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Определяет язык подсветки по имени файла.
|
/// Определяет язык подсветки по имени файла.
|
||||||
///
|
///
|
||||||
/// Используется при открытии файла с диска (`app:open(path)`), чтобы
|
/// Используется при открытии файла с диска (`api.editor:open(path)`), чтобы
|
||||||
/// подсветка включалась автоматически там, где для неё есть грамматика.
|
/// подсветка включалась автоматически там, где для неё есть грамматика.
|
||||||
|
/// Кроме расширений учитываются файлы без расширения (`Makefile`,
|
||||||
|
/// `Dockerfile`, `Containerfile`).
|
||||||
pub fn from_path(path: &str) -> Option<Self> {
|
pub fn from_path(path: &str) -> Option<Self> {
|
||||||
let extension = path.rsplit_once('.')?.1.to_ascii_lowercase();
|
if let Some(language) = special_file_language(path) {
|
||||||
match extension.as_str() {
|
return Some(language);
|
||||||
|
}
|
||||||
|
let file_name = file_name(path);
|
||||||
|
let (_, extension) = file_name.rsplit_once('.')?;
|
||||||
|
Self::from_extension(extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Язык по расширению файла без учёта регистра.
|
||||||
|
fn from_extension(extension: &str) -> Option<Self> {
|
||||||
|
match extension.to_ascii_lowercase().as_str() {
|
||||||
"rs" => Some(Self::Rust),
|
"rs" => Some(Self::Rust),
|
||||||
"go" => Some(Self::Go),
|
"go" => Some(Self::Go),
|
||||||
"js" | "mjs" | "cjs" | "jsx" => Some(Self::JavaScript),
|
"js" | "mjs" | "cjs" | "jsx" => Some(Self::JavaScript),
|
||||||
"json" => Some(Self::Json),
|
"json" => Some(Self::Json),
|
||||||
"py" => Some(Self::Python),
|
"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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Имя файла без каталогов.
|
||||||
|
fn file_name(path: &str) -> &str {
|
||||||
|
path.rsplit(['/', '\\']).next().unwrap_or(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Языки, у которых имя файла не содержит расширения.
|
||||||
|
fn special_file_language(path: &str) -> Option<Language> {
|
||||||
|
match file_name(path).to_ascii_lowercase().as_str() {
|
||||||
|
"makefile" | "gnumakefile" | "bsdmakefile" => Some(Language::Make),
|
||||||
|
"dockerfile" | "containerfile" => Some(Language::Dockerfile),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Язык LSP по расширению имени файла.
|
/// Язык LSP по расширению имени файла.
|
||||||
///
|
///
|
||||||
/// Нужен для документов, у которых подсветка ещё не включена: LSP-сервер
|
/// Нужен для документов, у которых подсветка ещё не включена: LSP-сервер
|
||||||
/// выбирается и получает `languageId` независимо от tree-sitter.
|
/// выбирается и получает `languageId` независимо от tree-sitter.
|
||||||
pub fn language_id_from_path(name: &str) -> &'static str {
|
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('.')
|
.rsplit_once('.')
|
||||||
.map(|(_, extension)| extension.to_ascii_lowercase());
|
.map(|(_, extension)| extension.to_ascii_lowercase());
|
||||||
|
|
||||||
@@ -103,15 +191,19 @@ pub fn language_id_from_path(name: &str) -> &'static str {
|
|||||||
Some("tsx") => "typescriptreact",
|
Some("tsx") => "typescriptreact",
|
||||||
Some("json") => "json",
|
Some("json") => "json",
|
||||||
Some("py") => "python",
|
Some("py") => "python",
|
||||||
Some("md" | "markdown") => "markdown",
|
Some("md" | "markdown" | "mdx") => "markdown",
|
||||||
Some("toml") => "toml",
|
Some("toml") => "toml",
|
||||||
Some("yaml" | "yml") => "yaml",
|
Some("yaml" | "yml") => "yaml",
|
||||||
Some("lua") => "lua",
|
Some("lua") => "lua",
|
||||||
Some("c" | "h") => "c",
|
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("sh" | "bash") => "shellscript",
|
||||||
|
Some("mk") => "makefile",
|
||||||
|
Some("dockerfile") => "dockerfile",
|
||||||
|
Some("sql") => "sql",
|
||||||
Some("html" | "htm") => "html",
|
Some("html" | "htm") => "html",
|
||||||
Some("css") => "css",
|
Some("css") => "css",
|
||||||
|
Some("xml" | "xsd" | "xsl" | "xslt" | "svg") => "xml",
|
||||||
_ => "plaintext",
|
_ => "plaintext",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +261,7 @@ impl Document {
|
|||||||
/// по расширению имени вкладки — так LSP работает и без `set_language`.
|
/// по расширению имени вкладки — так LSP работает и без `set_language`.
|
||||||
pub fn lsp_language_id(&self) -> String {
|
pub fn lsp_language_id(&self) -> String {
|
||||||
if let Some(language) = self.language {
|
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()
|
language_id_from_path(&self.name).to_owned()
|
||||||
}
|
}
|
||||||
@@ -190,7 +282,7 @@ impl Document {
|
|||||||
/// Что сделать с фокусом редактора после синхронизации модели с видом.
|
/// Что сделать с фокусом редактора после синхронизации модели с видом.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum FocusAfterSync {
|
pub enum FocusAfterSync {
|
||||||
/// Оставить фокус там, где он был (`app:open` без позиции).
|
/// Оставить фокус там, где он был (`api.editor:open` без позиции).
|
||||||
Keep,
|
Keep,
|
||||||
/// Перевести фокус в редактор и, если задано, поставить курсор.
|
/// Перевести фокус в редактор и, если задано, поставить курсор.
|
||||||
///
|
///
|
||||||
@@ -308,7 +400,7 @@ impl Workspace {
|
|||||||
/// Открывает файл с диска как вкладку и делает её активной.
|
/// Открывает файл с диска как вкладку и делает её активной.
|
||||||
///
|
///
|
||||||
/// Если вкладка с таким именем уже открыта и не содержит несохранённых
|
/// Если вкладка с таким именем уже открыта и не содержит несохранённых
|
||||||
/// изменений, её содержимое обновляется из файла — повторный `app:open`
|
/// изменений, её содержимое обновляется из файла — повторный `api.editor:open`
|
||||||
/// не создаёт дубликат. Грязная вкладка не трогается: открывается новая.
|
/// не создаёт дубликат. Грязная вкладка не трогается: открывается новая.
|
||||||
pub fn open_file(
|
pub fn open_file(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
//! Иконки на поле редактора (gutter).
|
//! Иконки на поле редактора (gutter).
|
||||||
//!
|
//!
|
||||||
//! Gutter — узкая колонка слева от редактора. Сценарий ставит на строку
|
//! Gutter — узкая колонка слева от редактора. Сценарий ставит на строку
|
||||||
//! иконку через `app:set_gutter_icon`, а вид рисует её на той же высоте, что и
|
//! иконку через `api.editor:set_gutter_icon`, а вид рисует её на той же высоте, что и
|
||||||
//! строка в редакторе, учитывая прокрутку.
|
//! строка в редакторе, учитывая прокрутку.
|
||||||
//!
|
//!
|
||||||
//! Состояние принадлежит документу: у каждой вкладки свои иконки, и при
|
//! Состояние принадлежит документу: у каждой вкладки свои иконки, и при
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
//! Иконки вкладок.
|
//! Иконки вкладок.
|
||||||
//!
|
//!
|
||||||
//! Имена совпадают с названиями Lucide (`file`, `file-code`, `book-open`) —
|
//! Имена совпадают с названиями Lucide (`file`, `file-code`, `book-open`) —
|
||||||
//! именно их принимает `app:set_tab_icon(index, name)`. Неизвестное имя не
|
//! именно их принимает `api.editor:set_tab_icon(index, name)`. Неизвестное имя не
|
||||||
//! подменяется молча: API возвращает `false`, а вкладка продолжает показывать
|
//! подменяется молча: API возвращает `false`, а вкладка продолжает показывать
|
||||||
//! иконку по умолчанию.
|
//! иконку по умолчанию.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -460,7 +460,7 @@ impl KeyMap {
|
|||||||
///
|
///
|
||||||
/// Заём `KeyMap` здесь заканчивается: вызывать Lua нужно уже после
|
/// Заём `KeyMap` здесь заканчивается: вызывать Lua нужно уже после
|
||||||
/// возврата из метода, потому что обработчик может сам менять привязки
|
/// возврата из метода, потому что обработчик может сам менять привязки
|
||||||
/// через `app:map_key`.
|
/// через `api.keymap:map_key`.
|
||||||
pub fn handlers_for(
|
pub fn handlers_for(
|
||||||
&self,
|
&self,
|
||||||
context: KeyContext,
|
context: KeyContext,
|
||||||
@@ -480,8 +480,8 @@ impl KeyMap {
|
|||||||
|
|
||||||
/// Назначает встроенные привязки приложения.
|
/// Назначает встроенные привязки приложения.
|
||||||
///
|
///
|
||||||
/// Они проходят через тот же `KeyMap`, поэтому `app:map_key` и
|
/// Они проходят через тот же `KeyMap`, поэтому `api.keymap:map_key` и
|
||||||
/// `app:map_completion_key` могут их заменить или удалить.
|
/// `api.keymap:map_completion_key` могут их заменить или удалить.
|
||||||
pub fn install_defaults(&mut self) {
|
pub fn install_defaults(&mut self) {
|
||||||
for spec in ["<C-s>", "<D-s>"] {
|
for spec in ["<C-s>", "<D-s>"] {
|
||||||
self.set(
|
self.set(
|
||||||
|
|||||||
+2
-2
@@ -36,7 +36,7 @@ use serde_json::{Value, json};
|
|||||||
|
|
||||||
/// Диагностика одного сервера для одного документа.
|
/// Диагностика одного сервера для одного документа.
|
||||||
///
|
///
|
||||||
/// Эту структуру Lua получает в обработчике `app:on_lsp_diagnostics(...)`:
|
/// Эту структуру Lua получает в обработчике `api.editor:on_lsp_diagnostics(...)`:
|
||||||
/// она содержит имя приславшего сервера, URI и путь файла, а также список
|
/// она содержит имя приславшего сервера, URI и путь файла, а также список
|
||||||
/// сообщений.
|
/// сообщений.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -580,7 +580,7 @@ impl LspManager {
|
|||||||
|
|
||||||
/// Снимок всей диагностики всех серверов.
|
/// Снимок всей диагностики всех серверов.
|
||||||
///
|
///
|
||||||
/// Используется обработчиком `app:on_lsp_diagnostics(...)`: он получает
|
/// Используется обработчиком `api.editor:on_lsp_diagnostics(...)`: он получает
|
||||||
/// файлы, серверы и сообщения целиком, а не только для активной вкладки.
|
/// файлы, серверы и сообщения целиком, а не только для активной вкладки.
|
||||||
pub fn diagnostics_snapshot(&self) -> Vec<DocumentDiagnostics> {
|
pub fn diagnostics_snapshot(&self) -> Vec<DocumentDiagnostics> {
|
||||||
self.diagnostics.snapshot()
|
self.diagnostics.snapshot()
|
||||||
|
|||||||
+4
-1
@@ -17,6 +17,7 @@ mod lsp;
|
|||||||
mod panel;
|
mod panel;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod script;
|
mod script;
|
||||||
|
mod syntax;
|
||||||
mod toolbar;
|
mod toolbar;
|
||||||
mod tree;
|
mod tree;
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ fn main() {
|
|||||||
application().with_assets(assets::AllAssets).run(|cx| {
|
application().with_assets(assets::AllAssets).run(|cx| {
|
||||||
// Инициализация темы, шрифтов и компонентов обязательна до создания окон.
|
// Инициализация темы, шрифтов и компонентов обязательна до создания окон.
|
||||||
init(cx);
|
init(cx);
|
||||||
|
// Грамматики Dockerfile и XML в gpui-kit не входят — регистрируем их сами.
|
||||||
|
syntax::register_extra_grammars();
|
||||||
|
|
||||||
open_window(window_options(), cx, |window, cx| {
|
open_window(window_options(), cx, |window, cx| {
|
||||||
cx.new(|cx| MyApp::new(window, cx).with_startup_script())
|
cx.new(|cx| MyApp::new(window, cx).with_startup_script())
|
||||||
@@ -52,7 +55,7 @@ fn main() {
|
|||||||
/// Применяет аргументы запуска до создания окна.
|
/// Применяет аргументы запуска до создания окна.
|
||||||
///
|
///
|
||||||
/// `--config` задаёт каталог `init.lua`, а первый позиционный аргумент — файл,
|
/// `--config` задаёт каталог `init.lua`, а первый позиционный аргумент — файл,
|
||||||
/// который [`MyApp`] откроет через `app:open(path)` после выполнения сценария.
|
/// который [`MyApp`] откроет через `api.editor:open(path)` после выполнения сценария.
|
||||||
/// Рабочий каталог процесса и пути сохранения файлов не меняются.
|
/// Рабочий каталог процесса и пути сохранения файлов не меняются.
|
||||||
fn apply_command_line() -> Result<(), String> {
|
fn apply_command_line() -> Result<(), String> {
|
||||||
let command_line = constants::parse_command_line(std::env::args_os().skip(1))?;
|
let command_line = constants::parse_command_line(std::env::args_os().skip(1))?;
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
//! Состояние боковых панелей окна.
|
//! Состояние боковых панелей окна.
|
||||||
//!
|
//!
|
||||||
//! Модель общая с API: Lua-сценарии меняют [`PanelState`] через `app.right_panel`
|
//! Модель общая с API: Lua-сценарии меняют [`PanelState`] через `api.panels.right`
|
||||||
//! и `app.bottom_panel`, а вид [`MyApp`](crate::app::MyApp) читает его при
|
//! и `api.panels.bottom`, а вид [`MyApp`](crate::app::MyApp) читает его при
|
||||||
//! отрисовке. Как и документы, состояние лежит в `Rc<RefCell<_>>`, чтобы вид и
|
//! отрисовке. Как и документы, состояние лежит в `Rc<RefCell<_>>`, чтобы вид и
|
||||||
//! API видели одни и те же панели.
|
//! API видели одни и те же панели.
|
||||||
//!
|
//!
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
//! Плавающее поле ввода, открываемое из Lua.
|
//! Плавающее поле ввода, открываемое из Lua.
|
||||||
//!
|
//!
|
||||||
//! Сценарий вызывает `app:open_input{ title = "Имя", callback = function(text) end }`.
|
//! Сценарий вызывает `api.window:open_input{ title = "Имя", callback = function(text) end }`.
|
||||||
//! Запрос складывается в общее состояние API, а вид при следующем кадре
|
//! Запрос складывается в общее состояние API, а вид при следующем кадре
|
||||||
//! создаёт поле ввода, показывает его чуть выше центра и забирает фокус.
|
//! создаёт поле ввода, показывает его чуть выше центра и забирает фокус.
|
||||||
//! Пока окно открыто, сочетания клавиш приложения не работают: текст набируется
|
//! Пока окно открыто, сочетания клавиш приложения не работают: текст набируется
|
||||||
@@ -24,7 +24,7 @@ pub struct PromptRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PromptRequest {
|
impl PromptRequest {
|
||||||
/// Разбирает Lua-таблицу опций `app:open_input`.
|
/// Разбирает Lua-таблицу опций `api.window:open_input`.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Возвращает ошибку Lua, если текстовое поле не строка или `callback`
|
/// Возвращает ошибку Lua, если текстовое поле не строка или `callback`
|
||||||
@@ -46,7 +46,7 @@ impl PromptRequest {
|
|||||||
mlua::Value::Function(callback) => Some(callback),
|
mlua::Value::Function(callback) => Some(callback),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(mlua::Error::RuntimeError(
|
return Err(mlua::Error::RuntimeError(
|
||||||
"app:open_input: поле callback должно быть функцией".to_owned(),
|
"api.window:open_input: поле callback должно быть функцией".to_owned(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -68,7 +68,7 @@ fn optional_text(options: &mlua::Table, field: &str) -> mlua::Result<Option<Stri
|
|||||||
Ok((!text.is_empty()).then_some(text))
|
Ok((!text.is_empty()).then_some(text))
|
||||||
}
|
}
|
||||||
_ => Err(mlua::Error::RuntimeError(format!(
|
_ => Err(mlua::Error::RuntimeError(format!(
|
||||||
"app:open_input: поле {field} должно быть строкой"
|
"api.window:open_input: поле {field} должно быть строкой"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -3,9 +3,10 @@
|
|||||||
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`script_dir`]
|
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`script_dir`]
|
||||||
//! (см. [`ScriptEngine::run_startup`]). Скриптам доступны:
|
//! (см. [`ScriptEngine::run_startup`]). Скриптам доступны:
|
||||||
//!
|
//!
|
||||||
//! * объект `app` — управление окном и панелями (методы [`AppApi`], вызываются
|
//! * объект `api` — управление приложением ([`AppApi`]). Он разбит на группы:
|
||||||
//! через двоеточие: `app:next_tab()`); во вложенном `app.editor` живёт API
|
//! `api.editor` (редактор), `api.panels` (панели), `api.path` (файловая
|
||||||
//! редактора ([`EditorApi`](crate::api::EditorApi)): `app.editor:save()`;
|
//! система), `api.window` (окно и поле ввода) и `api.keymap` (глобальные
|
||||||
|
//! привязки); вызов — через двоеточие: `api.editor:save()`;
|
||||||
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`script_dir`]
|
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`script_dir`]
|
||||||
//! (или по абсолютному пути) в общем глобальном окружении;
|
//! (или по абсолютному пути) в общем глобальном окружении;
|
||||||
//! * стандартный `require`, который также ищет модули в [`script_dir`].
|
//! * стандартный `require`, который также ищет модули в [`script_dir`].
|
||||||
@@ -102,7 +103,7 @@ impl ScriptEngine {
|
|||||||
lua: Lua::new(),
|
lua: Lua::new(),
|
||||||
script_dir: directory.as_ref().to_path_buf(),
|
script_dir: directory.as_ref().to_path_buf(),
|
||||||
};
|
};
|
||||||
engine.expose_app_api(api)?;
|
engine.expose_api(api)?;
|
||||||
engine.configure_require()?;
|
engine.configure_require()?;
|
||||||
Ok(engine)
|
Ok(engine)
|
||||||
}
|
}
|
||||||
@@ -121,15 +122,16 @@ impl ScriptEngine {
|
|||||||
run_file(&self.lua, path).map(|_| ())
|
run_file(&self.lua, path).map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Публикует объект `app` с функциями управления приложением.
|
/// Публикует объект `api` с функциями управления приложением.
|
||||||
///
|
///
|
||||||
/// Все методы [`AppApi`] из `#[mlua::userdata_impl]`-блока доступны как
|
/// Все методы объектов [`AppApi`] из `#[mlua::userdata_impl]`-блоков
|
||||||
/// методы userdata: `app:next_tab()`, `app:set_active_text("...")`.
|
/// доступны как методы userdata: `api.editor:save()`,
|
||||||
/// Номера вкладок всюду нумеруются с нуля.
|
/// `api.path:list("src")`, `api.window:close()`. Номера вкладок всюду
|
||||||
fn expose_app_api(&self, api: AppApi) -> mlua::Result<()> {
|
/// нумеруются с нуля.
|
||||||
|
fn expose_api(&self, api: AppApi) -> mlua::Result<()> {
|
||||||
self.lua
|
self.lua
|
||||||
.globals()
|
.globals()
|
||||||
.set("app", self.lua.create_userdata(api)?)?;
|
.set("api", self.lua.create_userdata(api)?)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//! Дополнительные грамматики tree-sitter.
|
||||||
|
//!
|
||||||
|
//! Большинство языков приходит из `gpui-kit` готовыми флагами `tree-sitter-*`.
|
||||||
|
//! Для Dockerfile и XML таких флагов нет, поэтому их грамматики регистрируются
|
||||||
|
//! в общем `LanguageRegistry` вручную. Регистрация выполняется до первой
|
||||||
|
//! отрисовки редактора и повторный вызов безопасен.
|
||||||
|
|
||||||
|
use gpui_kit::component::highlighter::{GrammarConfig, LanguageRegistry};
|
||||||
|
|
||||||
|
/// Регистрирует грамматики, которых нет в `gpui-kit`.
|
||||||
|
pub fn register_extra_grammars() {
|
||||||
|
let registry = LanguageRegistry::singleton();
|
||||||
|
|
||||||
|
// Команды, строки и heredoc Dockerfile/Containerfile; shell-команды
|
||||||
|
// внутри `RUN` подсвечиваются через инъекцию грамматики bash.
|
||||||
|
registry.register(
|
||||||
|
"dockerfile",
|
||||||
|
&GrammarConfig::new(
|
||||||
|
"dockerfile",
|
||||||
|
tree_sitter_containerfile::LANGUAGE.into(),
|
||||||
|
Vec::new(),
|
||||||
|
tree_sitter_containerfile::HIGHLIGHTS_QUERY,
|
||||||
|
tree_sitter_containerfile::INJECTIONS_QUERY,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
"xml",
|
||||||
|
&GrammarConfig::new(
|
||||||
|
"xml",
|
||||||
|
tree_sitter_xml::LANGUAGE_XML.into(),
|
||||||
|
Vec::new(),
|
||||||
|
tree_sitter_xml::XML_HIGHLIGHT_QUERY,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extra_grammars_are_registered_with_a_highlight_query() {
|
||||||
|
register_extra_grammars();
|
||||||
|
let registry = LanguageRegistry::singleton();
|
||||||
|
|
||||||
|
for name in ["dockerfile", "xml"] {
|
||||||
|
let config = registry
|
||||||
|
.language(name)
|
||||||
|
.unwrap_or_else(|| panic!("язык {name:?} не зарегистрирован"));
|
||||||
|
assert_eq!(config.name, name);
|
||||||
|
assert!(config.has_grammar(), "у языка {name:?} нет грамматики");
|
||||||
|
assert!(
|
||||||
|
!config.highlights.is_empty(),
|
||||||
|
"у языка {name:?} нет запроса подсветки"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+477
-137
@@ -36,12 +36,12 @@ fn lua_lsp_api_lists_no_servers_and_validates_config() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
r#"
|
||||||
assert(#app.editor:lsp_servers() == 0)
|
assert(#api.editor:lsp_servers() == 0)
|
||||||
assert(app.editor:stop_lsp("missing") == false)
|
assert(api.editor:stop_lsp("missing") == false)
|
||||||
assert(app.editor:restart_lsp("missing") == false)
|
assert(api.editor:restart_lsp("missing") == false)
|
||||||
|
|
||||||
-- Без cmd сервер не запускается, а ошибка объясняет, чего не хватает.
|
-- Без 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(not ok)
|
||||||
assert(tostring(err):find("cmd", 1, true), tostring(err))
|
assert(tostring(err):find("cmd", 1, true), tostring(err))
|
||||||
"#,
|
"#,
|
||||||
@@ -184,14 +184,14 @@ fn lua_script_can_set_tab_icons() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
r#"
|
||||||
assert(app.editor:tab_icon(0) == "file")
|
assert(api.editor:tab_icon(0) == "file")
|
||||||
assert(app.editor:set_tab_icon(0, "book-open"))
|
assert(api.editor:set_tab_icon(0, "book-open"))
|
||||||
assert(app.editor:tab_icon(0) == "book-open")
|
assert(api.editor:tab_icon(0) == "book-open")
|
||||||
app.editor:open_tab()
|
api.editor:open_tab()
|
||||||
assert(app.editor:tabs()[2].icon == "file")
|
assert(api.editor:tabs()[2].icon == "file")
|
||||||
assert(not app.editor:set_tab_icon(0, "no-such-icon"))
|
assert(not api.editor:set_tab_icon(0, "no-such-icon"))
|
||||||
assert(app.editor:clear_tab_icon(0))
|
assert(api.editor:clear_tab_icon(0))
|
||||||
assert(app.editor:tab_icon(0) == "file")
|
assert(api.editor:tab_icon(0) == "file")
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -253,26 +253,26 @@ fn lua_script_can_set_gutter_icons() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r##"
|
r##"
|
||||||
assert(app.editor:gutter_icon(0) == nil)
|
assert(api.editor:gutter_icon(0) == nil)
|
||||||
assert(app.editor:set_gutter_icon(4, "circle"))
|
assert(api.editor:set_gutter_icon(4, "circle"))
|
||||||
assert(app.editor:gutter_icon(4) == "circle")
|
assert(api.editor:gutter_icon(4) == "circle")
|
||||||
assert(app.editor:gutter_color(4) == nil)
|
assert(api.editor:gutter_color(4) == nil)
|
||||||
|
|
||||||
assert(app.editor:set_gutter_icon(4, "play", "#00ff00"))
|
assert(api.editor:set_gutter_icon(4, "play", "#00ff00"))
|
||||||
assert(app.editor:gutter_icon(4) == "play")
|
assert(api.editor:gutter_icon(4) == "play")
|
||||||
assert(app.editor:gutter_color(4) == "#00ff00")
|
assert(api.editor:gutter_color(4) == "#00ff00")
|
||||||
|
|
||||||
assert(not app.editor:set_gutter_icon(1, "no-such-icon"))
|
assert(not api.editor:set_gutter_icon(1, "no-such-icon"))
|
||||||
assert(not app.editor:set_gutter_icon(1, "circle", "oops"))
|
assert(not api.editor:set_gutter_icon(1, "circle", "oops"))
|
||||||
assert(app.editor:gutter_icon(1) == nil)
|
assert(api.editor:gutter_icon(1) == nil)
|
||||||
|
|
||||||
assert(app.editor:clear_gutter_icon(4))
|
assert(api.editor:clear_gutter_icon(4))
|
||||||
assert(not app.editor:clear_gutter_icon(4))
|
assert(not api.editor:clear_gutter_icon(4))
|
||||||
assert(app.editor:gutter_icon(4) == nil)
|
assert(api.editor:gutter_icon(4) == nil)
|
||||||
|
|
||||||
app.editor:set_gutter_icon(0, "circle")
|
api.editor:set_gutter_icon(0, "circle")
|
||||||
app.editor:clear_gutter()
|
api.editor:clear_gutter()
|
||||||
assert(app.editor:gutter_icon(0) == nil)
|
assert(api.editor:gutter_icon(0) == nil)
|
||||||
"##,
|
"##,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -344,10 +344,10 @@ fn window_commands_are_queued_for_the_view() {
|
|||||||
// Очередь могла остаться от другого теста на этом потоке — очищаем.
|
// Очередь могла остаться от другого теста на этом потоке — очищаем.
|
||||||
let _ = drain_window_commands();
|
let _ = drain_window_commands();
|
||||||
|
|
||||||
api.minimize();
|
api.window().minimize();
|
||||||
api.toggle_maximize();
|
api.window().toggle_maximize();
|
||||||
api.set_maximized(false);
|
api.window().set_maximized(false);
|
||||||
api.close();
|
api.window().close();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
drain_window_commands(),
|
drain_window_commands(),
|
||||||
@@ -374,26 +374,26 @@ fn panel_api_opens_closes_and_toggles_the_same_panel_state() {
|
|||||||
assert!(!right.borrow().is_open());
|
assert!(!right.borrow().is_open());
|
||||||
assert!(!bottom.borrow().is_open());
|
assert!(!bottom.borrow().is_open());
|
||||||
|
|
||||||
api.left_panel().open();
|
api.panels().left().open();
|
||||||
assert!(left.borrow().is_open());
|
assert!(left.borrow().is_open());
|
||||||
assert!(api.left_panel().is_open());
|
assert!(api.panels().left().is_open());
|
||||||
api.left_panel().close();
|
api.panels().left().close();
|
||||||
assert!(!left.borrow().is_open());
|
assert!(!left.borrow().is_open());
|
||||||
|
|
||||||
api.right_panel().open();
|
api.panels().right().open();
|
||||||
assert!(right.borrow().is_open());
|
assert!(right.borrow().is_open());
|
||||||
assert!(api.right_panel().is_open());
|
assert!(api.panels().right().is_open());
|
||||||
|
|
||||||
// toggle возвращает новое состояние и не трогает вторую панель.
|
// toggle возвращает новое состояние и не трогает вторую панель.
|
||||||
assert!(!api.right_panel().toggle());
|
assert!(!api.panels().right().toggle());
|
||||||
assert!(!right.borrow().is_open());
|
assert!(!right.borrow().is_open());
|
||||||
assert!(api.right_panel().toggle());
|
assert!(api.panels().right().toggle());
|
||||||
assert!(right.borrow().is_open());
|
assert!(right.borrow().is_open());
|
||||||
|
|
||||||
api.right_panel().close();
|
api.panels().right().close();
|
||||||
assert!(!right.borrow().is_open());
|
assert!(!right.borrow().is_open());
|
||||||
|
|
||||||
api.bottom_panel().open();
|
api.panels().bottom().open();
|
||||||
assert!(bottom.borrow().is_open());
|
assert!(bottom.borrow().is_open());
|
||||||
assert!(!right.borrow().is_open());
|
assert!(!right.borrow().is_open());
|
||||||
}
|
}
|
||||||
@@ -411,25 +411,25 @@ fn lua_script_can_control_panels() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
r#"
|
||||||
app.left_panel:open()
|
api.panels.left:open()
|
||||||
assert(app.left_panel:is_open())
|
assert(api.panels.left:is_open())
|
||||||
app.left_panel:close()
|
api.panels.left:close()
|
||||||
assert(not app.left_panel:is_open())
|
assert(not api.panels.left:is_open())
|
||||||
assert(app.left_panel:toggle() == true)
|
assert(api.panels.left:toggle() == true)
|
||||||
|
|
||||||
app.right_panel:open()
|
api.panels.right:open()
|
||||||
assert(app.right_panel:is_open())
|
assert(api.panels.right:is_open())
|
||||||
assert(not app.bottom_panel:is_open())
|
assert(not api.panels.bottom:is_open())
|
||||||
|
|
||||||
app.bottom_panel:open()
|
api.panels.bottom:open()
|
||||||
app.right_panel:toggle()
|
api.panels.right:toggle()
|
||||||
assert(not app.right_panel:is_open())
|
assert(not api.panels.right:is_open())
|
||||||
|
|
||||||
assert(app.bottom_panel:toggle() == false)
|
assert(api.panels.bottom:toggle() == false)
|
||||||
app.bottom_panel:close()
|
api.panels.bottom:close()
|
||||||
assert(not app.bottom_panel:is_open())
|
assert(not api.panels.bottom:is_open())
|
||||||
|
|
||||||
assert(app.right_panel:toggle() == true)
|
assert(api.panels.right:toggle() == true)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.expect("panels.lua");
|
.expect("panels.lua");
|
||||||
@@ -457,24 +457,24 @@ fn lua_script_configures_toolbar_and_hides_items_by_id() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r##"
|
r##"
|
||||||
app.toolbar:add_label("Готово", nil, "#808080", "status", "left")
|
api.panels.toolbar:add_label("Готово", nil, "#808080", "status", "left")
|
||||||
app.toolbar:add_button("Запустить", "play", nil, "run", "right", function() end)
|
api.panels.toolbar:add_button("Запустить", "play", nil, "run", "right", function() end)
|
||||||
app.toolbar:add_label("UTF-8", nil, nil, "encoding", "right")
|
api.panels.toolbar:add_label("UTF-8", nil, nil, "encoding", "right")
|
||||||
|
|
||||||
assert(app.toolbar:hide("run") == true)
|
assert(api.panels.toolbar:hide("run") == true)
|
||||||
assert(app.toolbar:show("run") == true)
|
assert(api.panels.toolbar:show("run") == true)
|
||||||
assert(app.toolbar:hide("missing") == false)
|
assert(api.panels.toolbar:hide("missing") == false)
|
||||||
assert(app.toolbar:show("missing") == false)
|
assert(api.panels.toolbar:show("missing") == false)
|
||||||
|
|
||||||
-- Неизвестная сторона и иконка — ошибка, а не молчаливый пропуск.
|
-- Неизвестная сторона и иконка — ошибка, а не молчаливый пропуск.
|
||||||
assert(not pcall(function()
|
assert(not pcall(function()
|
||||||
app.toolbar:add_label("x", nil, nil, "bad", "middle")
|
api.panels.toolbar:add_label("x", nil, nil, "bad", "middle")
|
||||||
end))
|
end))
|
||||||
assert(not pcall(function()
|
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))
|
end))
|
||||||
assert(not pcall(function()
|
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))
|
end))
|
||||||
"##,
|
"##,
|
||||||
)
|
)
|
||||||
@@ -510,7 +510,7 @@ fn lua_script_configures_toolbar_and_hides_items_by_id() {
|
|||||||
fn toolbar_replaces_items_with_the_same_id() {
|
fn toolbar_replaces_items_with_the_same_id() {
|
||||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
let (api, _keymap) = install_api(workspace);
|
let (api, _keymap) = install_api(workspace);
|
||||||
let toolbar_api = api.toolbar();
|
let toolbar_api = api.panels().toolbar();
|
||||||
let toolbar = api.toolbar_handle();
|
let toolbar = api.toolbar_handle();
|
||||||
|
|
||||||
toolbar_api
|
toolbar_api
|
||||||
@@ -547,7 +547,7 @@ fn lua_tree_block_appends_sorts_and_removes_nodes() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
r#"
|
||||||
local tree = app.left_panel:add_tree()
|
local tree = api.panels.left:add_tree()
|
||||||
tree:append({
|
tree:append({
|
||||||
{ id = "b", label = "banana", expandable = false },
|
{ id = "b", label = "banana", expandable = false },
|
||||||
{ id = "src", label = "src", icon = "folder", expandable = true, expanded = true },
|
{ 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)
|
tree:on_click(function(id) clicked = clicked + 1 end)
|
||||||
assert(toggled == 0 and clicked == 0)
|
assert(toggled == 0 and clicked == 0)
|
||||||
assert(tree:remove("missing") == false)
|
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");
|
.expect("tree.lua");
|
||||||
@@ -608,7 +613,7 @@ fn lua_bottom_panel_tree_has_a_tab_title_and_clears() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
r#"
|
||||||
diag_tree = app.bottom_panel:add_tree("Диагностика")
|
diag_tree = api.panels.bottom:add_tree("Диагностика")
|
||||||
diag_tree:append({
|
diag_tree:append({
|
||||||
{ id = "a", label = "a", expandable = true, expanded = true },
|
{ id = "a", label = "a", expandable = true, expanded = true },
|
||||||
}, "")
|
}, "")
|
||||||
@@ -653,7 +658,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
r#"
|
||||||
app.editor:on_lsp_diagnostics(0, function(servers)
|
api.editor:on_lsp_diagnostics(0, function(servers)
|
||||||
assert(#servers == 1, #servers)
|
assert(#servers == 1, #servers)
|
||||||
local entry = servers[1]
|
local entry = servers[1]
|
||||||
assert(entry.server == "test")
|
assert(entry.server == "test")
|
||||||
@@ -670,7 +675,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() {
|
|||||||
assert(first.message == "плохо")
|
assert(first.message == "плохо")
|
||||||
|
|
||||||
assert(entry.diagnostics[2].severity_name == "warning")
|
assert(entry.diagnostics[2].severity_name == "warning")
|
||||||
app.editor:open_tab()
|
api.editor:open_tab()
|
||||||
end)
|
end)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -729,13 +734,15 @@ fn lua_app_open_input_queues_options() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
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");
|
.expect("input.lua");
|
||||||
|
|
||||||
let engine = ScriptEngine::new(api).expect("движок 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("запрос на поле ввода");
|
let request = requests.borrow_mut().take().expect("запрос на поле ввода");
|
||||||
assert_eq!(request.title.as_deref(), Some("Имя"));
|
assert_eq!(request.title.as_deref(), Some("Имя"));
|
||||||
@@ -743,8 +750,8 @@ fn lua_app_open_input_queues_options() {
|
|||||||
assert!(request.callback.is_some());
|
assert!(request.callback.is_some());
|
||||||
|
|
||||||
// Без аргументов окно состоит из одной строки ввода, колбэка нет.
|
// Без аргументов окно состоит из одной строки ввода, колбэка нет.
|
||||||
std::fs::write(&script, "app:open_input()\n").expect("input.lua");
|
std::fs::write(&script, "api.window:open_input()\n").expect("input.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("запрос без опций");
|
let request = requests.borrow_mut().take().expect("запрос без опций");
|
||||||
assert!(request.title.is_none());
|
assert!(request.title.is_none());
|
||||||
assert!(request.placeholder.is_none());
|
assert!(request.placeholder.is_none());
|
||||||
@@ -754,9 +761,9 @@ fn lua_app_open_input_queues_options() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
r#"
|
||||||
assert(not pcall(function() app:open_input({ title = 1 }) end))
|
assert(not pcall(function() api.window:open_input({ title = 1 }) end))
|
||||||
assert(not pcall(function() app:open_input({ placeholder = 1 }) end))
|
assert(not pcall(function() api.window:open_input({ placeholder = 1 }) end))
|
||||||
assert(not pcall(function() app:open_input({ callback = "x" }) end))
|
assert(not pcall(function() api.window:open_input({ callback = "x" }) end))
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.expect("input.lua");
|
.expect("input.lua");
|
||||||
@@ -768,7 +775,7 @@ fn lua_app_open_input_queues_options() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
let (api, _keymap) = install_api(workspace);
|
let (api, _keymap) = install_api(workspace);
|
||||||
|
|
||||||
@@ -785,7 +792,7 @@ fn lua_list_dir_returns_sorted_entries_with_paths() {
|
|||||||
&script,
|
&script,
|
||||||
format!(
|
format!(
|
||||||
r#"
|
r#"
|
||||||
local entries = app:list_dir({quoted})
|
local entries = api.path:list({quoted})
|
||||||
assert(#entries == 3, #entries)
|
assert(#entries == 3, #entries)
|
||||||
assert(entries[1].name == "A.txt")
|
assert(entries[1].name == "A.txt")
|
||||||
assert(entries[2].name == "b.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[1].is_dir == false)
|
||||||
assert(entries[3].is_dir == true)
|
assert(entries[3].is_dir == true)
|
||||||
assert(entries[1].id == {quoted} .. "/A.txt")
|
assert(entries[1].id == {quoted} .. "/A.txt")
|
||||||
|
|
||||||
|
-- Массив сегментов — та же директория, что и строка с путём.
|
||||||
|
assert(#api.path:list({{ {quoted} }}) == 3)
|
||||||
|
assert(#api.path:list({{ {quoted}, "sub" }}) == 0)
|
||||||
"#
|
"#
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.expect("list.lua");
|
.expect("list.lua");
|
||||||
|
|
||||||
let engine = ScriptEngine::new(api).expect("движок 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(&dir);
|
||||||
let _ = std::fs::remove_dir_all(&scripts);
|
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");
|
let main = dir.join("main.lua");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&helper,
|
&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");
|
.expect("helper.lua");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&main,
|
&main,
|
||||||
format!(
|
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()
|
helper.to_string_lossy()
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -853,12 +955,12 @@ fn lua_script_can_include_files_and_control_the_app() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.active_index(),
|
state.active_index(),
|
||||||
1,
|
1,
|
||||||
"app.editor:open_tab() не переключил вкладку"
|
"api.editor:open_tab() не переключил вкладку"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.documents()[1].language,
|
state.documents()[1].language,
|
||||||
Some(Language::Python),
|
Some(Language::Python),
|
||||||
"app.editor:set_language() не включил подсветку из Lua"
|
"api.editor:set_language() не включил подсветку из Lua"
|
||||||
);
|
);
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
@@ -905,12 +1007,27 @@ fn lua_open_controls_focus_and_cursor() {
|
|||||||
let plain = dir.join("plain.lua");
|
let plain = dir.join("plain.lua");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&plain,
|
&plain,
|
||||||
format!("app.editor:open({:?})\n", path.to_string_lossy()),
|
format!("api.editor:open({:?})\n", path.to_string_lossy()),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
engine
|
engine
|
||||||
.run_file(&plain)
|
.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!(
|
assert_eq!(
|
||||||
workspace.borrow_mut().take_focus_after_sync(),
|
workspace.borrow_mut().take_focus_after_sync(),
|
||||||
Some(FocusAfterSync::Keep)
|
Some(FocusAfterSync::Keep)
|
||||||
@@ -920,12 +1037,12 @@ fn lua_open_controls_focus_and_cursor() {
|
|||||||
let focus = dir.join("focus.lua");
|
let focus = dir.join("focus.lua");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&focus,
|
&focus,
|
||||||
format!("app.editor:open({:?}, {{}})\n", path.to_string_lossy()),
|
format!("api.editor:open({:?}, {{}})\n", path.to_string_lossy()),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
engine
|
engine
|
||||||
.run_file(&focus)
|
.run_file(&focus)
|
||||||
.expect("app.editor:open с пустой таблицей");
|
.expect("api.editor:open с пустой таблицей");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workspace.borrow_mut().take_focus_after_sync(),
|
workspace.borrow_mut().take_focus_after_sync(),
|
||||||
Some(FocusAfterSync::Editor(None))
|
Some(FocusAfterSync::Editor(None))
|
||||||
@@ -935,10 +1052,10 @@ fn lua_open_controls_focus_and_cursor() {
|
|||||||
let at = dir.join("at.lua");
|
let at = dir.join("at.lua");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&at,
|
&at,
|
||||||
format!("app.editor:open({:?}, {{2, 5}})\n", path.to_string_lossy()),
|
format!("api.editor:open({:?}, {{2, 5}})\n", path.to_string_lossy()),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
engine.run_file(&at).expect("app.editor:open с позицией");
|
engine.run_file(&at).expect("api.editor:open с позицией");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workspace.borrow_mut().take_focus_after_sync(),
|
workspace.borrow_mut().take_focus_after_sync(),
|
||||||
Some(FocusAfterSync::Editor(Some(19)))
|
Some(FocusAfterSync::Editor(Some(19)))
|
||||||
@@ -948,6 +1065,29 @@ fn lua_open_controls_focus_and_cursor() {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
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]
|
#[test]
|
||||||
fn api_open_missing_file_creates_an_empty_buffer() {
|
fn api_open_missing_file_creates_an_empty_buffer() {
|
||||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
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(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
format!(
|
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()
|
path.to_string_lossy()
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
|
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();
|
let state = workspace.borrow();
|
||||||
assert_eq!(state.active().name, path.to_string_lossy());
|
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");
|
let dir = temp_script_dir("config-dir");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
dir.join("helper.lua"),
|
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");
|
.expect("helper.lua");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
@@ -1036,11 +1176,11 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
r#"
|
||||||
app:map_key("", "a", function() app.editor:set_active_text("a pressed") end)
|
api.keymap:map_key("", "a", function() api.editor:set_active_text("a pressed") end)
|
||||||
app:map_key("", "!1", function() app.editor:set_active_text("1 released") end)
|
api.keymap:map_key("", "!1", function() api.editor:set_active_text("1 released") end)
|
||||||
app:map_key("", "<S-c>", function() app.editor:set_active_text("shift c") end)
|
api.keymap:map_key("", "<S-c>", function() api.editor:set_active_text("shift c") end)
|
||||||
app:map_key("", "<C-z>", function() app.editor:set_active_text("ctrl z") end)
|
api.keymap:map_key("", "<C-z>", function() api.editor:set_active_text("ctrl z") end)
|
||||||
app:map_key("", "<A-!r>", function() app.editor:set_active_text("alt r released") end)
|
api.keymap:map_key("", "<A-!r>", function() api.editor:set_active_text("alt r released") end)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.expect("keys.lua");
|
.expect("keys.lua");
|
||||||
@@ -1138,11 +1278,11 @@ fn lua_keymap_accepts_command_names_and_nil() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
r#"
|
||||||
app:map_key("", "<C-n>", "next_tab")
|
api.keymap:map_key("", "<C-n>", "next_tab")
|
||||||
app:map_completion_key("<C-n>", "completion_next")
|
api.keymap:map_completion_key("<C-n>", "completion_next")
|
||||||
|
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
app:map_key("", "<C-x>", "no_such_command")
|
api.keymap:map_key("", "<C-x>", "no_such_command")
|
||||||
end)
|
end)
|
||||||
assert(not ok)
|
assert(not ok)
|
||||||
assert(tostring(err):find("доступны", 1, true), tostring(err))
|
assert(tostring(err):find("доступны", 1, true), tostring(err))
|
||||||
@@ -1183,7 +1323,7 @@ fn lua_keymap_accepts_command_names_and_nil() {
|
|||||||
// nil удаляет привязку, не оставляя заглушку.
|
// nil удаляет привязку, не оставляя заглушку.
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
"app:map_key(\"\", \"<C-n>\", nil)\napp:map_completion_key(\"<C-n>\", nil)\n",
|
"api.keymap:map_key(\"\", \"<C-n>\", nil)\napi.keymap:map_completion_key(\"<C-n>\", nil)\n",
|
||||||
)
|
)
|
||||||
.expect("keys.lua");
|
.expect("keys.lua");
|
||||||
engine.run_file(&script).expect("привязки должны удалиться");
|
engine.run_file(&script).expect("привязки должны удалиться");
|
||||||
@@ -1216,6 +1356,30 @@ fn language_names_cover_all_supported_grammars() {
|
|||||||
("json", Language::Json),
|
("json", Language::Json),
|
||||||
("python", Language::Python),
|
("python", Language::Python),
|
||||||
("py", 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:?}");
|
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::Rust.name(), "rust");
|
||||||
assert_eq!(Language::JavaScript.name(), "js");
|
assert_eq!(Language::JavaScript.name(), "js");
|
||||||
assert_eq!(Language::Python.name(), "python");
|
assert_eq!(Language::Python.name(), "python");
|
||||||
|
assert_eq!(Language::Yaml.name(), "yaml");
|
||||||
|
assert_eq!(Language::Cpp.name(), "cpp");
|
||||||
|
|
||||||
// Каждый язык перечитывается по имени и по полному имени для
|
// Каждый язык перечитывается по имени и по полному имени для
|
||||||
// tree-sitter/LSP.
|
// tree-sitter/LSP.
|
||||||
@@ -1236,12 +1402,68 @@ fn language_names_cover_all_supported_grammars() {
|
|||||||
Language::JavaScript,
|
Language::JavaScript,
|
||||||
Language::Json,
|
Language::Json,
|
||||||
Language::Python,
|
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.name()), Some(language));
|
||||||
assert_eq!(Language::from_name(language.syntax_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]
|
#[test]
|
||||||
fn highlighting_is_off_by_default_and_enabled_through_api() {
|
fn highlighting_is_off_by_default_and_enabled_through_api() {
|
||||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
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");
|
let script = dir.join("keys.lua");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"app:map_key("", "<A-r>", function() app.editor:set_active_text("alt r pressed") end)"#,
|
r#"api.keymap:map_key("", "<A-r>", function() api.editor:set_active_text("alt r pressed") end)"#,
|
||||||
)
|
)
|
||||||
.expect("keys.lua");
|
.expect("keys.lua");
|
||||||
let engine = ScriptEngine::new(AppApi::global()).expect("движок 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(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
r#"
|
||||||
app:map_key("", "<A-g>", function() app.editor:set_gutter_icon(0, "circle") end)
|
api.keymap:map_key("", "<A-g>", function() api.editor:set_gutter_icon(0, "circle") end)
|
||||||
app.editor:map_key("", "<A-r>", function() app.editor:set_gutter_icon(0, "play") end)
|
api.editor:map_key("", "<A-r>", function() api.editor:set_gutter_icon(0, "play") end)
|
||||||
|
|
||||||
local tree = app.left_panel:add_tree()
|
local tree = api.panels.left:add_tree()
|
||||||
tree:append({ { id = "a", label = "a" } }, "")
|
tree:append({ { id = "a", label = "a" } }, "")
|
||||||
tree:map_key("", "<A-t>", function() app.editor:set_gutter_icon(0, "file-code") end)
|
tree:map_key("", "<A-t>", function() api.editor:set_gutter_icon(0, "file-code") end)
|
||||||
app.left_panel:open()
|
api.panels.left:open()
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.expect("keys.lua");
|
.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);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
fn global_keymap_sees_tree_focus_in_lua(cx: &mut TestAppContext) {
|
||||||
|
cx.update(gpui_kit::init);
|
||||||
|
|
||||||
|
let (app, visual) = cx.add_window_view(MyApp::new);
|
||||||
|
|
||||||
|
let dir = temp_script_dir("tree-focus-query");
|
||||||
|
let script = dir.join("focus.lua");
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
r#"
|
||||||
|
local tree = api.panels.left:add_tree()
|
||||||
|
tree:append({ { id = "a", label = "a" } }, "")
|
||||||
|
api.panels.left:open()
|
||||||
|
|
||||||
|
api.keymap:map_key("", "<A-h>", function()
|
||||||
|
if tree:has_focus() then
|
||||||
|
api.editor:set_gutter_icon(0, "folder")
|
||||||
|
else
|
||||||
|
api.editor:set_gutter_icon(0, "file")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("focus.lua");
|
||||||
|
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||||||
|
engine.run_file(&script).expect("привязки");
|
||||||
|
|
||||||
|
let tree = {
|
||||||
|
let panel = AppApi::global().left_panel_handle();
|
||||||
|
let panel = panel.borrow();
|
||||||
|
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
|
||||||
|
tree.clone()
|
||||||
|
};
|
||||||
|
let icon = || AppApi::global().editor().gutter_icon(0);
|
||||||
|
|
||||||
|
// Фокус в редакторе: `tree:has_focus()` должен быть ложным.
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
app.update(cx, |this, cx| {
|
||||||
|
this.editor_handle()
|
||||||
|
.update(cx, |state, cx| state.focus(window, cx));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
visual.simulate_keystrokes("alt-h");
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert_eq!(icon().as_deref(), Some("file"));
|
||||||
|
|
||||||
|
// Фокус на дереве: глобальный обработчик видит `tree:has_focus()`.
|
||||||
|
tree.borrow_mut().focus("a");
|
||||||
|
visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |_, cx| cx.notify());
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
visual.simulate_keystrokes("alt-h");
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert_eq!(icon().as_deref(), Some("folder"));
|
||||||
|
|
||||||
|
drop(engine);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
fn editor_focus_from_lua_moves_focus_out_of_tree(cx: &mut TestAppContext) {
|
||||||
|
cx.update(gpui_kit::init);
|
||||||
|
|
||||||
|
let (app, visual) = cx.add_window_view(MyApp::new);
|
||||||
|
|
||||||
|
let tree = {
|
||||||
|
let panel = AppApi::global().left_panel_handle();
|
||||||
|
let tree = panel.borrow_mut().add_tree(None);
|
||||||
|
panel.borrow_mut().open();
|
||||||
|
tree
|
||||||
|
};
|
||||||
|
tree.borrow_mut()
|
||||||
|
.append(
|
||||||
|
"",
|
||||||
|
vec![TreeNode {
|
||||||
|
id: "a".to_owned(),
|
||||||
|
label: "a".to_owned(),
|
||||||
|
icon: None,
|
||||||
|
expandable: false,
|
||||||
|
expanded: false,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("узел дерева");
|
||||||
|
tree.borrow_mut().focus("a");
|
||||||
|
visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |_, cx| cx.notify());
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
visual.update(|window, _cx| {
|
||||||
|
let focus = tree
|
||||||
|
.borrow()
|
||||||
|
.existing_focus_handle()
|
||||||
|
.expect("дерево отрисовано");
|
||||||
|
assert!(focus.is_focused(window), "фокус должен быть на дереве");
|
||||||
|
});
|
||||||
|
|
||||||
|
AppApi::global().editor().focus();
|
||||||
|
visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |_, cx| cx.notify());
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
let editor = app.read(cx).editor_handle();
|
||||||
|
let presentation = editor.read(cx).presentation();
|
||||||
|
let focus = presentation.focus_handle();
|
||||||
|
assert!(focus.is_focused(window), "фокус должен быть в редакторе");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lua_key_modes_are_per_object_and_dynamic() {
|
fn lua_key_modes_are_per_object_and_dynamic() {
|
||||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
@@ -1432,24 +1768,24 @@ fn lua_key_modes_are_per_object_and_dynamic() {
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&script,
|
&script,
|
||||||
r#"
|
r#"
|
||||||
assert(app:mode() == "-")
|
assert(api.keymap:mode() == "-")
|
||||||
app:set_mode("i")
|
api.keymap:set_mode("i")
|
||||||
assert(app:mode() == "i")
|
assert(api.keymap:mode() == "i")
|
||||||
app:map_key("-", "<A-a>", function() app.editor:set_active_text("default") end)
|
api.keymap:map_key("-", "<A-a>", function() api.editor:set_active_text("default") end)
|
||||||
app:map_key("i", "<A-b>", function() app.editor:set_active_text("insert") end)
|
api.keymap:map_key("i", "<A-b>", function() api.editor:set_active_text("insert") end)
|
||||||
app:map_key("", "<A-c>", function() app.editor:set_active_text("all") end)
|
api.keymap:map_key("", "<A-c>", function() api.editor:set_active_text("all") end)
|
||||||
|
|
||||||
assert(app.editor:mode() == "-")
|
assert(api.editor:mode() == "-")
|
||||||
app.editor:set_mode("n")
|
api.editor:set_mode("n")
|
||||||
assert(app.editor:mode() == "n")
|
assert(api.editor:mode() == "n")
|
||||||
app.editor:map_key("n", "<A-d>", function() app.editor:set_active_text("editor n") end)
|
api.editor:map_key("n", "<A-d>", function() api.editor:set_active_text("editor n") end)
|
||||||
app.editor:map_key("i", "<A-e>", function() app.editor:set_active_text("editor i") end)
|
api.editor:map_key("i", "<A-e>", function() api.editor:set_active_text("editor i") end)
|
||||||
|
|
||||||
local tree = app.left_panel:add_tree()
|
local tree = api.panels.left:add_tree()
|
||||||
assert(tree:mode() == "-")
|
assert(tree:mode() == "-")
|
||||||
tree:set_mode("v")
|
tree:set_mode("v")
|
||||||
assert(tree:mode() == "v")
|
assert(tree:mode() == "v")
|
||||||
tree:map_key("v", "<A-f>", function() app.editor:set_active_text("tree v") end)
|
tree:map_key("v", "<A-f>", function() api.editor:set_active_text("tree v") end)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.expect("modes.lua");
|
.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 (app, visual) = cx.add_window_view(MyApp::new);
|
||||||
|
|
||||||
let api = AppApi::global();
|
let api = AppApi::global();
|
||||||
api.left_panel().open();
|
api.panels().left().open();
|
||||||
api.right_panel().open();
|
api.panels().right().open();
|
||||||
api.bottom_panel().open();
|
api.panels().bottom().open();
|
||||||
visual.update(|_window, cx| {
|
visual.update(|_window, cx| {
|
||||||
app.update(cx, |_, cx| cx.notify());
|
app.update(cx, |_, cx| cx.notify());
|
||||||
});
|
});
|
||||||
@@ -1552,7 +1888,7 @@ fn toolbar_items_render_from_api(cx: &mut TestAppContext) {
|
|||||||
.eval::<mlua::Function>()
|
.eval::<mlua::Function>()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let toolbar = AppApi::global().toolbar();
|
let toolbar = AppApi::global().panels().toolbar();
|
||||||
toolbar
|
toolbar
|
||||||
.add_label(
|
.add_label(
|
||||||
"Слева".into(),
|
"Слева".into(),
|
||||||
@@ -1644,7 +1980,7 @@ fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
|
|||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
api.left_panel().open();
|
api.panels().left().open();
|
||||||
|
|
||||||
visual.update(|_window, cx| {
|
visual.update(|_window, cx| {
|
||||||
app.update(cx, |_, cx| cx.notify());
|
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(0));
|
||||||
assert!(!panel.borrow_mut().set_active(2));
|
assert!(!panel.borrow_mut().set_active(2));
|
||||||
|
|
||||||
api.bottom_panel().open();
|
api.panels().bottom().open();
|
||||||
visual.update(|_window, cx| {
|
visual.update(|_window, cx| {
|
||||||
app.update(cx, |_, cx| cx.notify());
|
app.update(cx, |_, cx| cx.notify());
|
||||||
});
|
});
|
||||||
@@ -1724,7 +2060,7 @@ fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
api.left_panel().open();
|
api.panels().left().open();
|
||||||
tree.borrow_mut().focus("src");
|
tree.borrow_mut().focus("src");
|
||||||
|
|
||||||
visual.update(|_window, cx| {
|
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| {
|
visual.update(|_window, cx| {
|
||||||
app.update(cx, |_, cx| cx.notify());
|
app.update(cx, |_, cx| cx.notify());
|
||||||
});
|
});
|
||||||
visual.run_until_parked();
|
visual.run_until_parked();
|
||||||
api.left_panel().open();
|
api.panels().left().open();
|
||||||
visual.update(|_window, cx| {
|
visual.update(|_window, cx| {
|
||||||
app.update(cx, |_, cx| cx.notify());
|
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");
|
let open = dir.join("open.lua");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&keys,
|
&keys,
|
||||||
r#"app:map_key("", "<A-r>", function() app.editor:set_active_text("blocked") end)"#,
|
r#"api.keymap:map_key("", "<A-r>", function() api.editor:set_active_text("blocked") end)"#,
|
||||||
)
|
)
|
||||||
.expect("keys.lua");
|
.expect("keys.lua");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&open,
|
&open,
|
||||||
r#"
|
r#"
|
||||||
app:open_input({
|
api.window:open_input({
|
||||||
title = "Имя",
|
title = "Имя",
|
||||||
placeholder = "main.rs",
|
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");
|
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||||||
engine.run_file(&keys).expect("привязка");
|
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| {
|
let notify = |visual: &mut gpui_kit::VisualTestContext| {
|
||||||
visual.update(|_window, cx| {
|
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, "");
|
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);
|
notify(visual);
|
||||||
assert!(prompt_open(visual));
|
assert!(prompt_open(visual));
|
||||||
visual.simulate_click(point(px(2.), px(2.)), Modifiers::default());
|
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, "");
|
assert_eq!(workspace.borrow().active().content, "");
|
||||||
|
|
||||||
// Enter подтверждает ввод и вызывает колбэк уже после закрытия окна.
|
// Enter подтверждает ввод и вызывает колбэк уже после закрытия окна.
|
||||||
engine.run_file(&open).expect("app:open_input в третий раз");
|
engine
|
||||||
|
.run_file(&open)
|
||||||
|
.expect("api.window:open_input в третий раз");
|
||||||
notify(visual);
|
notify(visual);
|
||||||
assert!(prompt_open(visual));
|
assert!(prompt_open(visual));
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Панель состоит из двух областей. Элементы левой области выстраиваются от
|
//! Панель состоит из двух областей. Элементы левой области выстраиваются от
|
||||||
//! левого края вправо, элементы правой — от правого края влево. Сценарий
|
//! левого края вправо, элементы правой — от правого края влево. Сценарий
|
||||||
//! добавляет в области лейблы и кнопки через `app.toolbar`.
|
//! добавляет в области лейблы и кнопки через `api.panels.toolbar`.
|
||||||
//!
|
//!
|
||||||
//! Как и панели окна, состояние лежит в `Rc<RefCell<_>>`: Lua-сценарий меняет
|
//! Как и панели окна, состояние лежит в `Rc<RefCell<_>>`: Lua-сценарий меняет
|
||||||
//! его через API, а вид [`MyApp`](crate::app::MyApp) читает при отрисовке.
|
//! его через API, а вид [`MyApp`](crate::app::MyApp) читает при отрисовке.
|
||||||
|
|||||||
+20
-1
@@ -107,6 +107,10 @@ pub struct TreeState {
|
|||||||
focus_handle: Option<FocusHandle>,
|
focus_handle: Option<FocusHandle>,
|
||||||
/// Следующий кадр должен передать фокус этому дереву.
|
/// Следующий кадр должен передать фокус этому дереву.
|
||||||
focus_requested: bool,
|
focus_requested: bool,
|
||||||
|
/// Владеет ли дерево клавиатурным фокусом. Вид обновляет флаг перед
|
||||||
|
/// обработкой событий клавиатуры, чтобы глобальные привязки могли
|
||||||
|
/// проверить его из Lua через `tree:has_focus()`.
|
||||||
|
focused: bool,
|
||||||
/// Прокрутка списка: выбранная строка всегда прокручивается в видимую часть.
|
/// Прокрутка списка: выбранная строка всегда прокручивается в видимую часть.
|
||||||
scroll: ScrollHandle,
|
scroll: ScrollHandle,
|
||||||
/// Привязки клавиш блока и его текущий режим.
|
/// Привязки клавиш блока и его текущий режим.
|
||||||
@@ -143,11 +147,26 @@ impl TreeState {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.expand_ancestors(id);
|
self.expand_ancestors(id);
|
||||||
self.focus_requested = true;
|
|
||||||
self.select_and_scroll(id);
|
self.select_and_scroll(id);
|
||||||
|
self.request_focus();
|
||||||
true
|
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 {
|
pub fn scroll_handle(&self) -> ScrollHandle {
|
||||||
self.scroll.clone()
|
self.scroll.clone()
|
||||||
|
|||||||
Reference in New Issue
Block a user