From 16e88117d56f3ee1eed01b55952bd9fa2a35febf Mon Sep 17 00:00:00 2001 From: "dedal.qq" Date: Sat, 31 Oct 2026 11:20:28 +0300 Subject: [PATCH] gutter and keys --- AGENTS.md | 36 +- DOCS.md | 216 +++++++---- init.lua | 136 ++++--- src/api.rs | 924 ++++++++++++++++++++++++++++++----------------- src/app.rs | 330 ++++++++++++++++- src/constants.rs | 6 + src/editor.rs | 9 +- src/gutter.rs | 121 +++++++ src/keymap.rs | 138 ++++++- src/lsp.rs | 4 +- src/main.rs | 1 + src/script.rs | 5 +- src/tests.rs | 458 +++++++++++++++++++---- src/tree.rs | 18 + 14 files changed, 1844 insertions(+), 558 deletions(-) create mode 100644 src/gutter.rs diff --git a/AGENTS.md b/AGENTS.md index ddfd53c..1b8acf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ | `src/main.rs` | Точка входа, окно, аргументы командной строки | | `src/constants.rs` | Константы окна, CLI и каталог сценариев | | `src/editor.rs` | Доменная модель: документы, вкладки, языки | -| `src/api.rs` | `AppApi` / `PanelApi` / `TreeApi` / `ToolbarApi` — единственная поверхность управления | +| `src/api.rs` | `AppApi` / `EditorApi` / `PanelApi` / `TreeApi` / `ToolbarApi` — единственная поверхность управления | | `src/script.rs` | Lua-движок, `app`, `include`, `require` | | `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики | | `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение | @@ -19,6 +19,7 @@ | `src/panel.rs` | `PanelState` и блоки панелей | | `src/toolbar.rs` | Состояние верхней панели: лейблы и кнопки | | `src/tree.rs` | Абстрактное дерево для панелей | +| `src/gutter.rs` | Иконки на полях слева от редактора | | `src/icons.rs` | Каталог иконок Lucide | | `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер | | `DOCS.md`, `init.lua` | Пользовательская документация и пример конфигурации | @@ -28,23 +29,29 @@ ### 1. `AppApi` — центр приложения - Документы, вкладки, панели, дерево, окно, подсветка, LSP и привязки клавиш - меняются только через `AppApi` (или `PanelApi` / `TreeApi`). -- Состояние живёт в общих `Rc>` / `Arc`, чтобы UI, `AppApi` и Lua + меняются только через `AppApi`, `EditorApi` или `PanelApi` / `TreeApi` / + `ToolbarApi`. +- Всё, что относится к редактору (вкладки, текст, gutter, сохранение, LSP и + привязки редактора), живёт в `app.editor`; окно, панели и глобальные + привязки — в `app`. +- Состояние живёт в общих `Rc>` / `Arc`, чтобы UI, API и Lua видели одни и те же объекты. Второй копии состояния быть не должно. - Команды окна складываются в очередь и применяются в начале кадра (`apply_window_commands`); UI и Lua не вызывают `Window` напрямую. - Если действие пользователя меняет состояние, оно выражается методом - `AppApi`, а не логикой внутри обработчика UI. Отрисовка состояние только + API, а не логикой внутри обработчика UI. Отрисовка состояние только читает. - Исключение — системные кнопки окна внутри `gpui-kit::TitleBar`: их поведение принадлежит библиотеке. ### 2. Lua повторяет возможности API -- Публичные методы `impl AppApi` с `#[mlua::userdata_impl]` доступны как - `app:method(...)`. Добавляя возможность, сразу добавляйте и Lua-метод. +- Публичные методы `impl AppApi` / `impl EditorApi` с `#[mlua::userdata_impl]` + доступны как `app:method(...)` / `app.editor:method(...)`. Добавляя + возможность, сразу добавляйте и Lua-метод. - Имена — `snake_case`; вкладки и индексы нумеруются с нуля; панели — - `app.left_panel`, `app.right_panel`, `app.bottom_panel`. + `app.left_panel`, `app.right_panel`, `app.bottom_panel`; блоки панелей + получают свои объекты (`tree`). - Для методов, которые не должны попадать в Lua, используется `#[lua(skip)]`. Для generic-методов добавляется тонкая не-generic Lua-обёртка. - `DOCS.md` и комментарии в `init.lua` обновляются в том же изменении, что и @@ -55,12 +62,17 @@ - Встроенные привязки (`Ctrl/Cmd+S`, `Ctrl/Cmd+Space`, навигация по списку автодополнения) регистрируются в `KeyMap` и могут быть заменены или удалены - из Lua через `app:map_key` / `app:map_completion_key`. -- `KeyMap` имеет два слоя: глобальный и `completion`. Обработчик может быть - Lua-функцией или встроенной командой. + из Lua через `app:map_key` / `app.editor:map_key` / + `app:map_completion_key`. +- У приложения, редактора и каждого блока панели своя `KeyMap` и свой текущий + режим (один символ; `-` — режим по умолчанию). Привязка хранит строку + режимов, в которых она действует: `""` — все, `"-iv"` — по умолчанию и + `i`/`v`. +- Глобальные привязки обрабатываются всегда; затем, если фокус в редакторе, + дополнительно привязки редактора, а если фокус на блоке панели — привязки + этого блока. Обработчик может быть Lua-функцией или встроенной командой. - Перехватчик клавиш выполняется раньше действий редактора и используется - только для слоя автодополнения; глобальные привязки обрабатываются на корне - вида. Этот порядок менять нельзя. + только для слоя автодополнения. Этот порядок менять нельзя. - Текстовый ввод редактора — не привязка клавиши. ### 4. Модель отделена от GPUI diff --git a/DOCS.md b/DOCS.md index f7807ea..46261f6 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1,8 +1,9 @@ # Lua API Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект -`app` для управления окном, вкладками, панелями, файловой системой, подсветкой -и LSP-серверами. +`app` для управления окном, панелями и глобальными привязками клавиш. +Вкладки, текст, gutter, подсветка, сохранение и LSP-серверы живут в `app.editor`, +а блоки панелей (деревья) — в своих объектах. Сам редактор не содержит демонстрационных документов: при запуске открыта одна пустая вкладка `untitled-1.txt`. Всё остальное — файлы, деревья, серверы — @@ -21,36 +22,46 @@ cargo run -- main.go - В каталоге `--config` должны лежать `init.lua` и подключаемые им модули. - Первый позиционный аргумент открывается после выполнения `init.lua` - через `app:open(path)` — как `vim main.go` или `nano main.go`. + через `app.editor:open(path)` — как `vim main.go` или `nano main.go`. - `--config` меняет только каталог сценариев. Рабочий каталог процесса, а - вместе с ним пути `app:open`, `app:save` и `root` у LSP не меняются. + вместе с ним пути `app.editor:open`, `app.editor:save` и `root` у LSP не меняются. ## Объект `app` +Объект `app` управляет окном и панелями. Всё, что относится к самому +редактору — вкладки, текст, gutter, сохранение, LSP и привязки клавиш +редактора, — живёт в `app.editor`: + +```lua +app.editor:open("src/main.rs") +app.editor:save() +app.editor:map_key("n", "", "save") +``` + Номера вкладок во всех методах нумеруются с нуля. Массив, который возвращает -`app:tabs()`, остаётся обычной Lua-таблицей с индексами от 1, поэтому поле +`app.editor:tabs()`, остаётся обычной Lua-таблицей с индексами от 1, поэтому поле `index` — единственный надёжный способ сослаться на вкладку. ### Вкладки | Метод | Описание | | --- | --- | -| `app:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs", icon = "file" }, ...}` | -| `app:active_tab()` | Индекс активной вкладки | -| `app:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует | -| `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс | -| `app:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс | -| `app:open_tab()` | Открыть пустую вкладку, вернуть индекс | -| `app:open(path, position)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем. Без второго аргумента фокус не меняется; `{}` — перевести фокус в редактор, оставив курсор; `{line, column}` — ещё и поставить курсор (с нуля) | -| `app:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта | -| `app:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна | -| `app:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) | -| `app:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет | +| `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:open("src/main.rs") -- открыть, не трогая фокус -app:open("src/main.rs", {}) -- фокус в редактор, курсор на месте -app:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор на строку 4, столбец 2 +app.editor:open("src/main.rs") -- открыть, не трогая фокус +app.editor:open("src/main.rs", {}) -- фокус в редактор, курсор на месте +app.editor:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор на строку 4, столбец 2 ``` Строка и столбец нумеруются с нуля, как в диагностике LSP; столбец считается @@ -63,21 +74,45 @@ app:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор Например: ```lua -app:set_tab_icon(0, "file-code") -app:set_tab_icon(1, "book-open") -app:clear_tab_icon(1) -- снова "file" +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:active_text()` | Текст активной вкладки | -| `app:set_active_text(text)` | Заменить текст активной вкладки | +| `app.editor:active_text()` | Текст активной вкладки | +| `app.editor:set_active_text(text)` | Заменить текст активной вкладки | ### Подсветка синтаксиса @@ -86,19 +121,19 @@ app:clear_tab_icon(1) -- снова "file" | Метод | Описание | | --- | --- | -| `app:set_language(index, "rust")` | Включить подсветку для вкладки; `false`, если вкладки нет или язык не поддерживается | -| `app:language(index)` | Язык подсветки вкладки или `nil` | -| `app:clear_language(index)` | Выключить подсветку для вкладки | +| `app.editor:set_language(index, "rust")` | Включить подсветку для вкладки; `false`, если вкладки нет или язык не поддерживается | +| `app.editor:language(index)` | Язык подсветки вкладки или `nil` | +| `app.editor:clear_language(index)` | Выключить подсветку для вкладки | ```lua -app:set_language(0, "rust") +app.editor:set_language(0, "rust") ``` ### Сохранение | Метод | Описание | | --- | --- | -| `app:save()` | Сохранить активную вкладку в её файл | +| `app.editor:save()` | Сохранить активную вкладку в её файл | ### Файловая система @@ -108,7 +143,7 @@ app:set_language(0, "rust") Путь отсчитывается от текущего рабочего каталога процесса. Пустая строка или `"."` означают сам текущий каталог. Поле `id` — путь элемента относительно -текущего каталога: его можно передать в `app:open(id)` и использовать как +текущего каталога: его можно передать в `app.editor:open(id)` и использовать как идентификатор узла дерева. Элементы уже отсортированы по имени без учёта регистра. @@ -120,12 +155,24 @@ end ### Привязка клавиш -Все привязки проходят через один `KeyMap`. Встроенные сочетания можно заменить -или удалить: вторым аргументом принимается Lua-функция, имя встроенной команды -или `nil`. +Привязки живут у трёх видов объектов: у приложения (`app`), у редактора +(`app.editor`) и у каждого блока панели (`tree`). Встроенные сочетания можно +заменить или удалить: обработчиком принимается Lua-функция, имя встроенной +команды или `nil`. -`app:map_key(keys, handler)` назначает привязку для обычного режима, -`app:map_completion_key(keys, handler)` — для списка автодополнения. +```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: @@ -143,7 +190,32 @@ end и Windows буквы берутся по аппаратному keycode, поэтому `` работает и на русской раскладке. -Встроенные команды обычного режима: +#### Режимы + +У каждого объекта своя текущая режимная метка: один символ (`n`, `i`, `v`, `r` +или любой другой) либо `-` — режим по умолчанию. Режимы динамические: заранее +создавать их не нужно, достаточно переключить метку. + +| Значение `modes` | Когда срабатывает | +| --- | --- | +| `""` | во всех режимах | +| `"-"` | только в режиме по умолчанию | +| `"niv"` | в режимах `n`, `i`, `v` | +| `"-iv"` | в режиме по умолчанию, а также `i` и `v` | + +Текущий режим меняется методами `set_mode(mode)` и читается методом `mode()`. +Режимы у приложения, редактора и каждого блока независимы. + +```lua +app:set_mode("n") +app.editor:set_mode("i") + +app:map_key("n", "", "next_tab") -- только в режиме n +app.editor:map_key("i", "", "save") -- только в режиме i редактора +app.editor:map_key("", "", function() end) -- в любом режиме +``` + +Встроенные команды: | Имя | Действие | | --- | --- | @@ -169,25 +241,33 @@ end При запуске уже назначены: -- `Ctrl/Cmd+S` → `save`; +- `Ctrl/Cmd+S` → `save` во всех режимах; - `Ctrl/Cmd+Space` → `completion_trigger`; - `Enter`/`Tab`, `Esc`, `↑`/`↓`, `PageUp`/`PageDown`, `←`/`→`, `Home`/`End` → команды слоя автодополнения. ```lua --- Переопределить сохранение своей функцией. -app:map_key("", function() app:save() end) +-- Переопределить сохранение своей функцией во всех режимах. +app:map_key("", "", function() app.editor:save() end) -- Убрать встроенную привязку. -app:map_key("", nil) +app:map_key("", "", nil) -- Назначить встроенную команду на другую клавишу. -app:map_key("", "next_tab") -app:map_key("", "toggle_left_panel") +app:map_key("", "", "next_tab") +app:map_key("", "", "toggle_left_panel") -- Навигация по списку автодополнения на Ctrl+N / Ctrl+P. app:map_completion_key("", "completion_next") app:map_completion_key("", "completion_previous") + +-- Привязка, которая срабатывает только при фокусе в редакторе. +app.editor:map_key("", "", function() + app.editor:set_active_text("editor mode") +end) + +-- Привязка блока дерева: работает, когда фокус на этом дереве. +tree:map_key("v", "", function() print("tree v") end) ``` Пока список открыт, его слой проверяется раньше действий редактора. Сочетания @@ -209,7 +289,7 @@ app:map_completion_key("", "completion_previous") Запуск серверов целиком настраивается Lua-сценарием. ```lua -app:run_lsp({ +app.editor:run_lsp({ name = "gopls", -- условное имя для stop/restart cmd = "gopls", -- путь к бинарнику или имя в PATH args = { "serve" }, -- аргументы процесса @@ -223,11 +303,11 @@ app:run_lsp({ | Метод | Описание | | --- | --- | -| `app:run_lsp(config)` | Запустить сервер по описанию | -| `app:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` | -| `app:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен | -| `app:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками | -| `app:on_lsp_diagnostics(delay_ms, fn)` | Обработчик диагностики всех серверов; `nil` вместо `fn` удаляет его | +| `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` принимает строку, таблицу @@ -251,14 +331,14 @@ watcher = true -- все файлы, игнорируя только стан ### Обработчик диагностики -`app:on_lsp_diagnostics(delay_ms, fn)` регистрирует один обработчик на все +`app.editor:on_lsp_diagnostics(delay_ms, fn)` регистрирует один обработчик на все LSP-серверы. `delay_ms` — пауза тишины: если сообщения приходят чаще, вызовы пропускаются, а обработчик получает последний снимок после того, как обновления прекратились. Если зарегистрировать обработчик, когда диагностика уже накоплена, он сработает сразу после этой паузы. ```lua -app:on_lsp_diagnostics(1000, function(servers) +app.editor:on_lsp_diagnostics(1000, function(servers) for _, entry in ipairs(servers) do print(entry.server, entry.path, #entry.diagnostics) end @@ -293,10 +373,10 @@ end) Примеры запуска: ```lua -app:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" }, +app.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" }, languages = { "go" }, watcher = "**/*.go" }) -app:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer", +app.editor:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer", languages = { "rust" }, watcher = "**/*.rs" }) ``` @@ -304,7 +384,7 @@ app:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer", ```lua local gopls_ok, gopls_err = pcall(function() - app:run_lsp({ + app.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" }, @@ -409,6 +489,9 @@ local diagnostics = app.bottom_panel:add_tree("Диагностика") | `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` | | `tree:selected()` | Идентификатор выбранной строки или `nil` | | `tree:focus(id)` | Раскрыть всех родителей, выбрать узел и передать фокус дереву | +| `tree:map_key(modes, keys, handler)` | Привязка, которая действует, когда фокус на этом блоке | +| `tree:set_mode(mode)` | Переключить режим привязок блока | +| `tree:mode()` | Текущий режим привязок блока | Узел — таблица: @@ -439,6 +522,8 @@ local diagnostics = app.bottom_panel:add_tree("Диагностика") ребёнку, `←` сворачивает папку или выбирает родителя, `Enter` раскрывает папку или вызывает `on_click` для конечной ноды. Выбор сохраняется, пока панель закрыта; при повторном открытии панели фокус возвращается на выбранную строку. +Привязки блока (`tree:map_key`) обрабатываются дополнительно к глобальным и +только пока фокус находится на этом дереве; у каждого блока свой режим. Пример отложенной загрузки дерева: @@ -467,14 +552,14 @@ end) tree:on_click(function(id) -- Фокус в редактор, курсор остаётся на прежнем месте. - app:open(id, {}) + app.editor:open(id, {}) end) app.left_panel:open() load_dir("") -- При открытии панели вернуть фокус на последнюю выбранную строку. -app:map_key("", function() +app:map_key("", "", function() if app.left_panel:toggle() then local selected = tree:selected() if selected then @@ -499,7 +584,7 @@ local icons = { hint = "lightbulb", } -app:on_lsp_diagnostics(1000, function(servers) +app.editor:on_lsp_diagnostics(1000, function(servers) diagnostics:clear() local files = {} @@ -558,9 +643,9 @@ end) diagnostics:on_click(function(id) local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$") if path then - app:open(path, { tonumber(line), tonumber(character) }) + app.editor:open(path, { tonumber(line), tonumber(character) }) else - app:open(id, {}) + app.editor:open(id, {}) end end) @@ -578,20 +663,23 @@ app.bottom_panel:open() ## Добавление новых методов -Методы добавляются в `src/api.rs`: всё, что есть в `impl AppApi`, помеченном -`#[mlua::userdata_impl]`, автоматически доступно из Lua. +Методы добавляются в `src/api.rs`: всё, что есть в `impl AppApi` или +`impl EditorApi`, помеченном `#[mlua::userdata_impl]`, автоматически доступно +из Lua как `app:method(...)` или `app.editor:method(...)`. Чтобы не экспортировать метод, его помечают `#[lua(skip)]`. +Новый вид блока панели должен добавить методы в свой `*Api` и отдавать свою +`KeyMap`, как это делает `TreeApi`. ## Полный пример `init.lua` ```lua print("[qode] init.lua выполнен") -app:map_key("", function() app:close() end) -app:map_key("", "toggle_left_panel") -app:map_key("", "toggle_bottom_panel") +app:map_key("", "", function() app:close() end) +app:map_key("", "", "toggle_left_panel") +app:map_key("", "", "toggle_bottom_panel") -app:run_lsp({ +app.editor:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer", languages = { "rust" }, diff --git a/init.lua b/init.lua index 7120a88..b8f446c 100644 --- a/init.lua +++ b/init.lua @@ -10,41 +10,22 @@ -- -- В каталоге --config должны лежать init.lua и подключаемые им модули. -- Первый позиционный аргумент открывается после выполнения этого файла --- через app:open(path) — как `vim main.go` или `nano main.go`. +-- через app.editor:open(path) — как `vim main.go` или `nano main.go`. print("[lua] init.lua выполнен") --- Доступные методы объекта app (номера вкладок — с нуля): --- app:tabs() -- {{ index = 0, title = "main.rs", icon = "file" }, ...} --- app:active_tab() -- индекс активной вкладки --- app:set_active_tab(index) -- true, если вкладка существует --- app:next_tab() -- переключить на следующую, вернуть индекс --- app:previous_tab() -- переключить на предыдущую, вернуть индекс --- app:open_tab() -- открыть пустую вкладку, вернуть индекс --- app:open(path) -- открыть файл с диска, вернуть индекс вкладки; --- -- если файла нет — пустой буфер с таким именем --- app:open(path, {}) -- открыть и перевести фокус в редактор, --- -- курсор оставить на месте --- app:open(path, {m, n}) -- открыть, перевести фокус и поставить курсор --- -- на строку m и столбец n (с нуля) +-- Объект app: окно, панели и глобальные привязки клавиш: -- app:list_dir(path) -- {{ id = "src/main.rs", name = "main.rs", -- -- is_dir = false }, ...}; пустой путь — -- -- текущий каталог, id — путь от него --- app:close_tab(index) -- true, если вкладка закрыта --- app:set_tab_icon(i, name) -- иконка вкладки из каталога Lucide --- -- (file, file-code, book-open, ...); --- -- false — нет вкладки или иконки --- app:clear_tab_icon(i) -- вернуть иконку по умолчанию (file) --- app:tab_icon(i) -- имя иконки вкладки или nil --- app:active_text() -- текст активной вкладки --- app:set_active_text(text) -- заменить текст активной вкладки --- app:set_language(i, "rust") -- включить подсветку (rust|go|js|json|python) --- -- для вкладки i; false — нет вкладки/языка --- app:language(i) -- язык подсветки вкладки или nil --- app:clear_language(i) -- выключить подсветку для вкладки --- app:save() -- сохранить активную вкладку в её файл --- app:map_key(keys, handler) -- обработчик клавиши/сочетания: +-- app:map_key(modes, keys, handler) +-- -- глобальный обработчик клавиши/сочетания: -- -- функция, имя команды или nil (удалить) +-- -- modes — строка режимов: +-- -- "" — все режимы +-- -- "-" — только режим по умолчанию +-- -- "-iv" — режим по умолчанию, i и v +-- -- keys — сочетание: -- -- "a" нажатие A -- -- "1" нажатие 1 -- -- "!a" отпускание A @@ -65,14 +46,60 @@ print("[lua] init.lua выполнен") -- -- completion_previous, completion_page_up, -- -- completion_page_down, completion_accept, -- -- completion_cancel, completion_dismiss +-- app:set_mode(mode) -- режим глобальных привязок (один символ) +-- app:mode() -- текущий режим глобальных привязок -- app:close() -- закрыть окно -- app:toggle_maximize() -- app:set_maximized(true/false) -- app:minimize() -- app:is_maximized() -- +-- Редактор app.editor (номера вкладок — с нуля): +-- 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) -- открыть файл с диска, вернуть индекс вкладки; +-- -- если файла нет — пустой буфер с таким именем +-- app.editor:open(path, {}) -- открыть и перевести фокус в редактор, +-- -- курсор оставить на месте +-- app.editor:open(path, {m, n}) -- открыть, перевести фокус и поставить +-- -- курсор на строку m и столбец n (с нуля) +-- app.editor:close_tab(index) -- true, если вкладка закрыта +-- app.editor:set_tab_icon(i, name) -- иконка вкладки из каталога Lucide +-- -- (file, file-code, book-open, ...); +-- -- false — нет вкладки или иконки +-- app.editor:clear_tab_icon(i) -- вернуть иконку по умолчанию (file) +-- app.editor:tab_icon(i) -- имя иконки вкладки или nil +-- app.editor:set_gutter_icon(line, name[, color]) +-- -- иконка на поле слева от редактора +-- -- (активная вкладка, строка с нуля); +-- -- color — #RGB/#RGBA/#RRGGBB/#RRGGBBAA; +-- -- false — неизвестная иконка или цвет +-- app.editor:gutter_icon(line) -- имя иконки gutter или nil +-- app.editor:gutter_color(line) -- цвет иконки gutter или nil +-- app.editor:clear_gutter_icon(line) -- убрать иконку со строки +-- app.editor:clear_gutter() -- убрать все иконки активной вкладки +-- app.editor:active_text() -- текст активной вкладки +-- app.editor:set_active_text(text) -- заменить текст активной вкладки +-- app.editor:set_language(i, "rust") -- включить подсветку +-- -- (rust|go|js|json|python) для вкладки i; +-- -- false — нет вкладки/языка +-- app.editor:language(i) -- язык подсветки вкладки или nil +-- app.editor:clear_language(i) -- выключить подсветку для вкладки +-- app.editor:save() -- сохранить активную вкладку в её файл +-- app.editor:map_key(modes, keys, handler) +-- -- привязка, которая работает дополнительно +-- -- к глобальной, когда фокус в редакторе; +-- -- синтаксис modes/keys/handler как у app:map_key +-- app.editor:set_mode(mode) -- режим привязок редактора +-- app.editor:mode() -- текущий режим привязок редактора +-- -- LSP-серверы (запуск целиком настраивается этим сценарием): --- app:run_lsp({ +-- app.editor:run_lsp({ -- name = "gopls", -- условное имя для stop/restart -- cmd = "gopls", -- путь к бинарнику или имя в PATH -- args = { "serve" }, -- аргументы процесса @@ -82,10 +109,10 @@ print("[lua] init.lua выполнен") -- root = ".", -- корень workspace (по умолчанию cwd) -- initialization_options = {}, -- initializationOptions сервера -- }) --- app:lsp_servers() -- { "gopls", ... } --- app:stop_lsp("gopls") -- true, если сервер был запущен --- app:restart_lsp("gopls") -- перезапуск с прежними настройками --- app:on_lsp_diagnostics(delay_ms, fn) +-- app.editor:lsp_servers() -- { "gopls", ... } +-- app.editor:stop_lsp("gopls") -- true, если сервер был запущен +-- app.editor:restart_lsp("gopls") -- перезапуск с прежними настройками +-- app.editor:on_lsp_diagnostics(delay_ms, fn) -- -- fn(servers) вызывается один раз -- -- после затишья delay_ms; nil вместо -- -- функции удаляет обработчик. @@ -116,9 +143,9 @@ print("[lua] init.lua выполнен") -- список; горизонтальные стрелки закрывают его и двигают курсор. -- Документация выбранного элемента показывается второй всплывающей панелью -- справа от списка. Примеры запуска: --- app:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" }, +-- app.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" }, -- languages = { "go" }, watcher = "**/*.go" }) --- app:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer", +-- app.editor:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer", -- languages = { "rust" }, watcher = "**/*.rs" }) -- -- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием): @@ -163,22 +190,28 @@ print("[lua] init.lua выполнен") -- tree:on_click(fn) -- fn(id) — клик по конечной ноде -- tree:selected() -- id выбранной строки или nil -- tree:focus(id) -- раскрыть родителей, выбрать и дать фокус +-- tree:map_key(modes, keys, handler) +-- -- привязка, которая работает дополнительно +-- -- к глобальной, когда фокус на этом дереве +-- tree:set_mode(mode) -- режим привязок блока (один символ) +-- tree:mode() -- текущий режим привязок блока -- --- Методы добавляются в src/api.rs: всё, что есть в impl AppApi, помеченном --- #[mlua::userdata_impl], автоматически доступно из Lua. +-- Методы добавляются в src/api.rs: всё, что есть в impl AppApi или +-- impl EditorApi, помеченном #[mlua::userdata_impl], автоматически доступно +-- из Lua как app:method(...) или app.editor:method(...). -- -- Другие Lua-файлы можно подключать из каталога сценариев: -- include("tools.lua") -- выполнить файл сразу -- require("tools") -- выполнить один раз (как обычный Lua-модуль) -- -- Подсветка синтаксиса по умолчанию выключена. Включить её можно так: --- app:set_language(0, "rust") +-- app.editor:set_language(0, "rust") -- gopls запускается автоматически при старте редактора. -- Сервер обслуживает только документы с languageId "go" и получает изменения -- всех .go-файлов проекта (кроме vendor/) через файловый вотчер. local gopls_ok, gopls_err = pcall(function() - app:run_lsp({ + app.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" }, @@ -202,10 +235,10 @@ if not gopls_ok then end -- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно. --- app:map_key("", function() app:save() end) --- app:map_key("", function() app:close() end) +-- app:map_key("", "", function() app.editor:save() end) +-- app:map_key("", "", function() app:close() end) -- Убрать встроенную привязку можно так: --- app:map_key("", nil) +-- app:map_key("", "", nil) -- app:map_completion_key("", "completion_next") -- app:map_completion_key("", "completion_previous") @@ -239,7 +272,7 @@ end) project_tree:on_click(function(id) -- Пустая таблица: фокус уходит в редактор, курсор остаётся на месте. - app:open(id, {}) + app.editor:open(id, {}) end) app.left_panel:open() @@ -247,7 +280,7 @@ load_dir(project_tree, "") -- Открытие/закрытие панели с деревом. При открытии возвращаем фокус на -- последнюю выбранную строку, чтобы можно было сразу продолжить навигацию. -app:map_key("", function() +app:map_key("", "", function() local opened = app.left_panel:toggle() if opened then local selected = project_tree:selected() @@ -257,10 +290,10 @@ app:map_key("", function() end end) -app:map_key("", function() app:close() end) +app:map_key("", "", function() app:close() end) -app:map_key("", "toggle_left_panel") -app:map_key("", "toggle_bottom_panel") +app:map_key("", "", "toggle_left_panel") +app:map_key("", "", "toggle_bottom_panel") -- Дерево диагностики в нижней панели. -- @@ -290,7 +323,7 @@ local function diagnostic_rank(diagnostic) or 1 end -app:on_lsp_diagnostics(100, function(servers) +app.editor:on_lsp_diagnostics(100, function(servers) diagnostics_tree:clear() -- Сначала собираем диагностику по файлам: одинаковые проблемы от разных @@ -388,10 +421,10 @@ end) diagnostics_tree:on_click(function(id) local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$") if path then - app:open(path, { tonumber(line), tonumber(character) }) + app.editor:open(path, { tonumber(line), tonumber(character) }) else -- Клик по файлу: фокус в редактор, курсор не переставляем. - app:open(id, {}) + app.editor:open(id, {}) end end) @@ -403,3 +436,6 @@ app.toolbar:add_label("UTF-8", nil, "#efefe6", "encoding", "right") app.toolbar:add_button("Запустить", "play", nil, "run", "right", function() print("[toolbar] кнопка «Запустить» нажата") end) + +app.editor:set_gutter_icon(2, "play", "#efefe6") +app.editor:set_gutter_icon(90, "play") diff --git a/src/api.rs b/src/api.rs index 8162d94..0ee491f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2,7 +2,7 @@ //! //! Единственная точка входа для команд управления окном и редактором: UI-код //! и Lua-сценарии не трогают `Window` и состояние вида напрямую, а вызывают -//! методы [`AppApi`]. +//! методы [`AppApi`] или [`EditorApi`] (`app.editor`). //! //! Команды окна не выполняются в момент вызова: Lua-обработчик выполняется вне //! контекста `App`/`Window`, поэтому [`AppApi`] складывает их в очередь, а вид @@ -107,9 +107,9 @@ struct DiagnosticsHandler { /// Глобальный объект с функциями управления приложением. /// -/// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`. +/// Использование: `AppApi::global().close()`, `AppApi::global().editor()`. /// Экземпляр устанавливается один раз при создании приложения (см. -/// [`AppApi::install`]) и указывает на общие с видом документы и карту клавиш. +/// [`AppApi::install`]) и указывает на общие с видом документы и карты клавиш. /// /// Методы этого `impl`-блока автоматически экспортируются в Lua через /// `#[mlua::userdata_impl]`: в сценариях они доступны как `app:method(...)`. @@ -118,8 +118,12 @@ struct DiagnosticsHandler { pub struct AppApi { #[lua(skip)] workspace: Rc>, + /// Глобальные привязки клавиш и слой автодополнения. #[lua(skip)] keymap: Rc>, + /// Привязки клавиш, которые действуют, когда фокус в редакторе. + #[lua(skip)] + editor_keymap: Rc>, #[lua(skip)] left_panel: PanelHandle, #[lua(skip)] @@ -134,6 +138,40 @@ pub struct AppApi { diagnostics_handler: Rc>>, } +/// API редактора: вкладки, текст, gutter, сохранение, LSP и свои привязки +/// клавиш. +/// +/// В Lua доступно как `app.editor`. Объект всегда смотрит на те же документы +/// и LSP-менеджер, что и вид: второго состояния нет. +#[derive(Clone, mlua::UserData)] +pub struct EditorApi { + #[lua(skip)] + workspace: Rc>, + /// Привязки редактора и их текущий режим. + #[lua(skip)] + keymap: Rc>, + #[lua(skip)] + lsp: Arc, + #[lua(skip)] + diagnostics_handler: Rc>>, +} + +impl EditorApi { + fn new( + workspace: Rc>, + keymap: Rc>, + lsp: Arc, + diagnostics_handler: Rc>>, + ) -> Self { + Self { + workspace, + keymap, + lsp, + diagnostics_handler, + } + } +} + /// API одной панели окна. /// /// Один и тот же тип используется для `app.left_panel`, `app.right_panel` и @@ -250,6 +288,46 @@ impl TreeApi { self.state.borrow_mut().set_click_callback(Some(callback)); } + /// Назначает обработчик клавиши, который действует, когда фокус в этом + /// блоке. + /// + /// Первый аргумент `modes` — строка режимов (пустая строка — все режимы, + /// `"-"` — только режим по умолчанию), `keys` — сочетание в духе nvim, + /// `handler` — Lua-функция, имя встроенной команды или `nil`, чтобы удалить + /// привязку. Привязки блока срабатывают дополнительно к глобальным. + pub fn map_key(&self, modes: String, keys: String, handler: mlua::Value) -> mlua::Result<()> { + bind_key( + &self.state.borrow().keymap_handle(), + KeyContext::Global, + &modes, + &keys, + handler, + ) + } + + /// Переключает привязки блока в режим `mode`. + /// + /// Режим — ровно один символ; `"-"` — режим по умолчанию. + pub fn set_mode(&self, mode: String) -> mlua::Result<()> { + self.state + .borrow() + .keymap_handle() + .borrow_mut() + .set_mode(&mode) + .map_err(mlua::Error::RuntimeError) + } + + /// Текущий режим привязок блока. + #[lua(infallible)] + pub fn mode(&self) -> String { + self.state + .borrow() + .keymap_handle() + .borrow() + .mode() + .to_string() + } + /// Идентификатор выбранной строки или `nil`. #[lua(infallible)] pub fn selected(&self) -> Option { @@ -398,6 +476,7 @@ impl AppApi { let api = Self { workspace, keymap, + editor_keymap: Rc::new(RefCell::new(KeyMap::default())), left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))), right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))), bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))), @@ -452,235 +531,25 @@ impl AppApi { self.lsp.clone() } - /// Все открытые вкладки в порядке слева направо. + /// Привязки клавиш редактора (для вида). + #[lua(skip)] + pub fn editor_keymap_handle(&self) -> Rc> { + self.editor_keymap.clone() + } + + /// API редактора. + /// + /// В Lua доступно как `app.editor`: `app.editor:open_tab()`, + /// `app.editor:save()`, `app.editor:map_key("n", "", "save")`. #[allow(dead_code)] - // Часть публичного API приложения: вызывается внешним кодом и тестами. - #[lua(infallible)] - pub fn tabs(&self) -> Vec { - self.workspace - .borrow() - .documents() - .iter() - .enumerate() - .map(|(index, doc)| TabInfo { - index, - title: doc.name.clone(), - icon: doc.icon_name().to_owned(), - }) - .collect() - } - - /// Индекс активной вкладки в списке [`AppApi::tabs`]. - #[allow(dead_code)] - // Часть публичного API приложения: вызывается внешним кодом и тестами. - #[lua(infallible, name = "active_tab")] - pub fn active_tab_index(&self) -> usize { - self.workspace.borrow().active_index() - } - - /// Переключает на следующую вкладку (по кругу) и возвращает её индекс. - #[allow(dead_code)] - // Часть публичного API приложения: вызывается внешним кодом и тестами. - #[lua(infallible)] - pub fn next_tab(&self) -> usize { - self.workspace.borrow_mut().cycle_active(1) - } - - /// Переключает на предыдущую вкладку (по кругу) и возвращает её индекс. - #[allow(dead_code)] - // Часть публичного API приложения: вызывается внешним кодом и тестами. - #[lua(infallible)] - pub fn previous_tab(&self) -> usize { - self.workspace.borrow_mut().cycle_active(-1) - } - - /// Закрывает вкладку по её номеру в списке [`AppApi::tabs`]. - /// - /// Возвращает `false`, если вкладки с таким номером нет. Если закрывается - /// последняя вкладка, вместо неё открывается пустой документ. - #[lua(infallible)] - pub fn close_tab(&self, index: usize) -> bool { - let mut workspace = self.workspace.borrow_mut(); - let Some(document) = workspace.document(index) else { - return false; - }; - let uri = document_uri(&document.name); - workspace.close(index); - drop(workspace); - - if let Some(uri) = uri { - self.lsp.close_document(&uri); - } - true - } - - /// Сохраняет содержимое активной вкладки в файл, названный как сама вкладка. - /// - /// Путь считается относительно текущего рабочего каталога процесса. - /// При успешной записи с вкладки снимается маркер несохранённых изменений. - #[lua(skip)] - pub fn save(&self) -> std::io::Result<()> { - let (path, content) = { - let workspace = self.workspace.borrow(); - let doc = workspace.active(); - (doc.name.clone(), doc.content.clone()) - }; - std::fs::write(&path, &content)?; - if let Some(uri) = document_uri(&path) { - self.lsp.did_save(&uri, &content); - } - self.workspace.borrow_mut().mark_active_saved(); - Ok(()) - } - - /// Lua-версия [`AppApi::save`]: ошибка ввода-вывода становится ошибкой Lua. - #[lua(name = "save")] - fn lua_save(&self) -> mlua::Result<()> { - self.save() - .map_err(|err| mlua::Error::RuntimeError(err.to_string())) - } - - /// Назначает обработчик на нажатие или отпускание клавиши. - /// - /// `keys` записывается в духе nvim: - /// - /// * `a`, `1` — нажатие клавиши без модификаторов; - /// * ``, ``, ``, `` — нажатие с Shift, Ctrl, Alt или - /// платформенным модификатором (Cmd/Super); - /// * `!a` — отпускание клавиши; - /// * `` — отпускание клавиши при зажатом Alt. - /// - /// Второй аргумент — Lua-функция, имя встроенной команды (`save`, - /// `next_tab`, `toggle_left_panel` и т. п.) или `nil`, чтобы удалить - /// привязку. Повторное назначение той же клавиши заменяет обработчик. - /// - /// # Errors - /// Возвращает ошибку Lua, если запись привязки или имя команды некорректны. - pub fn map_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> { - self.bind_key(KeyContext::Global, &keys, handler) - } - - /// Назначает обработчик клавиши, пока открыт список автодополнения. - /// - /// Синтаксис совпадает с [`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<()> { - self.bind_key(KeyContext::Completion, &keys, handler) - } - - #[lua(skip)] - fn bind_key(&self, context: KeyContext, keys: &str, handler: mlua::Value) -> mlua::Result<()> { - let binding = KeyBinding::parse(keys).map_err(mlua::Error::RuntimeError)?; - match parse_key_handler(context, handler)? { - Some(handler) => self.keymap.borrow_mut().set(context, binding, handler), - None => { - self.keymap.borrow_mut().unbind(context, &binding); - } - } - Ok(()) - } - - /// Содержимое активной вкладки. - #[lua(infallible)] - pub fn active_text(&self) -> String { - self.workspace.borrow().active().content.clone() - } - - /// Заменяет содержимое активной вкладки. - #[lua(skip)] - pub fn set_active_text(&self, text: impl Into) { - self.workspace.borrow_mut().set_active_text(text); - } - - /// Lua-версия [`AppApi::set_active_text`] (generic-методы не экспортируются). - #[lua(infallible, name = "set_active_text")] - fn lua_set_active_text(&self, text: String) { - self.set_active_text(text); - } - - /// Открывает новую пустую вкладку, делает её активной и возвращает её индекс. - #[lua(infallible)] - pub fn open_tab(&self) -> usize { - let mut workspace = self.workspace.borrow_mut(); - workspace.open_new(); - workspace.active_index() - } - - /// Открывает файл с диска на редактирование (Rust-версия `app:open`). - /// - /// Если файла нет, создаётся пустая вкладка с таким именем — как в vim. - /// Язык подсветки определяется по расширению, если для него есть грамматика. - /// Фокус редактора не меняется: для этого есть [`AppApi::open_focused`] - /// и Lua-форма `app:open(path, {...})`. - #[lua(skip)] - pub fn open(&self, path: impl AsRef) -> std::io::Result { - self.open_impl(path, None, false) - } - - /// Открывает файл и переводит фокус в редактор (Rust-версия - /// `app:open(path, position)`). - /// - /// `position` — строка и столбец с нуля, как в диагностике LSP; столбец - /// считается в UTF-16. `None` оставляет курсор там, где он был. - #[lua(skip)] - pub fn open_focused( - &self, - path: impl AsRef, - position: Option<(u32, u32)>, - ) -> std::io::Result { - self.open_impl(path, position, true) - } - - #[lua(skip)] - fn open_impl( - &self, - path: impl AsRef, - position: Option<(u32, u32)>, - focus_editor: bool, - ) -> std::io::Result { - let path = path.as_ref(); - let content = match std::fs::read_to_string(path) { - Ok(content) => content, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(err) => return Err(err), - }; - - let cursor = position.map(|(line, column)| { - position_to_offset(&content, lsp_types::Position::new(line, column)) - }); - let focus = if focus_editor { - FocusAfterSync::Editor(cursor) - } else { - FocusAfterSync::Keep - }; - - let name = path.to_string_lossy().into_owned(); - let language = Language::from_path(&name); - let mut workspace = self.workspace.borrow_mut(); - let index = workspace.open_file(name, content, language); - workspace.set_focus_after_sync(focus); - Ok(index) - } - - /// Lua-версия [`AppApi::open`]: `app:open("main.go")`. - /// - /// Второй аргумент необязателен: - /// - /// * `nil`/нет аргумента — открыть вкладку, не трогая фокус; - /// * `{}` — перевести фокус в редактор, курсор оставить на месте; - /// * `{line, column}` — перевести фокус и поставить курсор (с нуля). - /// - /// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua. - #[lua(name = "open")] - fn lua_open(&self, path: String, position: mlua::Value) -> mlua::Result { - match parse_open_focus(position)? { - OpenFocus::Keep => self.open(&path), - OpenFocus::Editor(position) => self.open_focused(&path, position), - } - .map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}"))) + #[lua(getter, infallible)] + pub fn editor(&self) -> EditorApi { + EditorApi::new( + self.workspace.clone(), + self.editor_keymap.clone(), + self.lsp.clone(), + self.diagnostics_handler.clone(), + ) } /// Читает содержимое каталога (Rust-версия `app:list_dir`). @@ -737,99 +606,6 @@ impl AppApi { }) } - /// Делает вкладку активной по её номеру в списке [`AppApi::tabs`]. - /// - /// Возвращает `false`, если вкладки с таким номером нет. - #[lua(infallible)] - pub fn set_active_tab(&self, index: usize) -> bool { - let mut workspace = self.workspace.borrow_mut(); - if index >= workspace.documents().len() { - return false; - } - workspace.set_active(index); - true - } - - /// Назначает иконку вкладке `index`. - /// - /// `icon` — имя иконки Lucide: `file`, `file-code`, `book-open`, `settings` - /// и т. п. Регистр не важен; `_` и пробелы равны дефису, префикс `icons/` - /// и расширение `.svg` можно не писать. - /// - /// Возвращает `false`, если вкладки нет или иконка с таким именем не входит - /// в каталог. - #[lua(skip)] - pub fn set_tab_icon(&self, index: usize, icon: &str) -> bool { - let Some(name) = icons::canonical_name(icon) else { - return false; - }; - self.workspace.borrow_mut().set_tab_icon(index, name) - } - - /// Lua-версия [`AppApi::set_tab_icon`]. - #[lua(infallible, name = "set_tab_icon")] - fn lua_set_tab_icon(&self, index: usize, icon: String) -> bool { - self.set_tab_icon(index, &icon) - } - - /// Возвращает вкладке иконку по умолчанию (`file`). - /// - /// Возвращает `false`, если вкладки с таким номером нет. - #[lua(infallible)] - pub fn clear_tab_icon(&self, index: usize) -> bool { - self.workspace.borrow_mut().clear_tab_icon(index) - } - - /// Имя иконки вкладки: назначенное через [`AppApi::set_tab_icon`] или - /// `file` по умолчанию. `nil`, если вкладки нет. - #[lua(infallible)] - pub fn tab_icon(&self, index: usize) -> Option { - self.workspace - .borrow() - .document(index) - .map(|doc| doc.icon_name().to_owned()) - } - - /// Включает подсветку синтаксиса для вкладки `index`. - /// - /// `language` — название языка: `rust`, `go`, `js`, `json` или `python` - /// (принимаются также `rs`, `golang`, `javascript`, `py`; регистр не важен). - /// Подсветка по умолчанию выключена и включается только этим методом. - /// - /// Возвращает `false`, если вкладки нет или язык не поддерживается. - #[lua(skip)] - pub fn set_language(&self, index: usize, language: &str) -> bool { - let Some(language) = Language::from_name(language) else { - return false; - }; - self.workspace.borrow_mut().set_language(index, language) - } - - /// Lua-версия [`AppApi::set_language`]. - #[lua(infallible, name = "set_language")] - fn lua_set_language(&self, index: usize, language: String) -> bool { - self.set_language(index, &language) - } - - /// Язык подсветки вкладки: `"rust"`, `"go"`, `"js"`, `"json"`, `"python"` - /// или `nil`, если подсветка выключена / вкладки нет. - #[lua(infallible)] - pub fn language(&self, index: usize) -> Option { - self.workspace - .borrow() - .document(index) - .and_then(|doc| doc.language) - .map(|language| language.name().to_owned()) - } - - /// Выключает подсветку синтаксиса для вкладки. - /// - /// Возвращает `false`, если вкладки с таким номером нет. - #[lua(infallible)] - pub fn clear_language(&self, index: usize) -> bool { - self.workspace.borrow_mut().clear_language(index) - } - /// Левая панель окна. /// /// В Lua доступна как `app.left_panel` с методами `open`, `close`, @@ -900,6 +676,460 @@ impl AppApi { WINDOW_MAXIMIZED.with(Cell::get) } + /// Назначает глобальный обработчик на нажатие или отпускание клавиши. + /// + /// Первый аргумент `modes` — строка режимов, в которых действует + /// сочетание: пустая строка — все режимы, `"-"` — только режим по + /// умолчанию, `"-iv"` — режим по умолчанию, `i` и `v`. `keys` записывается + /// в духе nvim: + /// + /// * `a`, `1` — нажатие клавиши без модификаторов; + /// * ``, ``, ``, `` — нажатие с Shift, Ctrl, Alt или + /// платформенным модификатором (Cmd/Super); + /// * `!a` — отпускание клавиши; + /// * `` — отпускание клавиши при зажатом Alt. + /// + /// Обработчик — Lua-функция, имя встроенной команды (`save`, `next_tab`, + /// `toggle_left_panel` и т. п.) или `nil`, чтобы удалить привязку. Повторное + /// назначение той же клавиши заменяет обработчик. + /// + /// # Errors + /// Возвращает ошибку Lua, если запись привязки или имя команды некорректны. + pub fn map_key(&self, modes: String, keys: String, handler: mlua::Value) -> mlua::Result<()> { + bind_key(&self.keymap, KeyContext::Global, &modes, &keys, handler) + } + + /// Переключает глобальные привязки в режим `mode`. + /// + /// Режим — ровно один символ; `"-"` — режим по умолчанию. Режимы + /// динамические: заранее создавать их не нужно. + pub fn set_mode(&self, mode: String) -> mlua::Result<()> { + self.keymap + .borrow_mut() + .set_mode(&mode) + .map_err(mlua::Error::RuntimeError) + } + + /// Текущий режим глобальных привязок. + #[lua(infallible)] + pub fn mode(&self) -> String { + self.keymap.borrow().mode().to_string() + } + + /// Назначает обработчик клавиши, пока открыт список автодополнения. + /// + /// Синтаксис совпадает с [`AppApi::map_key`]. Встроенные команды этого + /// слоя: `completion_trigger`, `completion_next`, `completion_previous`, + /// `completion_page_up`, `completion_page_down`, `completion_accept`, + /// `completion_cancel`, `completion_dismiss`. + #[lua(name = "map_completion_key")] + pub fn map_completion_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> { + bind_key(&self.keymap, KeyContext::Completion, "", &keys, handler) + } +} + +#[mlua::userdata_impl] +impl EditorApi { + /// Все открытые вкладки в порядке слева направо. + #[allow(dead_code)] + // Часть публичного API приложения: вызывается внешним кодом и тестами. + #[lua(infallible)] + pub fn tabs(&self) -> Vec { + self.workspace + .borrow() + .documents() + .iter() + .enumerate() + .map(|(index, doc)| TabInfo { + index, + title: doc.name.clone(), + icon: doc.icon_name().to_owned(), + }) + .collect() + } + + /// Индекс активной вкладки в списке [`EditorApi::tabs`]. + #[allow(dead_code)] + // Часть публичного API приложения: вызывается внешним кодом и тестами. + #[lua(infallible, name = "active_tab")] + pub fn active_tab_index(&self) -> usize { + self.workspace.borrow().active_index() + } + + /// Переключает на следующую вкладку (по кругу) и возвращает её индекс. + #[allow(dead_code)] + // Часть публичного API приложения: вызывается внешним кодом и тестами. + #[lua(infallible)] + pub fn next_tab(&self) -> usize { + self.workspace.borrow_mut().cycle_active(1) + } + + /// Переключает на предыдущую вкладку (по кругу) и возвращает её индекс. + #[allow(dead_code)] + // Часть публичного API приложения: вызывается внешним кодом и тестами. + #[lua(infallible)] + pub fn previous_tab(&self) -> usize { + self.workspace.borrow_mut().cycle_active(-1) + } + + /// Закрывает вкладку по её номеру в списке [`EditorApi::tabs`]. + /// + /// Возвращает `false`, если вкладки с таким номером нет. Если закрывается + /// последняя вкладка, вместо неё открывается пустой документ. + #[lua(infallible)] + pub fn close_tab(&self, index: usize) -> bool { + let mut workspace = self.workspace.borrow_mut(); + let Some(document) = workspace.document(index) else { + return false; + }; + let uri = document_uri(&document.name); + workspace.close(index); + drop(workspace); + + if let Some(uri) = uri { + self.lsp.close_document(&uri); + } + true + } + + /// Сохраняет содержимое активной вкладки в файл, названный как сама вкладка. + /// + /// Путь считается относительно текущего рабочего каталога процесса. + /// При успешной записи с вкладки снимается маркер несохранённых изменений. + #[lua(skip)] + pub fn save(&self) -> std::io::Result<()> { + let (path, content) = { + let workspace = self.workspace.borrow(); + let doc = workspace.active(); + (doc.name.clone(), doc.content.clone()) + }; + std::fs::write(&path, &content)?; + if let Some(uri) = document_uri(&path) { + self.lsp.did_save(&uri, &content); + } + self.workspace.borrow_mut().mark_active_saved(); + Ok(()) + } + + /// Lua-версия [`EditorApi::save`]: ошибка ввода-вывода становится ошибкой Lua. + #[lua(name = "save")] + fn lua_save(&self) -> mlua::Result<()> { + self.save() + .map_err(|err| mlua::Error::RuntimeError(err.to_string())) + } + + /// Назначает обработчик на нажатие или отпускание клавиши. + /// + /// Первый аргумент `modes` — строка режимов, в которых действует + /// сочетание: пустая строка — все режимы, `"-"` — только режим по + /// умолчанию, `"-iv"` — режим по умолчанию, `i` и `v`. `keys` записывается + /// в духе nvim: + /// + /// * `a`, `1` — нажатие клавиши без модификаторов; + /// * ``, ``, ``, `` — нажатие с Shift, Ctrl, Alt или + /// платформенным модификатором (Cmd/Super); + /// * `!a` — отпускание клавиши; + /// * `` — отпускание клавиши при зажатом Alt. + /// + /// Третий аргумент — Lua-функция, имя встроенной команды (`save`, + /// `next_tab`, `toggle_left_panel` и т. п.) или `nil`, чтобы удалить + /// привязку. Повторное назначение той же клавиши заменяет обработчик. + /// + /// Привязки редактора срабатывают дополнительно к глобальным и только + /// когда фокус находится в редакторе. + /// + /// # Errors + /// Возвращает ошибку Lua, если запись привязки или имя команды некорректны. + pub fn map_key(&self, modes: String, keys: String, handler: mlua::Value) -> mlua::Result<()> { + bind_key(&self.keymap, KeyContext::Global, &modes, &keys, handler) + } + + /// Переключает привязки редактора в режим `mode`. + /// + /// Режим — ровно один символ; `"-"` — режим по умолчанию. + pub fn set_mode(&self, mode: String) -> mlua::Result<()> { + self.keymap + .borrow_mut() + .set_mode(&mode) + .map_err(mlua::Error::RuntimeError) + } + + /// Текущий режим привязок редактора. + #[lua(infallible)] + pub fn mode(&self) -> String { + self.keymap.borrow().mode().to_string() + } + + /// Содержимое активной вкладки. + #[lua(infallible)] + pub fn active_text(&self) -> String { + self.workspace.borrow().active().content.clone() + } + + /// Заменяет содержимое активной вкладки. + #[lua(skip)] + pub fn set_active_text(&self, text: impl Into) { + self.workspace.borrow_mut().set_active_text(text); + } + + /// Lua-версия [`EditorApi::set_active_text`] (generic-методы не экспортируются). + #[lua(infallible, name = "set_active_text")] + fn lua_set_active_text(&self, text: String) { + self.set_active_text(text); + } + + /// Открывает новую пустую вкладку, делает её активной и возвращает её индекс. + #[lua(infallible)] + pub fn open_tab(&self) -> usize { + let mut workspace = self.workspace.borrow_mut(); + workspace.open_new(); + workspace.active_index() + } + + /// Открывает файл с диска на редактирование (Rust-версия `app.editor:open`). + /// + /// Если файла нет, создаётся пустая вкладка с таким именем — как в vim. + /// Язык подсветки определяется по расширению, если для него есть грамматика. + /// Фокус редактора не меняется: для этого есть [`EditorApi::open_focused`] + /// и Lua-форма `app.editor:open(path, {...})`. + #[lua(skip)] + pub fn open(&self, path: impl AsRef) -> std::io::Result { + self.open_impl(path, None, false) + } + + /// Открывает файл и переводит фокус в редактор (Rust-версия + /// `app:open(path, position)`). + /// + /// `position` — строка и столбец с нуля, как в диагностике LSP; столбец + /// считается в UTF-16. `None` оставляет курсор там, где он был. + #[lua(skip)] + pub fn open_focused( + &self, + path: impl AsRef, + position: Option<(u32, u32)>, + ) -> std::io::Result { + self.open_impl(path, position, true) + } + + #[lua(skip)] + fn open_impl( + &self, + path: impl AsRef, + position: Option<(u32, u32)>, + focus_editor: bool, + ) -> std::io::Result { + let path = path.as_ref(); + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err), + }; + + let cursor = position.map(|(line, column)| { + position_to_offset(&content, lsp_types::Position::new(line, column)) + }); + let focus = if focus_editor { + FocusAfterSync::Editor(cursor) + } else { + FocusAfterSync::Keep + }; + + let name = path.to_string_lossy().into_owned(); + let language = Language::from_path(&name); + let mut workspace = self.workspace.borrow_mut(); + let index = workspace.open_file(name, content, language); + workspace.set_focus_after_sync(focus); + Ok(index) + } + + /// Lua-версия [`EditorApi::open`]: `app.editor:open("main.go")`. + /// + /// Второй аргумент необязателен: + /// + /// * `nil`/нет аргумента — открыть вкладку, не трогая фокус; + /// * `{}` — перевести фокус в редактор, курсор оставить на месте; + /// * `{line, column}` — перевести фокус и поставить курсор (с нуля). + /// + /// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua. + #[lua(name = "open")] + fn lua_open(&self, path: String, position: mlua::Value) -> mlua::Result { + match parse_open_focus(position)? { + OpenFocus::Keep => self.open(&path), + OpenFocus::Editor(position) => self.open_focused(&path, position), + } + .map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}"))) + } + + /// Делает вкладку активной по её номеру в списке [`EditorApi::tabs`]. + /// + /// Возвращает `false`, если вкладки с таким номером нет. + #[lua(infallible)] + pub fn set_active_tab(&self, index: usize) -> bool { + let mut workspace = self.workspace.borrow_mut(); + if index >= workspace.documents().len() { + return false; + } + workspace.set_active(index); + true + } + + /// Назначает иконку вкладке `index`. + /// + /// `icon` — имя иконки Lucide: `file`, `file-code`, `book-open`, `settings` + /// и т. п. Регистр не важен; `_` и пробелы равны дефису, префикс `icons/` + /// и расширение `.svg` можно не писать. + /// + /// Возвращает `false`, если вкладки нет или иконка с таким именем не входит + /// в каталог. + #[lua(skip)] + pub fn set_tab_icon(&self, index: usize, icon: &str) -> bool { + let Some(name) = icons::canonical_name(icon) else { + return false; + }; + self.workspace.borrow_mut().set_tab_icon(index, name) + } + + /// Lua-версия [`EditorApi::set_tab_icon`]. + #[lua(infallible, name = "set_tab_icon")] + fn lua_set_tab_icon(&self, index: usize, icon: String) -> bool { + self.set_tab_icon(index, &icon) + } + + /// Возвращает вкладке иконку по умолчанию (`file`). + /// + /// Возвращает `false`, если вкладки с таким номером нет. + #[lua(infallible)] + pub fn clear_tab_icon(&self, index: usize) -> bool { + self.workspace.borrow_mut().clear_tab_icon(index) + } + + /// Имя иконки вкладки: назначенное через [`EditorApi::set_tab_icon`] или + /// `file` по умолчанию. `nil`, если вкладки нет. + #[lua(infallible)] + pub fn tab_icon(&self, index: usize) -> Option { + self.workspace + .borrow() + .document(index) + .map(|doc| doc.icon_name().to_owned()) + } + + /// Ставит иконку на поле редактора (gutter) активной вкладки. + /// + /// `line` — номер строки с нуля; `icon` — имя иконки Lucide, как у + /// [`EditorApi::set_tab_icon`]; `color` — необязательный цвет иконки + /// (`#RGB`, `#RGBA`, `#RRGGBB` или `#RRGGBBAA`). `color = None` рисует + /// иконку цветом текста темы. + /// + /// На одной строке живёт одна иконка: повторный вызов заменяет прежнюю. + /// Возвращает `false`, если иконка или цвет неизвестны; тогда состояние + /// не меняется. + #[lua(skip)] + pub fn set_gutter_icon(&self, line: usize, icon: &str, color: Option<&str>) -> bool { + let Some(name) = icons::canonical_name(icon) else { + return false; + }; + let color = match color.map(str::trim) { + None | Some("") => None, + Some(value) if value.eq_ignore_ascii_case("transparent") => None, + Some(value) => match parse_hex_color(value) { + Some(color) => Some(color), + None => return false, + }, + }; + self.workspace + .borrow_mut() + .active_mut() + .gutter + .set(line, name, color); + true + } + + /// Lua-версия [`EditorApi::set_gutter_icon`]. + #[lua(infallible, name = "set_gutter_icon")] + fn lua_set_gutter_icon(&self, line: usize, icon: String, color: Option) -> bool { + self.set_gutter_icon(line, &icon, color.as_deref()) + } + + /// Имя иконки gutter на строке `line` активной вкладки или `nil`. + #[lua(infallible)] + pub fn gutter_icon(&self, line: usize) -> Option { + self.workspace + .borrow() + .active() + .gutter + .get(line) + .map(|marker| marker.icon.clone()) + } + + /// Цвет иконки gutter (`#RRGGBB` или `#RRGGBBAA`) или `nil`. + /// + /// `nil` возвращается и когда иконки нет, и когда она рисуется цветом + /// темы. + #[lua(infallible)] + pub fn gutter_color(&self, line: usize) -> Option { + self.workspace + .borrow() + .active() + .gutter + .get(line) + .and_then(|marker| marker.color) + .map(format_hex_color) + } + + /// Убирает иконку gutter со строки `line` активной вкладки. + /// + /// Возвращает `false`, если иконки на этой строке не было. + #[lua(infallible)] + pub fn clear_gutter_icon(&self, line: usize) -> bool { + self.workspace.borrow_mut().active_mut().gutter.remove(line) + } + + /// Убирает все иконки gutter активной вкладки. + #[lua(infallible)] + pub fn clear_gutter(&self) { + self.workspace.borrow_mut().active_mut().gutter.clear(); + } + + /// Включает подсветку синтаксиса для вкладки `index`. + /// + /// `language` — название языка: `rust`, `go`, `js`, `json` или `python` + /// (принимаются также `rs`, `golang`, `javascript`, `py`; регистр не важен). + /// Подсветка по умолчанию выключена и включается только этим методом. + /// + /// Возвращает `false`, если вкладки нет или язык не поддерживается. + #[lua(skip)] + pub fn set_language(&self, index: usize, language: &str) -> bool { + let Some(language) = Language::from_name(language) else { + return false; + }; + self.workspace.borrow_mut().set_language(index, language) + } + + /// Lua-версия [`EditorApi::set_language`]. + #[lua(infallible, name = "set_language")] + fn lua_set_language(&self, index: usize, language: String) -> bool { + self.set_language(index, &language) + } + + /// Язык подсветки вкладки: `"rust"`, `"go"`, `"js"`, `"json"`, `"python"` + /// или `nil`, если подсветка выключена / вкладки нет. + #[lua(infallible)] + pub fn language(&self, index: usize) -> Option { + self.workspace + .borrow() + .document(index) + .and_then(|doc| doc.language) + .map(|language| language.name().to_owned()) + } + + /// Выключает подсветку синтаксиса для вкладки. + /// + /// Возвращает `false`, если вкладки с таким номером нет. + #[lua(infallible)] + pub fn clear_language(&self, index: usize) -> bool { + self.workspace.borrow_mut().clear_language(index) + } + /// Запускает LSP-сервер по описанию (Rust-версия Lua-метода). #[lua(skip)] pub fn run_lsp(&self, config: LspConfig) -> Result<(), String> { @@ -1024,7 +1254,7 @@ impl AppApi { } } -/// Второй аргумент `app:open`. +/// Второй аргумент `app.editor:open`. enum OpenFocus { /// Фокус не меняется. Keep, @@ -1032,7 +1262,7 @@ enum OpenFocus { Editor(Option<(u32, u32)>), } -/// Разбирает необязательный второй аргумент `app:open`. +/// Разбирает необязательный второй аргумент `app.editor:open`. fn parse_open_focus(value: mlua::Value) -> mlua::Result { let table = match value { mlua::Value::Nil => return Ok(OpenFocus::Keep), @@ -1148,6 +1378,26 @@ fn parse_watcher(table: &mlua::Table) -> mlua::Result> { } } +/// Добавляет привязку в карту объекта или удаляет её (`handler = nil`). +/// +/// `modes` — строка режимов; пустая строка означает все режимы. +fn bind_key( + keymap: &Rc>, + context: KeyContext, + modes: &str, + keys: &str, + handler: mlua::Value, +) -> mlua::Result<()> { + let binding = KeyBinding::parse(keys).map_err(mlua::Error::RuntimeError)?; + match parse_key_handler(context, handler)? { + Some(handler) => keymap.borrow_mut().set(context, modes, binding, handler), + None => { + keymap.borrow_mut().unbind(context, &binding); + } + } + Ok(()) +} + /// Разбирает обработчик, переданный в `app:map_key` / `app:map_completion_key`. /// /// `nil` означает «удалить привязку». @@ -1243,6 +1493,28 @@ fn parse_toolbar_color(color: Option) -> mlua::Result> { }) } +/// Переводит цвет в шестнадцатеричную запись `#RRGGBB` или `#RRGGBBAA`. +fn format_hex_color(color: Hsla) -> String { + let rgba = Rgba::from(color); + let channel = |value: f32| (value.clamp(0., 1.) * 255.).round() as u8; + if rgba.a >= 0.999 { + format!( + "#{:02x}{:02x}{:02x}", + channel(rgba.r), + channel(rgba.g), + channel(rgba.b) + ) + } else { + format!( + "#{:02x}{:02x}{:02x}{:02x}", + channel(rgba.r), + channel(rgba.g), + channel(rgba.b), + channel(rgba.a) + ) + } +} + /// Переводит шестнадцатеричный цвет в `Hsla`. fn parse_hex_color(value: &str) -> Option { let hex = value.strip_prefix('#').unwrap_or(value); diff --git a/src/app.rs b/src/app.rs index 3a61f10..24437a3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,7 +27,7 @@ use gpui_kit::component::tab::{Tab, TabBar}; use gpui_kit::component::tag::Tag; use gpui_kit::component::text::TextView; use gpui_kit::component::{ - ActiveTheme, Icon, Sizable, StyledExt, TitleBar, h_flex, h_resizable, resizable_panel, + ActiveTheme, Icon, Sizable, Size, StyledExt, TitleBar, h_flex, h_resizable, resizable_panel, resize_handle, v_resizable, }; use gpui_kit::*; @@ -39,8 +39,12 @@ use crate::completion::{ completion_kind_name, documentation_text, fallback_documentation, highlight_range, item_key, parse_completion_response, range_for_lsp, word_start, }; -use crate::constants::{MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE, WINDOW_TITLE}; +use crate::constants::{ + GUTTER_ICON_SIZE, GUTTER_WIDTH, MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE, + WINDOW_TITLE, +}; use crate::editor::{FocusAfterSync, Workspace}; +use crate::gutter::GutterMarker; use crate::icons; use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap}; use crate::lsp::{LspManager, document_uri, position_to_offset}; @@ -182,6 +186,9 @@ pub struct MyApp { /// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения) /// уничтожились раньше самого Lua-движка. keymap: Rc>, + /// Привязки клавиш редактора: действуют дополнительно к глобальным, + /// когда фокус находится в редакторе. + editor_keymap: Rc>, /// Состояние левой панели, общее с `app.left_panel` в Lua. left_panel: PanelHandle, /// Состояние правой панели, общее с `app.right_panel` в Lua. @@ -252,6 +259,7 @@ impl MyApp { keymap.borrow_mut().install_defaults(); let api = AppApi::install(workspace.clone(), keymap.clone()); let lsp = api.lsp_handle(); + let editor_keymap = api.editor_keymap_handle(); let initial_text = workspace.borrow().active().content.clone(); let editor = cx.new(|cx| { @@ -319,7 +327,7 @@ impl MyApp { // Диагностика приходит уведомлениями сервера, а не ответом на запрос. // Одна задача применяет уже известную диагностику к редактору и вызывает // Lua-обработчик; у хранилища один `Waker`, поэтому подписчик один. - let diagnostics_api = api.clone(); + let diagnostics_api = api.editor(); let diagnostics_manager = lsp.clone(); cx.spawn(async move |this, cx| { let mut revision = diagnostics_manager.diagnostics_revision(); @@ -404,6 +412,7 @@ impl MyApp { Self { workspace, keymap, + editor_keymap, left_panel, right_panel, bottom_panel, @@ -445,7 +454,7 @@ impl MyApp { // Первый позиционный аргумент запуска ведёт себя как `vim file.go`: // открываем его тем же API-методом, который доступен из Lua. if let Some(path) = crate::constants::startup_file() - && let Err(err) = AppApi::global().open(path) + && let Err(err) = AppApi::global().editor().open(path) { eprintln!("Не удалось открыть {}: {err}", path.display()); } @@ -1414,8 +1423,10 @@ impl MyApp { true } }; + // Событие не останавливаем: глобальные привязки и привязки + // блока обрабатывает корень вида, а навигация дерева уже + // выполнена здесь. if handled { - cx.stop_propagation(); entity.update(cx, |_, cx| cx.notify()); } } @@ -1682,8 +1693,34 @@ impl MyApp { // Нижняя панель занимает ширину редактора, а правая тянется на всю // высоту — как в большинстве редакторов кода. + // Gutter — отдельная колонка слева от редактора: сам `Editor` не + // умеет рисовать пользовательские иконки. Дети идут в порядке + // «редактор, затем gutter», хотя `flex_row_reverse` ставит gutter + // слева: так gutter подготавливается после редактора и видит его + // прокрутку текущего кадра, а не прошлого. + let editor = div() + .size_full() + .flex() + .flex_row_reverse() + .child( + div() + .flex_1() + .min_w_0() + .h_full() + // Самый компактный размер убирает верхний и нижний + // внутренние отступы редактора; слева место под иконки + // уже занимает gutter. + .child( + Editor::new(&self.editor) + .with_size(Size::XSmall) + .bordered(false) + .size_full(), + ), + ) + .child(self.render_gutter(cx)); + let editor_area = v_resizable("editor-area") - .child(resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full())) + .child(resizable_panel().child(editor)) .child( resizable_panel() .size(px(bottom_size)) @@ -1748,6 +1785,35 @@ impl MyApp { }) } + /// Колонка иконок слева от редактора (gutter). + /// + /// Позиции иконок вычисляются в [`GutterElement::prepaint`], после + /// раскладки самого редактора. Это важно: прокрутка окончательно + /// фиксируется именно в его prepaint, и если считать `top` здесь, в + /// `render`, иконки отстают от текста на кадр. + fn render_gutter(&self, cx: &mut Context) -> impl IntoElement { + let markers = self + .workspace + .borrow() + .active() + .gutter + .iter() + .map(|(line, marker)| (line, marker.clone())) + .collect::>(); + GutterElement { + editor: self.editor.clone(), + markers, + width: px(GUTTER_WIDTH), + background: cx + .theme() + .highlight_theme + .style + .editor_gutter_background + .unwrap_or(cx.theme().background), + foreground: cx.theme().muted_foreground, + } + } + /// Полоса между вкладками и редактором. /// /// Элементы добавляются сценарием через `app.toolbar`. Левые элементы @@ -1931,7 +1997,7 @@ impl MyApp { // на соседнюю по устаревшему индексу. cx.stop_propagation(); entity.update(cx, |_, cx| { - AppApi::global().close_tab(index); + AppApi::global().editor().close_tab(index); cx.notify(); }); } @@ -1961,7 +2027,7 @@ impl MyApp { let entity = entity.clone(); move |_, _, cx| { entity.update(cx, |_, cx| { - AppApi::global().open_tab(); + AppApi::global().editor().open_tab(); cx.notify(); }); } @@ -1971,7 +2037,7 @@ impl MyApp { let entity = entity.clone(); move |index, _, cx| { entity.update(cx, |_, cx| { - AppApi::global().set_active_tab(*index); + AppApi::global().editor().set_active_tab(*index); cx.notify(); }); } @@ -2009,6 +2075,13 @@ impl Render for MyApp { }; let keymap = self.keymap.clone(); + let editor_keymap = self.editor_keymap.clone(); + let editor = self.editor.clone(); + let panels = [ + self.left_panel.clone(), + self.right_panel.clone(), + self.bottom_panel.clone(), + ]; let entity = cx.entity(); div() @@ -2020,23 +2093,49 @@ impl Render for MyApp { .text_color(cx.theme().foreground) .on_key_down({ let keymap = keymap.clone(); + let editor_keymap = editor_keymap.clone(); + let editor = editor.clone(); + let panels = panels.clone(); let entity = entity.clone(); move |event, window, cx| { + // Сначала всегда срабатывают глобальные привязки. let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, true); + // Затем — привязки редактора или блока, на котором фокус. + let focused = run_focused_keymap( + &editor, + &editor_keymap, + &panels, + &event.keystroke, + true, + window, + cx, + ); // Обработчик мог поставить команду окна или изменить документы. apply_window_commands(window); - if handled { + if handled || focused { entity.update(cx, |_, cx| cx.notify()); } } }) .on_key_up({ let keymap = keymap.clone(); + let editor_keymap = editor_keymap.clone(); + let editor = editor.clone(); + let panels = panels.clone(); let entity = entity.clone(); move |event, window, cx| { let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, false); + let focused = run_focused_keymap( + &editor, + &editor_keymap, + &panels, + &event.keystroke, + false, + window, + cx, + ); apply_window_commands(window); - if handled { + if handled || focused { entity.update(cx, |_, cx| cx.notify()); } } @@ -2050,6 +2149,153 @@ impl Render for MyApp { } } +/// Колонка иконок gutter. +/// +/// В отличие от обычного `div`, позиции иконок считаются в `prepaint`. К +/// этому моменту [`EditorState`] уже обновил раскладку и прокрутку для +/// текущего кадра, поэтому иконки не отстают от текста при скроллинге. +struct GutterElement { + editor: Entity, + markers: Vec<(usize, GutterMarker)>, + width: Pixels, + background: Hsla, + foreground: Hsla, +} + +/// Приготовленные к рисованию иконки: границы, svg-путь и цвет. +struct GutterPrepaint { + icons: Vec<(Bounds, SharedString, Hsla)>, +} + +impl IntoElement for GutterElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for GutterElement { + type RequestLayoutState = (); + type PrepaintState = GutterPrepaint; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.size.width = self.width.into(); + style.size.height = relative(1.).into(); + style.flex_shrink = 0.; + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let state = self.editor.read(cx); + // Свежая геометрия кадра: редактор кладёт её в свой prepaint, а этот + // элемент подготавливается уже после него. Строки текста начинаются + // от `input_bounds`, а не от верха колонки: у редактора есть + // внутренние отступы (padding). + let (line_height, scroll_y, text_top) = match state.prepaint_caret_geometry() { + Some(geometry) => ( + geometry.line_height(), + geometry.scroll_offset().y, + geometry.input_bounds().top(), + ), + // До первой раскладки высота строки неизвестна: рисовать иконки + // некуда, следующий кадр всё равно придёт с геометрией. + None => { + let Some(line_height) = state.line_height() else { + return GutterPrepaint { icons: Vec::new() }; + }; + ( + line_height, + state.scroll_offset().y, + state.input_bounds().top(), + ) + } + }; + let display_map = state.display_map(); + let line_count = display_map.buffer_line_count(); + + let icon_size = px(GUTTER_ICON_SIZE).min(line_height); + let mut icons = Vec::new(); + for (line, marker) in &self.markers { + // Иконка для несуществующей строки не рисуется: иначе редактор + // прижал бы её к последней строке документа. + if *line >= line_count { + continue; + } + // Номер строки буфера → номер видимой строки: свёрнутый код и + // перенос строк не сдвигают иконки. + let row = display_map.buffer_line_to_display_row(*line); + let y = text_top + crate::gutter::row_offset(row, line_height, scroll_y); + if y + line_height <= text_top || y >= bounds.bottom() { + continue; + } + let Some(icon) = icons::from_name(&marker.icon) else { + continue; + }; + let icon_bounds = Bounds::new( + point( + bounds.origin.x + (bounds.size.width - icon_size).half(), + y + (line_height - icon_size).half(), + ), + size(icon_size, icon_size), + ); + icons.push(( + icon_bounds, + icon.path(), + marker.color.unwrap_or(self.foreground), + )); + } + + GutterPrepaint { icons } + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + window.paint_quad(fill(bounds, self.background)); + for (icon_bounds, path, color) in &prepaint.icons { + let _ = window.paint_svg( + *icon_bounds, + path.clone(), + None, + TransformationMatrix::default(), + *color, + cx, + ); + } + } +} + /// Переводит диапазон LSP в байтовый диапазон текста. /// /// Пустой диапазон (например, «ожидалось `;`») расширяется на следующий @@ -2132,7 +2378,7 @@ fn contrast_foreground(background: Hsla) -> Hsla { /// Сохраняет активную вкладку, печатая ошибку в stderr. fn save_active_tab() { - if let Err(err) = AppApi::global().save() { + if let Err(err) = AppApi::global().editor().save() { eprintln!("Не удалось сохранить вкладку: {err}"); } } @@ -2140,19 +2386,20 @@ fn save_active_tab() { /// Выполняет встроенную команду приложения. fn run_app_command(command: AppCommand) { let app = AppApi::global(); + let editor = app.editor(); match command { AppCommand::Save => save_active_tab(), AppCommand::NextTab => { - app.next_tab(); + editor.next_tab(); } AppCommand::PreviousTab => { - app.previous_tab(); + editor.previous_tab(); } AppCommand::OpenTab => { - app.open_tab(); + editor.open_tab(); } AppCommand::CloseTab => { - app.close_tab(app.active_tab_index()); + editor.close_tab(editor.active_tab_index()); } AppCommand::ToggleLeftPanel => { app.left_panel().toggle(); @@ -2178,6 +2425,57 @@ fn dismisses_completion(keystroke: &Keystroke) -> bool { modified || matches!(keystroke.key.as_str(), "left" | "right" | "home" | "end") } +/// Выполняет привязки того объекта, на котором сейчас фокус. +/// +/// Глобальные привязки обрабатываются отдельно и всегда; здесь — только +/// дополнительный слой: редактор, если фокус в нём, либо блок панели, если +/// фокус на нём. Фокус в других элементах (панель инструментов, вкладки, +/// строка состояния) дополнительного слоя не добавляет. +fn run_focused_keymap( + editor: &Entity, + editor_keymap: &Rc>, + panels: &[PanelHandle; 3], + keystroke: &Keystroke, + pressed: bool, + window: &Window, + cx: &App, +) -> bool { + if editor + .read(cx) + .presentation() + .focus_handle() + .is_focused(window) + { + return run_keymap(editor_keymap, KeyContext::Global, keystroke, pressed); + } + + match focused_block_keymap(panels, window) { + Some(keymap) => run_keymap(&keymap, KeyContext::Global, keystroke, pressed), + None => false, + } +} + +/// Ищет карту клавиш блока, владеющего фокусом. +/// +/// Когда у панелей появятся другие виды блоков, каждый из них должен отдавать +/// свою [`KeyMap`] так же, как это делает дерево. +fn focused_block_keymap(panels: &[PanelHandle; 3], window: &Window) -> Option>> { + for panel in panels { + let panel = panel.borrow(); + for block in panel.blocks() { + let PanelBlock::Tree { tree, .. } = block; + let tree = tree.borrow(); + if tree + .existing_focus_handle() + .is_some_and(|focus| focus.is_focused(window)) + { + return Some(tree.keymap_handle()); + } + } + } + None +} + /// Вызывает обработчики одного слоя `KeyMap` для события клавиатуры. /// /// Возвращает `true`, если хотя бы одна привязка сработала. Заём карты diff --git a/src/constants.rs b/src/constants.rs index c042f9b..a3366de 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -22,6 +22,12 @@ pub const WINDOW_TITLE: &str = "qode"; /// Ширина одного уровня отступа в редакторе. pub const TAB_SIZE: usize = 4; +/// Ширина колонки с иконками слева от редактора (gutter). +pub const GUTTER_WIDTH: f32 = 24.0; + +/// Размер иконки в gutter. +pub const GUTTER_ICON_SIZE: f32 = 14.0; + /// Ширина правой панели при открытии. pub const RIGHT_PANEL_WIDTH: f32 = 240.0; diff --git a/src/editor.rs b/src/editor.rs index cc63031..25de5eb 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -7,6 +7,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; +use crate::gutter::GutterState; + /// Счётчик идентификаторов документов. static NEXT_ID: AtomicU64 = AtomicU64::new(1); @@ -126,15 +128,17 @@ pub struct Document { /// Имя иконки вкладки из каталога Lucide (см. `crate::icons`). /// /// `None` — показывается иконка по умолчанию. Назначается через - /// `AppApi::set_tab_icon`. + /// `EditorApi::set_tab_icon`. pub icon: Option, /// Язык подсветки синтаксиса. /// /// `None` — подсветка выключена (поведение по умолчанию). Включается - /// только явно через `AppApi::set_language`, например из Lua-сценария. + /// только явно через `EditorApi::set_language`, например из Lua-сценария. pub language: Option, /// Позиция курсора в байтах на момент ухода с вкладки. pub cursor: usize, + /// Иконки на поле редактора для этой вкладки. + pub gutter: GutterState, /// Содержимое на момент последнего «сохранения». saved_content: String, } @@ -150,6 +154,7 @@ impl Document { icon: None, language: None, cursor: 0, + gutter: GutterState::new(), } } diff --git a/src/gutter.rs b/src/gutter.rs new file mode 100644 index 0000000..170436d --- /dev/null +++ b/src/gutter.rs @@ -0,0 +1,121 @@ +//! Иконки на поле редактора (gutter). +//! +//! Gutter — узкая колонка слева от редактора. Сценарий ставит на строку +//! иконку через `app:set_gutter_icon`, а вид рисует её на той же высоте, что и +//! строка в редакторе, учитывая прокрутку. +//! +//! Состояние принадлежит документу: у каждой вкладки свои иконки, и при +//! переключении вкладок показываются иконки активного документа. Номера строк +//! нумеруются с нуля и не сдвигаются автоматически при правке текста: +//! сценарий сам решает, когда переставить иконку. + +use std::collections::BTreeMap; + +use gpui_kit::{Hsla, Pixels}; + +/// Иконка одной строки. +#[derive(Clone, Debug, PartialEq)] +pub struct GutterMarker { + /// Каноническое имя иконки Lucide. + pub icon: String, + /// Цвет иконки; `None` — цвет текста темы. + pub color: Option, +} + +/// Иконки gutter документа по номеру строки. +/// +/// Одна строка несёт не больше одной иконки: повторный вызов заменяет её. +/// `BTreeMap` держит строки отсортированными, поэтому вид рисует их сверху +/// вниз без дополнительной сортировки. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GutterState { + markers: BTreeMap, +} + +impl GutterState { + /// Создаёт пустое поле. + pub fn new() -> Self { + Self::default() + } + + /// Ставит иконку на строку `line`, заменяя прежнюю. + /// + /// Имя иконки проверяет API: здесь хранится уже каноническое имя. + pub fn set(&mut self, line: usize, icon: impl Into, color: Option) { + self.markers.insert( + line, + GutterMarker { + icon: icon.into(), + color, + }, + ); + } + + /// Убирает иконку со строки. + /// + /// Возвращает `false`, если на этой строке иконки не было. + pub fn remove(&mut self, line: usize) -> bool { + self.markers.remove(&line).is_some() + } + + /// Убирает все иконки. + pub fn clear(&mut self) { + self.markers.clear(); + } + + /// Иконка строки, если она есть. + pub fn get(&self, line: usize) -> Option<&GutterMarker> { + self.markers.get(&line) + } + + /// Иконки в порядке возрастания номеров строк. + pub fn iter(&self) -> impl Iterator { + self.markers.iter().map(|(line, marker)| (*line, marker)) + } +} + +/// Вертикальный отступ видимой строки `row` от верхнего края gutter. +/// +/// `scroll_y` — смещение прокрутки редактора (отрицательное при прокрутке +/// вниз), `line_height` — высота строки из раскладки редактора. `row` — не +/// номер строки буфера, а номер видимой строки: свёрнутые строки не занимают +/// места. +pub fn row_offset(row: usize, line_height: Pixels, scroll_y: Pixels) -> Pixels { + scroll_y + line_height * row as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui_kit::{hsla, px}; + + #[test] + fn markers_are_keyed_by_line_and_sorted() { + let mut gutter = GutterState::new(); + assert_eq!(gutter.iter().count(), 0); + + gutter.set(5, "file", None); + gutter.set(1, "play", Some(hsla(0.5, 1., 0.5, 1.))); + let lines: Vec = gutter.iter().map(|(line, _)| line).collect(); + assert_eq!(lines, [1, 5]); + assert_eq!( + gutter.get(1).map(|marker| marker.icon.as_str()), + Some("play") + ); + + assert!(gutter.remove(1)); + assert!(!gutter.remove(1)); + assert!(gutter.get(1).is_none()); + assert_eq!(gutter.iter().count(), 1); + + gutter.clear(); + assert_eq!(gutter.iter().count(), 0); + } + + #[test] + fn row_offset_follows_scroll_and_line_height() { + assert_eq!(row_offset(0, px(20.), px(0.)), px(0.)); + assert_eq!(row_offset(3, px(20.), px(-40.)), px(20.)); + assert_eq!(row_offset(1, px(18.), px(-9.)), px(9.)); + } +} diff --git a/src/keymap.rs b/src/keymap.rs index a01c824..0c66d44 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -12,6 +12,13 @@ //! автодополнения. Оба слоя проходят через одну [`KeyMap`], поэтому сценарий //! может переопределить и удалить любую встроенную привязку. //! +//! У каждого объекта со своим `map_key` (приложение, редактор, блок панели) +//! отдельная [`KeyMap`]. Кроме слоя привязка хранит строку режимов: пустая +//! строка — все режимы, `"-"` — только режим по умолчанию, `"-iv"` — режим по +//! умолчанию, `i` и `v`. Текущий режим карты — один символ, по умолчанию `-`. +//! Режимы динамические: [`KeyMap::set_mode`] просто меняет метку, а +//! [`KeyMap::handlers_for`] отбирает подходящие привязки. +//! //! Клавиша сравнивается с `Keystroke.key` из GPUI. Это не результат ввода, а //! имя клавиши, которое платформа уже нормализует: //! @@ -348,38 +355,97 @@ pub enum KeyHandler { Completion(CompletionCommand), } +/// Режим по умолчанию. Тире — такой же идентификатор режима, как и +/// остальные однобуквенные режимы (`n`, `i`, `v` и т. п.). +pub const DEFAULT_MODE: char = '-'; + /// Привязка вместе с обработчиком. #[derive(Clone)] struct Binding { context: KeyContext, binding: KeyBinding, + /// Режимы, в которых действует привязка. Пустая строка — все режимы. + modes: String, handler: KeyHandler, } -/// Все назначенные привязки клавиш. -#[derive(Default)] +impl Binding { + /// Действует ли привязка в режиме `mode`. + fn accepts(&self, mode: char) -> bool { + self.modes.is_empty() || self.modes.contains(mode) + } +} + +/// Все назначенные привязки клавиш и текущий режим объекта. +/// +/// У каждого объекта со своим `map_key` (приложение, редактор, блок панели) +/// отдельная карта: режимы одного объекта не влияют на другой. pub struct KeyMap { bindings: Vec, + mode: char, +} + +impl Default for KeyMap { + fn default() -> Self { + Self { + bindings: Vec::new(), + mode: DEFAULT_MODE, + } + } } impl KeyMap { /// Назначает или заменяет обработчик в указанном слое. - pub fn set(&mut self, context: KeyContext, binding: KeyBinding, handler: KeyHandler) { + /// + /// `modes` — строка идентификаторов режимов, в которых работает + /// привязка. Пустая строка означает все режимы, `"-"` — только режим по + /// умолчанию, `"-iv"` — режим по умолчанию и режимы `i`, `v`. + pub fn set( + &mut self, + context: KeyContext, + modes: impl Into, + binding: KeyBinding, + handler: KeyHandler, + ) { + let modes = modes.into(); if let Some(existing) = self .bindings .iter_mut() .find(|existing| existing.context == context && existing.binding == binding) { + existing.modes = modes; existing.handler = handler; } else { self.bindings.push(Binding { context, binding, + modes, handler, }); } } + /// Переключает карту в режим `mode`. + /// + /// Режимы динамические: заранее их создавать не нужно. Идентификатор — + /// ровно один символ; тире означает режим по умолчанию. + pub fn set_mode(&mut self, mode: &str) -> Result<(), String> { + let mode = mode.trim(); + let mut chars = mode.chars(); + match (chars.next(), chars.next()) { + (Some(mode), None) => { + self.mode = mode; + Ok(()) + } + _ => Err(format!("режим должен быть одним символом: {mode:?}")), + } + } + + /// Текущий режим карты. + pub fn mode(&self) -> char { + self.mode + } + /// Удаляет привязку из указанного слоя. /// /// Возвращает `true`, если привязка была. @@ -404,7 +470,9 @@ impl KeyMap { self.bindings .iter() .filter(|binding| { - binding.context == context && binding.binding.matches(keystroke, pressed) + binding.context == context + && binding.accepts(self.mode) + && binding.binding.matches(keystroke, pressed) }) .map(|binding| binding.handler.clone()) .collect() @@ -418,6 +486,7 @@ impl KeyMap { for spec in ["", ""] { self.set( KeyContext::Global, + "", parse_default(spec), KeyHandler::App(AppCommand::Save), ); @@ -425,6 +494,7 @@ impl KeyMap { for spec in ["", ""] { self.set( KeyContext::Completion, + "", parse_default(spec), KeyHandler::Completion(CompletionCommand::Trigger), ); @@ -445,6 +515,7 @@ impl KeyMap { ] { self.set( KeyContext::Completion, + "", parse_default(spec), KeyHandler::Completion(command), ); @@ -718,4 +789,63 @@ mod tests { .is_empty() ); } + + #[test] + fn bindings_respect_the_current_mode() { + let mut keymap = KeyMap::default(); + let plain = gpui_kit::Modifiers::default(); + let bound = |keymap: &KeyMap, key: &str| { + !keymap + .handlers_for(KeyContext::Global, &stroke(key, plain), true) + .is_empty() + }; + + keymap.set( + KeyContext::Global, + "", + parse("a"), + KeyHandler::App(AppCommand::Save), + ); + keymap.set( + KeyContext::Global, + "-", + parse("b"), + KeyHandler::App(AppCommand::Save), + ); + keymap.set( + KeyContext::Global, + "iv", + parse("c"), + KeyHandler::App(AppCommand::Save), + ); + keymap.set( + KeyContext::Global, + "-iv", + parse("d"), + KeyHandler::App(AppCommand::Save), + ); + + // По умолчанию: все общие, только "-" и "-iv". + assert!(bound(&keymap, "a")); + assert!(bound(&keymap, "b")); + assert!(!bound(&keymap, "c")); + assert!(bound(&keymap, "d")); + + keymap.set_mode("i").expect("режим i"); + assert!(bound(&keymap, "a")); + assert!(!bound(&keymap, "b")); + assert!(bound(&keymap, "c")); + assert!(bound(&keymap, "d")); + + keymap.set_mode("n").expect("режим n"); + assert!(bound(&keymap, "a")); + assert!(!bound(&keymap, "b")); + assert!(!bound(&keymap, "c")); + assert!(!bound(&keymap, "d")); + + // Режим — ровно один символ. + assert!(keymap.set_mode("iv").is_err()); + assert!(keymap.set_mode("").is_err()); + assert_eq!(keymap.mode(), 'n'); + } } diff --git a/src/lsp.rs b/src/lsp.rs index 29c7e0e..2209bcd 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -10,7 +10,7 @@ //! * файловый вотчер, который пересылает серверу `workspace/didChangeWatchedFiles`. //! //! Всё остальное — какие серверы запускать, с какими аргументами и когда — -//! решает Lua-сценарий через [`AppApi::run_lsp`](crate::api::AppApi::run_lsp). +//! решает Lua-сценарий через [`EditorApi::run_lsp`](crate::api::EditorApi::run_lsp). //! //! Обмен с сервером идёт в фоновых потоках: поток-читатель разбирает ответы и //! складывает их в каналы конкретных запросов, а UI ждёт ответ в @@ -140,7 +140,7 @@ pub struct WatcherConfig { /// Описание запускаемого LSP-сервера. /// -/// Создаётся из Lua-таблицы в [`AppApi::run_lsp`](crate::api::AppApi::run_lsp). +/// Создаётся из Lua-таблицы в [`EditorApi::run_lsp`](crate::api::EditorApi::run_lsp). #[derive(Clone, Debug)] pub struct LspConfig { /// Условное имя, по которому сервер останавливают и перезапускают. diff --git a/src/main.rs b/src/main.rs index 15e04da..25175f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod app; mod completion; mod constants; mod editor; +mod gutter; mod icons; mod keymap; mod lsp; diff --git a/src/script.rs b/src/script.rs index e80145e..8f65362 100644 --- a/src/script.rs +++ b/src/script.rs @@ -3,8 +3,9 @@ //! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`script_dir`] //! (см. [`ScriptEngine::run_startup`]). Скриптам доступны: //! -//! * объект `app` — управление окном и редактором (методы [`AppApi`], вызываются -//! через двоеточие: `app:next_tab()`); +//! * объект `app` — управление окном и панелями (методы [`AppApi`], вызываются +//! через двоеточие: `app:next_tab()`); во вложенном `app.editor` живёт API +//! редактора ([`EditorApi`](crate::api::EditorApi)): `app.editor:save()`; //! * функция `include(path)` — выполняет другой Lua-файл относительно [`script_dir`] //! (или по абсолютному пути) в общем глобальном окружении; //! * стандартный `require`, который также ищет модули в [`script_dir`]. diff --git a/src/tests.rs b/src/tests.rs index 809c25e..a3e05d1 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -36,12 +36,12 @@ fn lua_lsp_api_lists_no_servers_and_validates_config() { std::fs::write( &script, r#" - assert(#app:lsp_servers() == 0) - assert(app:stop_lsp("missing") == false) - assert(app:restart_lsp("missing") == false) + assert(#app.editor:lsp_servers() == 0) + assert(app.editor:stop_lsp("missing") == false) + assert(app.editor:restart_lsp("missing") == false) -- Без cmd сервер не запускается, а ошибка объясняет, чего не хватает. - local ok, err = pcall(function() app:run_lsp({ name = "broken" }) end) + local ok, err = pcall(function() app.editor:run_lsp({ name = "broken" }) end) assert(not ok) assert(tostring(err):find("cmd", 1, true), tostring(err)) "#, @@ -127,7 +127,7 @@ fn api_lists_tabs_and_closes_them_by_number() { let workspace = Rc::new(RefCell::new(Workspace::default())); let (api, _keymap) = install_api(workspace.clone()); - let tabs = api.tabs(); + let tabs = api.editor().tabs(); assert_eq!( tabs.iter() .map(|tab| (tab.index, tab.title.as_str())) @@ -136,13 +136,13 @@ fn api_lists_tabs_and_closes_them_by_number() { ); // Несуществующий номер ничего не закрывает. - assert!(!api.close_tab(99)); - assert_eq!(api.tabs().len(), 1); + assert!(!api.editor().close_tab(99)); + assert_eq!(api.editor().tabs().len(), 1); - api.open_tab(); - assert_eq!(api.tabs().len(), 2); - assert!(api.close_tab(1)); - assert_eq!(api.tabs().len(), 1); + api.editor().open_tab(); + assert_eq!(api.editor().tabs().len(), 2); + assert!(api.editor().close_tab(1)); + assert_eq!(api.editor().tabs().len(), 1); assert_eq!(workspace.borrow().documents().len(), 1); } @@ -152,26 +152,26 @@ fn api_sets_and_clears_tab_icons() { let (api, _keymap) = install_api(workspace); // По умолчанию у всех вкладок иконка file. - assert_eq!(api.tabs()[0].icon, "file"); - assert_eq!(api.tab_icon(0).as_deref(), Some("file")); + assert_eq!(api.editor().tabs()[0].icon, "file"); + assert_eq!(api.editor().tab_icon(0).as_deref(), Some("file")); - assert!(api.set_tab_icon(0, "file-code")); - assert_eq!(api.tabs()[0].icon, "file-code"); - assert_eq!(api.tab_icon(0).as_deref(), Some("file-code")); + assert!(api.editor().set_tab_icon(0, "file-code")); + assert_eq!(api.editor().tabs()[0].icon, "file-code"); + assert_eq!(api.editor().tab_icon(0).as_deref(), Some("file-code")); // Имя нормализуется, а вкладка возвращает канонический вариант. - assert!(api.set_tab_icon(0, "icons/book-open.svg")); - assert_eq!(api.tab_icon(0).as_deref(), Some("book-open")); + assert!(api.editor().set_tab_icon(0, "icons/book-open.svg")); + assert_eq!(api.editor().tab_icon(0).as_deref(), Some("book-open")); // Неизвестная иконка и несуществующая вкладка ничего не меняют. - assert!(!api.set_tab_icon(0, "definitely-not-an-icon")); - assert_eq!(api.tab_icon(0).as_deref(), Some("book-open")); - assert!(!api.set_tab_icon(99, "file")); + assert!(!api.editor().set_tab_icon(0, "definitely-not-an-icon")); + assert_eq!(api.editor().tab_icon(0).as_deref(), Some("book-open")); + assert!(!api.editor().set_tab_icon(99, "file")); - assert!(api.clear_tab_icon(0)); - assert_eq!(api.tab_icon(0).as_deref(), Some("file")); - assert!(!api.clear_tab_icon(99)); - assert_eq!(api.tab_icon(99), None); + assert!(api.editor().clear_tab_icon(0)); + assert_eq!(api.editor().tab_icon(0).as_deref(), Some("file")); + assert!(!api.editor().clear_tab_icon(99)); + assert_eq!(api.editor().tab_icon(99), None); } #[test] @@ -184,14 +184,14 @@ fn lua_script_can_set_tab_icons() { std::fs::write( &script, r#" - assert(app:tab_icon(0) == "file") - assert(app:set_tab_icon(0, "book-open")) - assert(app:tab_icon(0) == "book-open") - app:open_tab() - assert(app:tabs()[2].icon == "file") - assert(not app:set_tab_icon(0, "no-such-icon")) - assert(app:clear_tab_icon(0)) - assert(app:tab_icon(0) == "file") + assert(app.editor:tab_icon(0) == "file") + assert(app.editor:set_tab_icon(0, "book-open")) + assert(app.editor:tab_icon(0) == "book-open") + app.editor:open_tab() + assert(app.editor:tabs()[2].icon == "file") + assert(not app.editor:set_tab_icon(0, "no-such-icon")) + assert(app.editor:clear_tab_icon(0)) + assert(app.editor:tab_icon(0) == "file") "#, ) .unwrap(); @@ -203,6 +203,87 @@ fn lua_script_can_set_tab_icons() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn api_sets_and_clears_gutter_icons_per_document() { + let workspace = Rc::new(RefCell::new(Workspace::default())); + let (api, _keymap) = install_api(workspace); + + assert_eq!(api.editor().gutter_icon(0), None); + assert!(api.editor().set_gutter_icon(2, "circle", None)); + assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("circle")); + assert_eq!(api.editor().gutter_color(2), None); + + // Неизвестная иконка и некорректный цвет ничего не меняют. + assert!( + !api.editor() + .set_gutter_icon(3, "definitely-not-an-icon", None) + ); + assert!( + !api.editor() + .set_gutter_icon(3, "circle", Some("not-a-color")) + ); + assert_eq!(api.editor().gutter_icon(3), None); + + assert!(api.editor().set_gutter_icon(2, "play", Some("#ff0000"))); + assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("play")); + assert_eq!(api.editor().gutter_color(2).as_deref(), Some("#ff0000")); + + // Иконки принадлежат вкладке, а не приложению. + api.editor().open_tab(); + assert_eq!(api.editor().gutter_icon(2), None); + assert!(api.editor().set_gutter_icon(0, "circle", None)); + api.editor().set_active_tab(0); + assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("play")); + + assert!(api.editor().clear_gutter_icon(2)); + assert!(!api.editor().clear_gutter_icon(2)); + assert_eq!(api.editor().gutter_icon(2), None); + + api.editor().clear_gutter(); + assert_eq!(api.editor().gutter_icon(0), None); +} + +#[test] +fn lua_script_can_set_gutter_icons() { + let workspace = Rc::new(RefCell::new(Workspace::default())); + let (api, _keymap) = install_api(workspace); + + let dir = temp_script_dir("gutter-icons"); + let script = dir.join("gutter.lua"); + std::fs::write( + &script, + r##" + assert(app.editor:gutter_icon(0) == nil) + assert(app.editor:set_gutter_icon(4, "circle")) + assert(app.editor:gutter_icon(4) == "circle") + assert(app.editor:gutter_color(4) == nil) + + assert(app.editor:set_gutter_icon(4, "play", "#00ff00")) + assert(app.editor:gutter_icon(4) == "play") + assert(app.editor:gutter_color(4) == "#00ff00") + + assert(not app.editor:set_gutter_icon(1, "no-such-icon")) + assert(not app.editor:set_gutter_icon(1, "circle", "oops")) + assert(app.editor:gutter_icon(1) == nil) + + assert(app.editor:clear_gutter_icon(4)) + assert(not app.editor:clear_gutter_icon(4)) + assert(app.editor:gutter_icon(4) == nil) + + app.editor:set_gutter_icon(0, "circle") + app.editor:clear_gutter() + assert(app.editor:gutter_icon(0) == nil) + "##, + ) + .unwrap(); + + let engine = ScriptEngine::new(api).expect("движок Lua"); + engine.run_file(&script).expect("скрипт gutter"); + + drop(engine); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn api_save_writes_active_tab_to_file_named_after_it() { // Тест пишет во временный файл, а не в каталог проекта. @@ -223,7 +304,7 @@ fn api_save_writes_active_tab_to_file_named_after_it() { assert!(workspace.borrow().active().is_dirty()); let (api, _keymap) = install_api(workspace.clone()); - api.save().expect("сохранение должно удаться"); + api.editor().save().expect("сохранение должно удаться"); assert_eq!( std::fs::read_to_string(&path).expect("файл должен существовать"), @@ -239,19 +320,19 @@ fn api_switches_tabs_forward_and_back_in_a_cycle() { let workspace = Rc::new(RefCell::new(Workspace::default())); let (api, _keymap) = install_api(workspace.clone()); - api.open_tab(); - api.set_active_tab(0); - assert_eq!(api.active_tab_index(), 0); + api.editor().open_tab(); + api.editor().set_active_tab(0); + assert_eq!(api.editor().active_tab_index(), 0); - assert_eq!(api.next_tab(), 1); - assert_eq!(api.active_tab_index(), 1); + assert_eq!(api.editor().next_tab(), 1); + assert_eq!(api.editor().active_tab_index(), 1); // С последней вкладки следующая — первая. - assert_eq!(api.next_tab(), 0); + assert_eq!(api.editor().next_tab(), 0); // С первой вкладки предыдущая — последняя. - assert_eq!(api.previous_tab(), 1); - assert_eq!(api.previous_tab(), 0); + assert_eq!(api.editor().previous_tab(), 1); + assert_eq!(api.editor().previous_tab(), 0); assert_eq!(workspace.borrow().active_index(), 0); } @@ -572,7 +653,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() { std::fs::write( &script, r#" - app:on_lsp_diagnostics(0, function(servers) + app.editor:on_lsp_diagnostics(0, function(servers) assert(#servers == 1, #servers) local entry = servers[1] assert(entry.server == "test") @@ -589,7 +670,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() { assert(first.message == "плохо") assert(entry.diagnostics[2].severity_name == "warning") - app:open_tab() + app.editor:open_tab() end) "#, ) @@ -597,7 +678,10 @@ fn lua_diagnostics_handler_receives_every_server_and_file() { let engine = ScriptEngine::new(api.clone()).expect("движок Lua"); engine.run_file(&script).expect("регистрация обработчика"); - assert_eq!(api.diagnostics_delay(), Some(std::time::Duration::ZERO)); + assert_eq!( + api.editor().diagnostics_delay(), + Some(std::time::Duration::ZERO) + ); let uri = crate::lsp::path_to_uri(Path::new("/tmp/qode-diagnostics.go")); let diagnostics = vec![ @@ -623,7 +707,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() { assert_eq!(report[0].path.as_deref(), Some("/tmp/qode-diagnostics.go")); assert_eq!(report[0].diagnostics.len(), 2); - assert!(api.emit_diagnostics(report)); + assert!(api.editor().emit_diagnostics(report)); assert_eq!( workspace.borrow().documents().len(), 2, @@ -696,13 +780,13 @@ fn lua_script_can_include_files_and_control_the_app() { let main = dir.join("main.lua"); std::fs::write( &helper, - "function lua_config() app:set_active_text(\"from lua\") end\n", + "function lua_config() app.editor:set_active_text(\"from lua\") end\n", ) .expect("helper.lua"); std::fs::write( &main, format!( - "include({:?})\nlua_config()\napp:open_tab()\napp:set_language(1, \"python\")\n", + "include({:?})\nlua_config()\napp.editor:open_tab()\napp.editor:set_language(1, \"python\")\n", helper.to_string_lossy() ), ) @@ -720,12 +804,12 @@ fn lua_script_can_include_files_and_control_the_app() { assert_eq!( state.active_index(), 1, - "app:open_tab() не переключил вкладку" + "app.editor:open_tab() не переключил вкладку" ); assert_eq!( state.documents()[1].language, Some(Language::Python), - "app:set_language() не включил подсветку из Lua" + "app.editor:set_language() не включил подсветку из Lua" ); let _ = std::fs::remove_dir_all(&dir); @@ -740,7 +824,7 @@ fn api_opens_file_from_disk_and_detects_language() { let path = dir.join("main.go"); std::fs::write(&path, "package main\n").unwrap(); - let index = api.open(&path).expect("файл открыт"); + let index = api.editor().open(&path).expect("файл открыт"); { let state = workspace.borrow(); assert_eq!(index, state.active_index()); @@ -752,7 +836,7 @@ fn api_opens_file_from_disk_and_detects_language() { // Повторное открытие чистого файла переиспользует вкладку. let tabs = workspace.borrow().documents().len(); - assert_eq!(api.open(&path).unwrap(), index); + assert_eq!(api.editor().open(&path).unwrap(), index); assert_eq!(workspace.borrow().documents().len(), tabs); let _ = std::fs::remove_dir_all(&dir); @@ -770,8 +854,14 @@ fn lua_open_controls_focus_and_cursor() { // Без второго аргумента фокус остаётся прежним. let plain = dir.join("plain.lua"); - std::fs::write(&plain, format!("app:open({:?})\n", path.to_string_lossy())).unwrap(); - engine.run_file(&plain).expect("app:open без позиции"); + std::fs::write( + &plain, + format!("app.editor:open({:?})\n", path.to_string_lossy()), + ) + .unwrap(); + engine + .run_file(&plain) + .expect("app.editor:open без позиции"); assert_eq!( workspace.borrow_mut().take_focus_after_sync(), Some(FocusAfterSync::Keep) @@ -781,10 +871,12 @@ fn lua_open_controls_focus_and_cursor() { let focus = dir.join("focus.lua"); std::fs::write( &focus, - format!("app:open({:?}, {{}})\n", path.to_string_lossy()), + format!("app.editor:open({:?}, {{}})\n", path.to_string_lossy()), ) .unwrap(); - engine.run_file(&focus).expect("app:open с пустой таблицей"); + engine + .run_file(&focus) + .expect("app.editor:open с пустой таблицей"); assert_eq!( workspace.borrow_mut().take_focus_after_sync(), Some(FocusAfterSync::Editor(None)) @@ -794,10 +886,10 @@ fn lua_open_controls_focus_and_cursor() { let at = dir.join("at.lua"); std::fs::write( &at, - format!("app:open({:?}, {{2, 5}})\n", path.to_string_lossy()), + format!("app.editor:open({:?}, {{2, 5}})\n", path.to_string_lossy()), ) .unwrap(); - engine.run_file(&at).expect("app:open с позицией"); + engine.run_file(&at).expect("app.editor:open с позицией"); assert_eq!( workspace.borrow_mut().take_focus_after_sync(), Some(FocusAfterSync::Editor(Some(19))) @@ -815,7 +907,7 @@ fn api_open_missing_file_creates_an_empty_buffer() { let dir = temp_script_dir("open-missing"); let path = dir.join("new.go"); - let index = api.open(&path).expect("новый буфер"); + let index = api.editor().open(&path).expect("новый буфер"); let state = workspace.borrow(); assert_eq!(index, state.active_index()); assert_eq!(state.active().name, path.to_string_lossy()); @@ -839,14 +931,14 @@ fn lua_app_open_opens_a_file_and_returns_its_tab() { std::fs::write( &script, format!( - "local index = app:open({:?})\nassert(index == app:active_tab())\n", + "local index = app.editor:open({:?})\nassert(index == app.editor:active_tab())\n", path.to_string_lossy() ), ) .unwrap(); let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua"); - engine.run_file(&script).expect("app:open из Lua"); + engine.run_file(&script).expect("app.editor:open из Lua"); let state = workspace.borrow(); assert_eq!(state.active().name, path.to_string_lossy()); @@ -866,7 +958,7 @@ fn lua_engine_loads_init_from_a_custom_config_dir() { let dir = temp_script_dir("config-dir"); std::fs::write( dir.join("helper.lua"), - "function config_value() app:set_active_text(\"custom config\") end\n", + "function config_value() app.editor:set_active_text(\"custom config\") end\n", ) .expect("helper.lua"); std::fs::write( @@ -895,11 +987,11 @@ fn lua_key_bindings_match_press_release_and_modifiers() { std::fs::write( &script, r#" - app:map_key("a", function() app:set_active_text("a pressed") end) - app:map_key("!1", function() app:set_active_text("1 released") end) - app:map_key("", function() app:set_active_text("shift c") end) - app:map_key("", function() app:set_active_text("ctrl z") end) - app:map_key("", function() app:set_active_text("alt r released") end) + app:map_key("", "a", function() app.editor:set_active_text("a pressed") end) + app:map_key("", "!1", function() app.editor:set_active_text("1 released") end) + app:map_key("", "", function() app.editor:set_active_text("shift c") end) + app:map_key("", "", function() app.editor:set_active_text("ctrl z") end) + app:map_key("", "", function() app.editor:set_active_text("alt r released") end) "#, ) .expect("keys.lua"); @@ -997,11 +1089,11 @@ fn lua_keymap_accepts_command_names_and_nil() { std::fs::write( &script, r#" - app:map_key("", "next_tab") + app:map_key("", "", "next_tab") app:map_completion_key("", "completion_next") local ok, err = pcall(function() - app:map_key("", "no_such_command") + app:map_key("", "", "no_such_command") end) assert(not ok) assert(tostring(err):find("доступны", 1, true), tostring(err)) @@ -1042,7 +1134,7 @@ fn lua_keymap_accepts_command_names_and_nil() { // nil удаляет привязку, не оставляя заглушку. std::fs::write( &script, - "app:map_key(\"\", nil)\napp:map_completion_key(\"\", nil)\n", + "app:map_key(\"\", \"\", nil)\napp:map_completion_key(\"\", nil)\n", ) .expect("keys.lua"); engine.run_file(&script).expect("привязки должны удалиться"); @@ -1106,22 +1198,22 @@ fn highlighting_is_off_by_default_and_enabled_through_api() { let workspace = Rc::new(RefCell::new(Workspace::default())); let (api, _keymap) = install_api(workspace.clone()); - assert_eq!(api.language(0), None); + assert_eq!(api.editor().language(0), None); assert_eq!(workspace.borrow().documents()[0].language, None); - assert!(api.set_language(0, "rs")); - assert_eq!(api.language(0), Some("rust".to_owned())); + assert!(api.editor().set_language(0, "rs")); + assert_eq!(api.editor().language(0), Some("rust".to_owned())); assert_eq!( workspace.borrow().documents()[0].language, Some(Language::Rust) ); // Неизвестный язык и несуществующая вкладка. - assert!(!api.set_language(0, "cobol")); - assert!(!api.set_language(99, "rust")); + assert!(!api.editor().set_language(0, "cobol")); + assert!(!api.editor().set_language(99, "rust")); - assert!(api.clear_language(0)); - assert_eq!(api.language(0), None); + assert!(api.editor().clear_language(0)); + assert_eq!(api.editor().language(0), None); } #[test] @@ -1144,8 +1236,13 @@ fn keymap_bindings_are_parsed_and_replaced() { .load("return function() end") .eval::() .unwrap(); - keymap.set(KeyContext::Global, plain.clone(), KeyHandler::Lua(first)); - keymap.set(KeyContext::Global, plain, KeyHandler::Lua(second)); + keymap.set( + KeyContext::Global, + "", + plain.clone(), + KeyHandler::Lua(first), + ); + keymap.set(KeyContext::Global, "", plain, KeyHandler::Lua(second)); let stroke = Keystroke { modifiers: Modifiers::default(), @@ -1175,7 +1272,7 @@ fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext let script = dir.join("keys.lua"); std::fs::write( &script, - r#"app:map_key("", function() app:set_active_text("alt r pressed") end)"#, + r#"app:map_key("", "", function() app.editor:set_active_text("alt r pressed") end)"#, ) .expect("keys.lua"); let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua"); @@ -1199,6 +1296,178 @@ fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext let _ = std::fs::remove_dir_all(&dir); } +#[gpui_kit::test] +fn editor_and_block_keymaps_depend_on_focus(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + + let (app, visual) = cx.add_window_view(MyApp::new); + + let dir = temp_script_dir("focus-keys"); + let script = dir.join("keys.lua"); + std::fs::write( + &script, + r#" + app:map_key("", "", function() app.editor:set_gutter_icon(0, "circle") end) + app.editor:map_key("", "", function() app.editor:set_gutter_icon(0, "play") end) + + local tree = app.left_panel:add_tree() + tree:append({ { id = "a", label = "a" } }, "") + tree:map_key("", "", function() app.editor:set_gutter_icon(0, "file-code") end) + app.left_panel:open() + "#, + ) + .expect("keys.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); + + // Фокус в редакторе: работают глобальные и редакторские привязки. + 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-r"); + visual.run_until_parked(); + assert_eq!(icon().as_deref(), Some("play")); + + // Привязка блока при фокусе в редакторе не срабатывает. + visual.simulate_keystrokes("alt-t"); + visual.run_until_parked(); + assert_eq!(icon().as_deref(), Some("play")); + + visual.simulate_keystrokes("alt-g"); + visual.run_until_parked(); + assert_eq!(icon().as_deref(), Some("circle")); + + // Фокус на дереве: работают глобальные и привязки блока, а не редактора. + tree.borrow_mut().focus("a"); + visual.update(|_window, cx| { + app.update(cx, |_, cx| cx.notify()); + }); + visual.run_until_parked(); + + visual.simulate_keystrokes("alt-t"); + visual.run_until_parked(); + assert_eq!(icon().as_deref(), Some("file-code")); + + visual.simulate_keystrokes("alt-r"); + visual.run_until_parked(); + assert_eq!(icon().as_deref(), Some("file-code")); + + visual.simulate_keystrokes("alt-g"); + visual.run_until_parked(); + assert_eq!(icon().as_deref(), Some("circle")); + + drop(engine); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn lua_key_modes_are_per_object_and_dynamic() { + let workspace = Rc::new(RefCell::new(Workspace::default())); + let (api, keymap) = install_api(workspace.clone()); + + let dir = temp_script_dir("key-modes"); + let script = dir.join("modes.lua"); + std::fs::write( + &script, + r#" + assert(app:mode() == "-") + app:set_mode("i") + assert(app:mode() == "i") + app:map_key("-", "", function() app.editor:set_active_text("default") end) + app:map_key("i", "", function() app.editor:set_active_text("insert") end) + app:map_key("", "", function() app.editor:set_active_text("all") end) + + assert(app.editor:mode() == "-") + app.editor:set_mode("n") + assert(app.editor:mode() == "n") + app.editor:map_key("n", "", function() app.editor:set_active_text("editor n") end) + app.editor:map_key("i", "", function() app.editor:set_active_text("editor i") end) + + local tree = app.left_panel:add_tree() + assert(tree:mode() == "-") + tree:set_mode("v") + assert(tree:mode() == "v") + tree:map_key("v", "", function() app.editor:set_active_text("tree v") end) + "#, + ) + .expect("modes.lua"); + let engine = ScriptEngine::new(api.clone()).expect("движок Lua"); + engine.run_file(&script).expect("режимы"); + + let tree_keymap = { + let panel = api.left_panel_handle(); + let panel = panel.borrow(); + let PanelBlock::Tree { tree, .. } = &panel.blocks()[0]; + tree.borrow().keymap_handle() + }; + let editor_keymap = api.editor_keymap_handle(); + + let alt = Modifiers { + alt: true, + ..Default::default() + }; + let fire = |keymap: &Rc>, key: &str| { + let stroke = Keystroke { + modifiers: alt, + key: key.to_owned(), + key_char: None, + }; + for handler in keymap + .borrow() + .handlers_for(KeyContext::Global, &stroke, true) + { + if let KeyHandler::Lua(callback) = handler { + callback.call::<()>(()).expect("Lua-обработчик"); + } + } + }; + let text = || workspace.borrow().active().content.clone(); + + // Глобальная карта в режиме i: "-" не действует, "i" и "" — действуют. + fire(&keymap, "a"); + assert_eq!(text(), ""); + fire(&keymap, "b"); + assert_eq!(text(), "insert"); + fire(&keymap, "c"); + assert_eq!(text(), "all"); + + // Смена режима не пересоздаёт привязки. + keymap.borrow_mut().set_mode("n").unwrap(); + fire(&keymap, "b"); + assert_eq!(text(), "all"); + // Привязка "-" действует только в режиме по умолчанию. + fire(&keymap, "a"); + assert_eq!(text(), "all"); + keymap.borrow_mut().set_mode("-").unwrap(); + fire(&keymap, "a"); + assert_eq!(text(), "default"); + + // У редактора и блока свои режимы и карты. + fire(&editor_keymap, "d"); + assert_eq!(text(), "editor n"); + fire(&editor_keymap, "e"); + assert_eq!(text(), "editor n"); + + fire(&tree_keymap, "f"); + assert_eq!(text(), "tree v"); + + drop(engine); + let _ = std::fs::remove_dir_all(&dir); +} + #[gpui_kit::test] fn opened_panels_render_without_panicking(cx: &mut TestAppContext) { cx.update(gpui_kit::init); @@ -1265,6 +1534,35 @@ fn toolbar_items_render_from_api(cx: &mut TestAppContext) { assert_eq!(toolbar.borrow().right().len(), 1); } +#[gpui_kit::test] +fn gutter_icons_render_from_api(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + + let (app, visual) = cx.add_window_view(MyApp::new); + + let api = AppApi::global(); + assert!(api.editor().set_gutter_icon(0, "circle", Some("#ff0000"))); + assert!(api.editor().set_gutter_icon(5, "play", None)); + + visual.update(|_window, cx| { + app.update(cx, |_, cx| cx.notify()); + }); + visual.run_until_parked(); + + assert_eq!( + AppApi::global().editor().gutter_icon(0).as_deref(), + Some("circle") + ); + assert_eq!( + AppApi::global().editor().gutter_color(0).as_deref(), + Some("#ff0000") + ); + assert_eq!( + AppApi::global().editor().gutter_icon(5).as_deref(), + Some("play") + ); +} + #[gpui_kit::test] fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) { cx.update(gpui_kit::init); diff --git a/src/tree.rs b/src/tree.rs index 282d4d9..3c0f378 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -18,6 +18,8 @@ use mlua::Function; use gpui_kit::{App, FocusHandle, ScrollHandle}; +use crate::keymap::KeyMap; + /// Общая ссылка на состояние блока дерева. pub type TreeHandle = Rc>; @@ -107,6 +109,8 @@ pub struct TreeState { focus_requested: bool, /// Прокрутка списка: выбранная строка всегда прокручивается в видимую часть. scroll: ScrollHandle, + /// Привязки клавиш блока и его текущий режим. + keymap: Rc>, } impl TreeState { @@ -156,6 +160,20 @@ impl TreeState { .clone() } + /// Уже созданный фокус-обработчик, если дерево хотя бы раз рисовалось. + /// + /// В отличие от [`Self::focus_handle`] ничего не создаёт: вид проверяет + /// по нему, владеет ли блок клавиатурным фокусом прямо сейчас. + pub fn existing_focus_handle(&self) -> Option { + self.focus_handle.clone() + } + + /// Карта клавиш блока: `tree:map_key`, `tree:set_mode` и отрисовка вида + /// работают с одной и той же картой. + pub fn keymap_handle(&self) -> Rc> { + self.keymap.clone() + } + /// Забирает отложенный запрос фокуса. pub fn take_focus_request(&mut self) -> bool { std::mem::take(&mut self.focus_requested)