gutter and keys

This commit is contained in:
2026-10-31 11:20:28 +03:00
parent afd7600150
commit 16e88117d5
14 changed files with 1844 additions and 558 deletions
+24 -12
View File
@@ -11,7 +11,7 @@
| `src/main.rs` | Точка входа, окно, аргументы командной строки | | `src/main.rs` | Точка входа, окно, аргументы командной строки |
| `src/constants.rs` | Константы окна, CLI и каталог сценариев | | `src/constants.rs` | Константы окна, CLI и каталог сценариев |
| `src/editor.rs` | Доменная модель: документы, вкладки, языки | | `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/script.rs` | Lua-движок, `app`, `include`, `require` |
| `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики | | `src/keymap.rs` | `KeyBinding`, `KeyMap`, встроенные команды и Lua-обработчики |
| `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение | | `src/app.rs` | GPUI-вид, отрисовка, события, автодополнение |
@@ -19,6 +19,7 @@
| `src/panel.rs` | `PanelState` и блоки панелей | | `src/panel.rs` | `PanelState` и блоки панелей |
| `src/toolbar.rs` | Состояние верхней панели: лейблы и кнопки | | `src/toolbar.rs` | Состояние верхней панели: лейблы и кнопки |
| `src/tree.rs` | Абстрактное дерево для панелей | | `src/tree.rs` | Абстрактное дерево для панелей |
| `src/gutter.rs` | Иконки на полях слева от редактора |
| `src/icons.rs` | Каталог иконок Lucide | | `src/icons.rs` | Каталог иконок Lucide |
| `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер | | `src/lsp.rs` | Менеджер LSP-серверов, JSON-RPC, файловый вотчер |
| `DOCS.md`, `init.lua` | Пользовательская документация и пример конфигурации | | `DOCS.md`, `init.lua` | Пользовательская документация и пример конфигурации |
@@ -28,23 +29,29 @@
### 1. `AppApi` — центр приложения ### 1. `AppApi` — центр приложения
- Документы, вкладки, панели, дерево, окно, подсветка, LSP и привязки клавиш - Документы, вкладки, панели, дерево, окно, подсветка, LSP и привязки клавиш
меняются только через `AppApi` (или `PanelApi` / `TreeApi`). меняются только через `AppApi`, `EditorApi` или `PanelApi` / `TreeApi` /
- Состояние живёт в общих `Rc<RefCell<_>>` / `Arc`, чтобы UI, `AppApi` и Lua `ToolbarApi`.
- Всё, что относится к редактору (вкладки, текст, gutter, сохранение, LSP и
привязки редактора), живёт в `app.editor`; окно, панели и глобальные
привязки — в `app`.
- Состояние живёт в общих `Rc<RefCell<_>>` / `Arc`, чтобы UI, API и Lua
видели одни и те же объекты. Второй копии состояния быть не должно. видели одни и те же объекты. Второй копии состояния быть не должно.
- Команды окна складываются в очередь и применяются в начале кадра - Команды окна складываются в очередь и применяются в начале кадра
(`apply_window_commands`); UI и Lua не вызывают `Window` напрямую. (`apply_window_commands`); UI и Lua не вызывают `Window` напрямую.
- Если действие пользователя меняет состояние, оно выражается методом - Если действие пользователя меняет состояние, оно выражается методом
`AppApi`, а не логикой внутри обработчика UI. Отрисовка состояние только API, а не логикой внутри обработчика UI. Отрисовка состояние только
читает. читает.
- Исключение — системные кнопки окна внутри `gpui-kit::TitleBar`: их - Исключение — системные кнопки окна внутри `gpui-kit::TitleBar`: их
поведение принадлежит библиотеке. поведение принадлежит библиотеке.
### 2. Lua повторяет возможности API ### 2. Lua повторяет возможности API
- Публичные методы `impl AppApi` с `#[mlua::userdata_impl]` доступны как - Публичные методы `impl AppApi` / `impl EditorApi` с `#[mlua::userdata_impl]`
`app:method(...)`. Добавляя возможность, сразу добавляйте и Lua-метод. доступны как `app:method(...)` / `app.editor:method(...)`. Добавляя
возможность, сразу добавляйте и Lua-метод.
- Имена — `snake_case`; вкладки и индексы нумеруются с нуля; панели — - Имена — `snake_case`; вкладки и индексы нумеруются с нуля; панели —
`app.left_panel`, `app.right_panel`, `app.bottom_panel`. `app.left_panel`, `app.right_panel`, `app.bottom_panel`; блоки панелей
получают свои объекты (`tree`).
- Для методов, которые не должны попадать в Lua, используется `#[lua(skip)]`. - Для методов, которые не должны попадать в Lua, используется `#[lua(skip)]`.
Для generic-методов добавляется тонкая не-generic Lua-обёртка. Для generic-методов добавляется тонкая не-generic Lua-обёртка.
- `DOCS.md` и комментарии в `init.lua` обновляются в том же изменении, что и - `DOCS.md` и комментарии в `init.lua` обновляются в том же изменении, что и
@@ -55,12 +62,17 @@
- Встроенные привязки (`Ctrl/Cmd+S`, `Ctrl/Cmd+Space`, навигация по списку - Встроенные привязки (`Ctrl/Cmd+S`, `Ctrl/Cmd+Space`, навигация по списку
автодополнения) регистрируются в `KeyMap` и могут быть заменены или удалены автодополнения) регистрируются в `KeyMap` и могут быть заменены или удалены
из Lua через `app:map_key` / `app:map_completion_key`. из Lua через `app:map_key` / `app.editor:map_key` /
- `KeyMap` имеет два слоя: глобальный и `completion`. Обработчик может быть `app:map_completion_key`.
Lua-функцией или встроенной командой. - У приложения, редактора и каждого блока панели своя `KeyMap` и свой текущий
режим (один символ; `-` — режим по умолчанию). Привязка хранит строку
режимов, в которых она действует: `""` — все, `"-iv"` — по умолчанию и
`i`/`v`.
- Глобальные привязки обрабатываются всегда; затем, если фокус в редакторе,
дополнительно привязки редактора, а если фокус на блоке панели — привязки
этого блока. Обработчик может быть Lua-функцией или встроенной командой.
- Перехватчик клавиш выполняется раньше действий редактора и используется - Перехватчик клавиш выполняется раньше действий редактора и используется
только для слоя автодополнения; глобальные привязки обрабатываются на корне только для слоя автодополнения. Этот порядок менять нельзя.
вида. Этот порядок менять нельзя.
- Текстовый ввод редактора — не привязка клавиши. - Текстовый ввод редактора — не привязка клавиши.
### 4. Модель отделена от GPUI ### 4. Модель отделена от GPUI
+152 -64
View File
@@ -1,8 +1,9 @@
# Lua API # Lua API
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект
`app` для управления окном, вкладками, панелями, файловой системой, подсветкой `app` для управления окном, панелями и глобальными привязками клавиш.
и LSP-серверами. Вкладки, текст, gutter, подсветка, сохранение и LSP-серверы живут в `app.editor`,
а блоки панелей (деревья) — в своих объектах.
Сам редактор не содержит демонстрационных документов: при запуске открыта Сам редактор не содержит демонстрационных документов: при запуске открыта
одна пустая вкладка `untitled-1.txt`. Всё остальное — файлы, деревья, серверы — одна пустая вкладка `untitled-1.txt`. Всё остальное — файлы, деревья, серверы —
@@ -21,36 +22,46 @@ cargo run -- main.go
- В каталоге `--config` должны лежать `init.lua` и подключаемые им модули. - В каталоге `--config` должны лежать `init.lua` и подключаемые им модули.
- Первый позиционный аргумент открывается после выполнения `init.lua` - Первый позиционный аргумент открывается после выполнения `init.lua`
через `app:open(path)` — как `vim main.go` или `nano main.go`. через `app.editor:open(path)` — как `vim main.go` или `nano main.go`.
- `--config` меняет только каталог сценариев. Рабочий каталог процесса, а - `--config` меняет только каталог сценариев. Рабочий каталог процесса, а
вместе с ним пути `app:open`, `app:save` и `root` у LSP не меняются. вместе с ним пути `app.editor:open`, `app.editor:save` и `root` у LSP не меняются.
## Объект `app` ## Объект `app`
Объект `app` управляет окном и панелями. Всё, что относится к самому
редактору — вкладки, текст, gutter, сохранение, LSP и привязки клавиш
редактора, — живёт в `app.editor`:
```lua
app.editor:open("src/main.rs")
app.editor:save()
app.editor:map_key("n", "<C-s>", "save")
```
Номера вкладок во всех методах нумеруются с нуля. Массив, который возвращает Номера вкладок во всех методах нумеруются с нуля. Массив, который возвращает
`app:tabs()`, остаётся обычной Lua-таблицей с индексами от 1, поэтому поле `app.editor:tabs()`, остаётся обычной Lua-таблицей с индексами от 1, поэтому поле
`index` — единственный надёжный способ сослаться на вкладку. `index` — единственный надёжный способ сослаться на вкладку.
### Вкладки ### Вкладки
| Метод | Описание | | Метод | Описание |
| --- | --- | | --- | --- |
| `app:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs", icon = "file" }, ...}` | | `app.editor:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs", icon = "file" }, ...}` |
| `app:active_tab()` | Индекс активной вкладки | | `app.editor:active_tab()` | Индекс активной вкладки |
| `app:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует | | `app.editor:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует |
| `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс | | `app.editor:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
| `app:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс | | `app.editor:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс |
| `app:open_tab()` | Открыть пустую вкладку, вернуть индекс | | `app.editor:open_tab()` | Открыть пустую вкладку, вернуть индекс |
| `app:open(path, position)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем. Без второго аргумента фокус не меняется; `{}` — перевести фокус в редактор, оставив курсор; `{line, column}` — ещё и поставить курсор (с нуля) | | `app.editor:open(path, position)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем. Без второго аргумента фокус не меняется; `{}` — перевести фокус в редактор, оставив курсор; `{line, column}` — ещё и поставить курсор (с нуля) |
| `app:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта | | `app.editor:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта |
| `app:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна | | `app.editor:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна |
| `app:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) | | `app.editor:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) |
| `app:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет | | `app.editor:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет |
```lua ```lua
app:open("src/main.rs") -- открыть, не трогая фокус app.editor:open("src/main.rs") -- открыть, не трогая фокус
app:open("src/main.rs", {}) -- фокус в редактор, курсор на месте app.editor:open("src/main.rs", {}) -- фокус в редактор, курсор на месте
app:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор на строку 4, столбец 2 app.editor:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор на строку 4, столбец 2
``` ```
Строка и столбец нумеруются с нуля, как в диагностике LSP; столбец считается Строка и столбец нумеруются с нуля, как в диагностике LSP; столбец считается
@@ -63,21 +74,45 @@ app:open("src/main.rs", {4, 2}) -- фокус в редактор, курсор
Например: Например:
```lua ```lua
app:set_tab_icon(0, "file-code") app.editor:set_tab_icon(0, "file-code")
app:set_tab_icon(1, "book-open") app.editor:set_tab_icon(1, "book-open")
app:clear_tab_icon(1) -- снова "file" app.editor:clear_tab_icon(1) -- снова "file"
``` ```
Подчёркивания и пробелы считаются дефисом, а `icons/` и `.svg` можно Подчёркивания и пробелы считаются дефисом, а `icons/` и `.svg` можно
не писать: `file_code`, `file-code` и `icons/file-code.svg` равнозначны. не писать: `file_code`, `file-code` и `icons/file-code.svg` равнозначны.
Неизвестное имя не назначается — метод вернёт `false`. Неизвестное имя не назначается — метод вернёт `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.editor:active_text()` | Текст активной вкладки |
| `app:set_active_text(text)` | Заменить текст активной вкладки | | `app.editor:set_active_text(text)` | Заменить текст активной вкладки |
### Подсветка синтаксиса ### Подсветка синтаксиса
@@ -86,19 +121,19 @@ app:clear_tab_icon(1) -- снова "file"
| Метод | Описание | | Метод | Описание |
| --- | --- | | --- | --- |
| `app:set_language(index, "rust")` | Включить подсветку для вкладки; `false`, если вкладки нет или язык не поддерживается | | `app.editor:set_language(index, "rust")` | Включить подсветку для вкладки; `false`, если вкладки нет или язык не поддерживается |
| `app:language(index)` | Язык подсветки вкладки или `nil` | | `app.editor:language(index)` | Язык подсветки вкладки или `nil` |
| `app:clear_language(index)` | Выключить подсветку для вкладки | | `app.editor:clear_language(index)` | Выключить подсветку для вкладки |
```lua ```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` — путь элемента относительно `"."` означают сам текущий каталог. Поле `id` — путь элемента относительно
текущего каталога: его можно передать в `app:open(id)` и использовать как текущего каталога: его можно передать в `app.editor:open(id)` и использовать как
идентификатор узла дерева. Элементы уже отсортированы по имени без учёта идентификатор узла дерева. Элементы уже отсортированы по имени без учёта
регистра. регистра.
@@ -120,12 +155,24 @@ end
### Привязка клавиш ### Привязка клавиш
Все привязки проходят через один `KeyMap`. Встроенные сочетания можно заменить Привязки живут у трёх видов объектов: у приложения (`app`), у редактора
или удалить: вторым аргументом принимается Lua-функция, имя встроенной команды (`app.editor`) и у каждого блока панели (`tree`). Встроенные сочетания можно
или `nil`. заменить или удалить: обработчиком принимается Lua-функция, имя встроенной
команды или `nil`.
`app:map_key(keys, handler)` назначает привязку для обычного режима, ```lua
`app:map_completion_key(keys, handler)` — для списка автодополнения. 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: Запись клавиши в духе nvim:
@@ -143,7 +190,32 @@ end
и Windows буквы берутся по аппаратному keycode, поэтому `<C-q>` работает и Windows буквы берутся по аппаратному keycode, поэтому `<C-q>` работает
и на русской раскладке. и на русской раскладке.
Встроенные команды обычного режима: #### Режимы
У каждого объекта своя текущая режимная метка: один символ (`n`, `i`, `v`, `r`
или любой другой) либо `-` — режим по умолчанию. Режимы динамические: заранее
создавать их не нужно, достаточно переключить метку.
| Значение `modes` | Когда срабатывает |
| --- | --- |
| `""` | во всех режимах |
| `"-"` | только в режиме по умолчанию |
| `"niv"` | в режимах `n`, `i`, `v` |
| `"-iv"` | в режиме по умолчанию, а также `i` и `v` |
Текущий режим меняется методами `set_mode(mode)` и читается методом `mode()`.
Режимы у приложения, редактора и каждого блока независимы.
```lua
app:set_mode("n")
app.editor:set_mode("i")
app:map_key("n", "<C-n>", "next_tab") -- только в режиме n
app.editor:map_key("i", "<C-s>", "save") -- только в режиме i редактора
app.editor:map_key("", "<C-space>", function() end) -- в любом режиме
```
Встроенные команды:
| Имя | Действие | | Имя | Действие |
| --- | --- | | --- | --- |
@@ -169,25 +241,33 @@ end
При запуске уже назначены: При запуске уже назначены:
- `Ctrl/Cmd+S` → `save`; - `Ctrl/Cmd+S` → `save` во всех режимах;
- `Ctrl/Cmd+Space` → `completion_trigger`; - `Ctrl/Cmd+Space` → `completion_trigger`;
- `Enter`/`Tab`, `Esc`, `↑`/`↓`, `PageUp`/`PageDown`, `←`/`→`, `Home`/`End` - `Enter`/`Tab`, `Esc`, `↑`/`↓`, `PageUp`/`PageDown`, `←`/`→`, `Home`/`End`
→ команды слоя автодополнения. → команды слоя автодополнения.
```lua ```lua
-- Переопределить сохранение своей функцией. -- Переопределить сохранение своей функцией во всех режимах.
app:map_key("<C-s>", function() app:save() end) app:map_key("", "<C-s>", function() app.editor:save() end)
-- Убрать встроенную привязку. -- Убрать встроенную привязку.
app:map_key("<C-s>", nil) app:map_key("", "<C-s>", nil)
-- Назначить встроенную команду на другую клавишу. -- Назначить встроенную команду на другую клавишу.
app:map_key("<C-n>", "next_tab") app:map_key("", "<C-n>", "next_tab")
app:map_key("<C-b>", "toggle_left_panel") app:map_key("", "<C-b>", "toggle_left_panel")
-- Навигация по списку автодополнения на Ctrl+N / Ctrl+P. -- Навигация по списку автодополнения на Ctrl+N / Ctrl+P.
app:map_completion_key("<C-n>", "completion_next") app:map_completion_key("<C-n>", "completion_next")
app:map_completion_key("<C-p>", "completion_previous") app:map_completion_key("<C-p>", "completion_previous")
-- Привязка, которая срабатывает только при фокусе в редакторе.
app.editor:map_key("", "<A-r>", function()
app.editor:set_active_text("editor mode")
end)
-- Привязка блока дерева: работает, когда фокус на этом дереве.
tree:map_key("v", "<A-f>", function() print("tree v") end)
``` ```
Пока список открыт, его слой проверяется раньше действий редактора. Сочетания Пока список открыт, его слой проверяется раньше действий редактора. Сочетания
@@ -209,7 +289,7 @@ app:map_completion_key("<C-p>", "completion_previous")
Запуск серверов целиком настраивается Lua-сценарием. Запуск серверов целиком настраивается Lua-сценарием.
```lua ```lua
app:run_lsp({ app.editor:run_lsp({
name = "gopls", -- условное имя для stop/restart name = "gopls", -- условное имя для stop/restart
cmd = "gopls", -- путь к бинарнику или имя в PATH cmd = "gopls", -- путь к бинарнику или имя в PATH
args = { "serve" }, -- аргументы процесса args = { "serve" }, -- аргументы процесса
@@ -223,11 +303,11 @@ app:run_lsp({
| Метод | Описание | | Метод | Описание |
| --- | --- | | --- | --- |
| `app:run_lsp(config)` | Запустить сервер по описанию | | `app.editor:run_lsp(config)` | Запустить сервер по описанию |
| `app:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` | | `app.editor:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` |
| `app:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен | | `app.editor:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен |
| `app:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками | | `app.editor:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками |
| `app:on_lsp_diagnostics(delay_ms, fn)` | Обработчик диагностики всех серверов; `nil` вместо `fn` удаляет его | | `app.editor:on_lsp_diagnostics(delay_ms, fn)` | Обработчик диагностики всех серверов; `nil` вместо `fn` удаляет его |
`cmd` обязателен. Если `root` не задан или относителен, он отсчитывается от `cmd` обязателен. Если `root` не задан или относителен, он отсчитывается от
текущего рабочего каталога процесса. Поле `watcher` принимает строку, таблицу текущего рабочего каталога процесса. Поле `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` — пауза тишины: если сообщения приходят чаще, вызовы LSP-серверы. `delay_ms` — пауза тишины: если сообщения приходят чаще, вызовы
пропускаются, а обработчик получает последний снимок после того, как пропускаются, а обработчик получает последний снимок после того, как
обновления прекратились. Если зарегистрировать обработчик, когда диагностика обновления прекратились. Если зарегистрировать обработчик, когда диагностика
уже накоплена, он сработает сразу после этой паузы. уже накоплена, он сработает сразу после этой паузы.
```lua ```lua
app:on_lsp_diagnostics(1000, function(servers) app.editor:on_lsp_diagnostics(1000, function(servers)
for _, entry in ipairs(servers) do for _, entry in ipairs(servers) do
print(entry.server, entry.path, #entry.diagnostics) print(entry.server, entry.path, #entry.diagnostics)
end end
@@ -293,10 +373,10 @@ end)
Примеры запуска: Примеры запуска:
```lua ```lua
app:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" }, app.editor:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" },
languages = { "go" }, watcher = "**/*.go" }) 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" }) languages = { "rust" }, watcher = "**/*.rs" })
``` ```
@@ -304,7 +384,7 @@ app:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer",
```lua ```lua
local gopls_ok, gopls_err = pcall(function() local gopls_ok, gopls_err = pcall(function()
app:run_lsp({ app.editor:run_lsp({
name = "gopls", name = "gopls",
cmd = "gopls", cmd = "gopls",
args = { "serve" }, args = { "serve" },
@@ -409,6 +489,9 @@ local diagnostics = app.bottom_panel:add_tree("Диагностика")
| `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` | | `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` |
| `tree:selected()` | Идентификатор выбранной строки или `nil` | | `tree:selected()` | Идентификатор выбранной строки или `nil` |
| `tree:focus(id)` | Раскрыть всех родителей, выбрать узел и передать фокус дереву | | `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` раскрывает папку ребёнку, `←` сворачивает папку или выбирает родителя, `Enter` раскрывает папку
или вызывает `on_click` для конечной ноды. Выбор сохраняется, пока панель или вызывает `on_click` для конечной ноды. Выбор сохраняется, пока панель
закрыта; при повторном открытии панели фокус возвращается на выбранную строку. закрыта; при повторном открытии панели фокус возвращается на выбранную строку.
Привязки блока (`tree:map_key`) обрабатываются дополнительно к глобальным и
только пока фокус находится на этом дереве; у каждого блока свой режим.
Пример отложенной загрузки дерева: Пример отложенной загрузки дерева:
@@ -467,14 +552,14 @@ end)
tree:on_click(function(id) tree:on_click(function(id)
-- Фокус в редактор, курсор остаётся на прежнем месте. -- Фокус в редактор, курсор остаётся на прежнем месте.
app:open(id, {}) app.editor:open(id, {})
end) end)
app.left_panel:open() app.left_panel:open()
load_dir("") load_dir("")
-- При открытии панели вернуть фокус на последнюю выбранную строку. -- При открытии панели вернуть фокус на последнюю выбранную строку.
app:map_key("<C-b>", function() app:map_key("", "<C-b>", function()
if app.left_panel:toggle() then if app.left_panel:toggle() then
local selected = tree:selected() local selected = tree:selected()
if selected then if selected then
@@ -499,7 +584,7 @@ local icons = {
hint = "lightbulb", hint = "lightbulb",
} }
app:on_lsp_diagnostics(1000, function(servers) app.editor:on_lsp_diagnostics(1000, function(servers)
diagnostics:clear() diagnostics:clear()
local files = {} local files = {}
@@ -558,9 +643,9 @@ end)
diagnostics:on_click(function(id) diagnostics:on_click(function(id)
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$") local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
if path then if path then
app:open(path, { tonumber(line), tonumber(character) }) app.editor:open(path, { tonumber(line), tonumber(character) })
else else
app:open(id, {}) app.editor:open(id, {})
end end
end) end)
@@ -578,20 +663,23 @@ app.bottom_panel:open()
## Добавление новых методов ## Добавление новых методов
Методы добавляются в `src/api.rs`: всё, что есть в `impl AppApi`, помеченном Методы добавляются в `src/api.rs`: всё, что есть в `impl AppApi` или
`#[mlua::userdata_impl]`, автоматически доступно из Lua. `impl EditorApi`, помеченном `#[mlua::userdata_impl]`, автоматически доступно
из Lua как `app:method(...)` или `app.editor:method(...)`.
Чтобы не экспортировать метод, его помечают `#[lua(skip)]`. Чтобы не экспортировать метод, его помечают `#[lua(skip)]`.
Новый вид блока панели должен добавить методы в свой `*Api` и отдавать свою
`KeyMap`, как это делает `TreeApi`.
## Полный пример `init.lua` ## Полный пример `init.lua`
```lua ```lua
print("[qode] init.lua выполнен") print("[qode] init.lua выполнен")
app:map_key("<C-q>", function() app:close() end) app:map_key("", "<C-q>", function() app:close() end)
app:map_key("<C-e>", "toggle_left_panel") app:map_key("", "<C-e>", "toggle_left_panel")
app:map_key("<C-t>", "toggle_bottom_panel") app:map_key("", "<C-t>", "toggle_bottom_panel")
app:run_lsp({ app.editor:run_lsp({
name = "rust-analyzer", name = "rust-analyzer",
cmd = "rust-analyzer", cmd = "rust-analyzer",
languages = { "rust" }, languages = { "rust" },
+86 -50
View File
@@ -10,41 +10,22 @@
-- --
-- В каталоге --config должны лежать init.lua и подключаемые им модули. -- В каталоге --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 выполнен") print("[lua] init.lua выполнен")
-- Доступные методы объекта app (номера вкладок — с нуля): -- Объект 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:list_dir(path) -- {{ id = "src/main.rs", name = "main.rs", -- app:list_dir(path) -- {{ id = "src/main.rs", name = "main.rs",
-- -- is_dir = false }, ...}; пустой путь — -- -- is_dir = false }, ...}; пустой путь —
-- -- текущий каталог, id — путь от него -- -- текущий каталог, id — путь от него
-- app:close_tab(index) -- true, если вкладка закрыта -- app:map_key(modes, keys, handler)
-- 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) -- обработчик клавиши/сочетания:
-- -- функция, имя команды или nil (удалить) -- -- функция, имя команды или nil (удалить)
-- -- modes — строка режимов:
-- -- "" — все режимы
-- -- "-" — только режим по умолчанию
-- -- "-iv" — режим по умолчанию, i и v
-- -- keys — сочетание:
-- -- "a" нажатие A -- -- "a" нажатие A
-- -- "1" нажатие 1 -- -- "1" нажатие 1
-- -- "!a" отпускание A -- -- "!a" отпускание A
@@ -65,14 +46,60 @@ print("[lua] init.lua выполнен")
-- -- completion_previous, completion_page_up, -- -- completion_previous, completion_page_up,
-- -- completion_page_down, completion_accept, -- -- completion_page_down, completion_accept,
-- -- completion_cancel, completion_dismiss -- -- completion_cancel, completion_dismiss
-- app:set_mode(mode) -- режим глобальных привязок (один символ)
-- app:mode() -- текущий режим глобальных привязок
-- app:close() -- закрыть окно -- app:close() -- закрыть окно
-- app:toggle_maximize() -- app:toggle_maximize()
-- app:set_maximized(true/false) -- app:set_maximized(true/false)
-- app:minimize() -- app:minimize()
-- app:is_maximized() -- 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-серверы (запуск целиком настраивается этим сценарием): -- LSP-серверы (запуск целиком настраивается этим сценарием):
-- app:run_lsp({ -- app.editor:run_lsp({
-- name = "gopls", -- условное имя для stop/restart -- name = "gopls", -- условное имя для stop/restart
-- cmd = "gopls", -- путь к бинарнику или имя в PATH -- cmd = "gopls", -- путь к бинарнику или имя в PATH
-- args = { "serve" }, -- аргументы процесса -- args = { "serve" }, -- аргументы процесса
@@ -82,10 +109,10 @@ print("[lua] init.lua выполнен")
-- root = ".", -- корень workspace (по умолчанию cwd) -- root = ".", -- корень workspace (по умолчанию cwd)
-- initialization_options = {}, -- initializationOptions сервера -- initialization_options = {}, -- initializationOptions сервера
-- }) -- })
-- app:lsp_servers() -- { "gopls", ... } -- app.editor:lsp_servers() -- { "gopls", ... }
-- app:stop_lsp("gopls") -- true, если сервер был запущен -- app.editor:stop_lsp("gopls") -- true, если сервер был запущен
-- app:restart_lsp("gopls") -- перезапуск с прежними настройками -- app.editor:restart_lsp("gopls") -- перезапуск с прежними настройками
-- app:on_lsp_diagnostics(delay_ms, fn) -- app.editor:on_lsp_diagnostics(delay_ms, fn)
-- -- fn(servers) вызывается один раз -- -- fn(servers) вызывается один раз
-- -- после затишья delay_ms; nil вместо -- -- после затишья 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" }) -- 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" }) -- languages = { "rust" }, watcher = "**/*.rs" })
-- --
-- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием): -- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием):
@@ -163,22 +190,28 @@ print("[lua] init.lua выполнен")
-- tree:on_click(fn) -- fn(id) — клик по конечной ноде -- tree:on_click(fn) -- fn(id) — клик по конечной ноде
-- tree:selected() -- id выбранной строки или nil -- tree:selected() -- id выбранной строки или nil
-- tree:focus(id) -- раскрыть родителей, выбрать и дать фокус -- tree:focus(id) -- раскрыть родителей, выбрать и дать фокус
-- tree:map_key(modes, keys, handler)
-- -- привязка, которая работает дополнительно
-- -- к глобальной, когда фокус на этом дереве
-- tree:set_mode(mode) -- режим привязок блока (один символ)
-- tree:mode() -- текущий режим привязок блока
-- --
-- Методы добавляются в src/api.rs: всё, что есть в impl AppApi, помеченном -- Методы добавляются в src/api.rs: всё, что есть в impl AppApi или
-- #[mlua::userdata_impl], автоматически доступно из Lua. -- impl EditorApi, помеченном #[mlua::userdata_impl], автоматически доступно
-- из Lua как app:method(...) или app.editor:method(...).
-- --
-- Другие Lua-файлы можно подключать из каталога сценариев: -- Другие Lua-файлы можно подключать из каталога сценариев:
-- include("tools.lua") -- выполнить файл сразу -- include("tools.lua") -- выполнить файл сразу
-- require("tools") -- выполнить один раз (как обычный Lua-модуль) -- require("tools") -- выполнить один раз (как обычный Lua-модуль)
-- --
-- Подсветка синтаксиса по умолчанию выключена. Включить её можно так: -- Подсветка синтаксиса по умолчанию выключена. Включить её можно так:
-- app:set_language(0, "rust") -- app.editor:set_language(0, "rust")
-- gopls запускается автоматически при старте редактора. -- gopls запускается автоматически при старте редактора.
-- Сервер обслуживает только документы с languageId "go" и получает изменения -- Сервер обслуживает только документы с languageId "go" и получает изменения
-- всех .go-файлов проекта (кроме vendor/) через файловый вотчер. -- всех .go-файлов проекта (кроме vendor/) через файловый вотчер.
local gopls_ok, gopls_err = pcall(function() local gopls_ok, gopls_err = pcall(function()
app:run_lsp({ app.editor:run_lsp({
name = "gopls", name = "gopls",
cmd = "gopls", cmd = "gopls",
args = { "serve" }, args = { "serve" },
@@ -202,10 +235,10 @@ if not gopls_ok then
end end
-- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно. -- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно.
-- app:map_key("<C-s>", function() app:save() end) -- app:map_key("", "<C-s>", function() app.editor:save() end)
-- app:map_key("<!Esc>", function() app:close() end) -- app:map_key("", "<!Esc>", function() app:close() end)
-- Убрать встроенную привязку можно так: -- Убрать встроенную привязку можно так:
-- app:map_key("<C-s>", nil) -- app:map_key("", "<C-s>", nil)
-- app:map_completion_key("<C-n>", "completion_next") -- app:map_completion_key("<C-n>", "completion_next")
-- app:map_completion_key("<C-p>", "completion_previous") -- app:map_completion_key("<C-p>", "completion_previous")
@@ -239,7 +272,7 @@ end)
project_tree:on_click(function(id) project_tree:on_click(function(id)
-- Пустая таблица: фокус уходит в редактор, курсор остаётся на месте. -- Пустая таблица: фокус уходит в редактор, курсор остаётся на месте.
app:open(id, {}) app.editor:open(id, {})
end) end)
app.left_panel:open() app.left_panel:open()
@@ -247,7 +280,7 @@ load_dir(project_tree, "")
-- Открытие/закрытие панели с деревом. При открытии возвращаем фокус на -- Открытие/закрытие панели с деревом. При открытии возвращаем фокус на
-- последнюю выбранную строку, чтобы можно было сразу продолжить навигацию. -- последнюю выбранную строку, чтобы можно было сразу продолжить навигацию.
app:map_key("<C-b>", function() app:map_key("", "<C-b>", function()
local opened = app.left_panel:toggle() local opened = app.left_panel:toggle()
if opened then if opened then
local selected = project_tree:selected() local selected = project_tree:selected()
@@ -257,10 +290,10 @@ app:map_key("<C-b>", function()
end end
end) end)
app:map_key("<C-q>", function() app:close() end) app:map_key("", "<C-q>", function() app:close() end)
app:map_key("<C-e>", "toggle_left_panel") app:map_key("", "<C-e>", "toggle_left_panel")
app:map_key("<C-t>", "toggle_bottom_panel") app:map_key("", "<C-t>", "toggle_bottom_panel")
-- Дерево диагностики в нижней панели. -- Дерево диагностики в нижней панели.
-- --
@@ -290,7 +323,7 @@ local function diagnostic_rank(diagnostic)
or 1 or 1
end end
app:on_lsp_diagnostics(100, function(servers) app.editor:on_lsp_diagnostics(100, function(servers)
diagnostics_tree:clear() diagnostics_tree:clear()
-- Сначала собираем диагностику по файлам: одинаковые проблемы от разных -- Сначала собираем диагностику по файлам: одинаковые проблемы от разных
@@ -388,10 +421,10 @@ end)
diagnostics_tree:on_click(function(id) diagnostics_tree:on_click(function(id)
local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$") local path, line, character = id:match("^diag:(.*):(%d+):(%d+):%d+$")
if path then if path then
app:open(path, { tonumber(line), tonumber(character) }) app.editor:open(path, { tonumber(line), tonumber(character) })
else else
-- Клик по файлу: фокус в редактор, курсор не переставляем. -- Клик по файлу: фокус в редактор, курсор не переставляем.
app:open(id, {}) app.editor:open(id, {})
end end
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() app.toolbar:add_button("Запустить", "play", nil, "run", "right", function()
print("[toolbar] кнопка «Запустить» нажата") print("[toolbar] кнопка «Запустить» нажата")
end) end)
app.editor:set_gutter_icon(2, "play", "#efefe6")
app.editor:set_gutter_icon(90, "play")
+598 -326
View File
File diff suppressed because it is too large Load Diff
+314 -16
View File
@@ -27,7 +27,7 @@ use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::tag::Tag; use gpui_kit::component::tag::Tag;
use gpui_kit::component::text::TextView; use gpui_kit::component::text::TextView;
use gpui_kit::component::{ 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, resize_handle, v_resizable,
}; };
use gpui_kit::*; use gpui_kit::*;
@@ -39,8 +39,12 @@ use crate::completion::{
completion_kind_name, documentation_text, fallback_documentation, highlight_range, item_key, completion_kind_name, documentation_text, fallback_documentation, highlight_range, item_key,
parse_completion_response, range_for_lsp, word_start, 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::editor::{FocusAfterSync, Workspace};
use crate::gutter::GutterMarker;
use crate::icons; use crate::icons;
use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap}; use crate::keymap::{AppCommand, CompletionCommand, KeyContext, KeyHandler, KeyMap};
use crate::lsp::{LspManager, document_uri, position_to_offset}; use crate::lsp::{LspManager, document_uri, position_to_offset};
@@ -182,6 +186,9 @@ pub struct MyApp {
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения) /// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
/// уничтожились раньше самого Lua-движка. /// уничтожились раньше самого Lua-движка.
keymap: Rc<RefCell<KeyMap>>, keymap: Rc<RefCell<KeyMap>>,
/// Привязки клавиш редактора: действуют дополнительно к глобальным,
/// когда фокус находится в редакторе.
editor_keymap: Rc<RefCell<KeyMap>>,
/// Состояние левой панели, общее с `app.left_panel` в Lua. /// Состояние левой панели, общее с `app.left_panel` в Lua.
left_panel: PanelHandle, left_panel: PanelHandle,
/// Состояние правой панели, общее с `app.right_panel` в Lua. /// Состояние правой панели, общее с `app.right_panel` в Lua.
@@ -252,6 +259,7 @@ impl MyApp {
keymap.borrow_mut().install_defaults(); keymap.borrow_mut().install_defaults();
let api = AppApi::install(workspace.clone(), keymap.clone()); let api = AppApi::install(workspace.clone(), keymap.clone());
let lsp = api.lsp_handle(); let lsp = api.lsp_handle();
let editor_keymap = api.editor_keymap_handle();
let initial_text = workspace.borrow().active().content.clone(); let initial_text = workspace.borrow().active().content.clone();
let editor = cx.new(|cx| { let editor = cx.new(|cx| {
@@ -319,7 +327,7 @@ impl MyApp {
// Диагностика приходит уведомлениями сервера, а не ответом на запрос. // Диагностика приходит уведомлениями сервера, а не ответом на запрос.
// Одна задача применяет уже известную диагностику к редактору и вызывает // Одна задача применяет уже известную диагностику к редактору и вызывает
// Lua-обработчик; у хранилища один `Waker`, поэтому подписчик один. // Lua-обработчик; у хранилища один `Waker`, поэтому подписчик один.
let diagnostics_api = api.clone(); let diagnostics_api = api.editor();
let diagnostics_manager = lsp.clone(); let diagnostics_manager = lsp.clone();
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let mut revision = diagnostics_manager.diagnostics_revision(); let mut revision = diagnostics_manager.diagnostics_revision();
@@ -404,6 +412,7 @@ impl MyApp {
Self { Self {
workspace, workspace,
keymap, keymap,
editor_keymap,
left_panel, left_panel,
right_panel, right_panel,
bottom_panel, bottom_panel,
@@ -445,7 +454,7 @@ impl MyApp {
// Первый позиционный аргумент запуска ведёт себя как `vim file.go`: // Первый позиционный аргумент запуска ведёт себя как `vim file.go`:
// открываем его тем же API-методом, который доступен из Lua. // открываем его тем же API-методом, который доступен из Lua.
if let Some(path) = crate::constants::startup_file() 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()); eprintln!("Не удалось открыть {}: {err}", path.display());
} }
@@ -1414,8 +1423,10 @@ impl MyApp {
true true
} }
}; };
// Событие не останавливаем: глобальные привязки и привязки
// блока обрабатывает корень вида, а навигация дерева уже
// выполнена здесь.
if handled { if handled {
cx.stop_propagation();
entity.update(cx, |_, cx| cx.notify()); 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") 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( .child(
resizable_panel() resizable_panel()
.size(px(bottom_size)) .size(px(bottom_size))
@@ -1748,6 +1785,35 @@ impl MyApp {
}) })
} }
/// Колонка иконок слева от редактора (gutter).
///
/// Позиции иконок вычисляются в [`GutterElement::prepaint`], после
/// раскладки самого редактора. Это важно: прокрутка окончательно
/// фиксируется именно в его prepaint, и если считать `top` здесь, в
/// `render`, иконки отстают от текста на кадр.
fn render_gutter(&self, cx: &mut Context<Self>) -> impl IntoElement {
let markers = self
.workspace
.borrow()
.active()
.gutter
.iter()
.map(|(line, marker)| (line, marker.clone()))
.collect::<Vec<_>>();
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`. Левые элементы /// Элементы добавляются сценарием через `app.toolbar`. Левые элементы
@@ -1931,7 +1997,7 @@ impl MyApp {
// на соседнюю по устаревшему индексу. // на соседнюю по устаревшему индексу.
cx.stop_propagation(); cx.stop_propagation();
entity.update(cx, |_, cx| { entity.update(cx, |_, cx| {
AppApi::global().close_tab(index); AppApi::global().editor().close_tab(index);
cx.notify(); cx.notify();
}); });
} }
@@ -1961,7 +2027,7 @@ impl MyApp {
let entity = entity.clone(); let entity = entity.clone();
move |_, _, cx| { move |_, _, cx| {
entity.update(cx, |_, cx| { entity.update(cx, |_, cx| {
AppApi::global().open_tab(); AppApi::global().editor().open_tab();
cx.notify(); cx.notify();
}); });
} }
@@ -1971,7 +2037,7 @@ impl MyApp {
let entity = entity.clone(); let entity = entity.clone();
move |index, _, cx| { move |index, _, cx| {
entity.update(cx, |_, cx| { entity.update(cx, |_, cx| {
AppApi::global().set_active_tab(*index); AppApi::global().editor().set_active_tab(*index);
cx.notify(); cx.notify();
}); });
} }
@@ -2009,6 +2075,13 @@ impl Render for MyApp {
}; };
let keymap = self.keymap.clone(); 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(); let entity = cx.entity();
div() div()
@@ -2020,23 +2093,49 @@ impl Render for MyApp {
.text_color(cx.theme().foreground) .text_color(cx.theme().foreground)
.on_key_down({ .on_key_down({
let keymap = keymap.clone(); let keymap = keymap.clone();
let editor_keymap = editor_keymap.clone();
let editor = editor.clone();
let panels = panels.clone();
let entity = entity.clone(); let entity = entity.clone();
move |event, window, cx| { move |event, window, cx| {
// Сначала всегда срабатывают глобальные привязки.
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, true); 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); apply_window_commands(window);
if handled { if handled || focused {
entity.update(cx, |_, cx| cx.notify()); entity.update(cx, |_, cx| cx.notify());
} }
} }
}) })
.on_key_up({ .on_key_up({
let keymap = keymap.clone(); let keymap = keymap.clone();
let editor_keymap = editor_keymap.clone();
let editor = editor.clone();
let panels = panels.clone();
let entity = entity.clone(); let entity = entity.clone();
move |event, window, cx| { move |event, window, cx| {
let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, false); let handled = run_keymap(&keymap, KeyContext::Global, &event.keystroke, false);
let focused = run_focused_keymap(
&editor,
&editor_keymap,
&panels,
&event.keystroke,
false,
window,
cx,
);
apply_window_commands(window); apply_window_commands(window);
if handled { if handled || focused {
entity.update(cx, |_, cx| cx.notify()); entity.update(cx, |_, cx| cx.notify());
} }
} }
@@ -2050,6 +2149,153 @@ impl Render for MyApp {
} }
} }
/// Колонка иконок gutter.
///
/// В отличие от обычного `div`, позиции иконок считаются в `prepaint`. К
/// этому моменту [`EditorState`] уже обновил раскладку и прокрутку для
/// текущего кадра, поэтому иконки не отстают от текста при скроллинге.
struct GutterElement {
editor: Entity<EditorState>,
markers: Vec<(usize, GutterMarker)>,
width: Pixels,
background: Hsla,
foreground: Hsla,
}
/// Приготовленные к рисованию иконки: границы, svg-путь и цвет.
struct GutterPrepaint {
icons: Vec<(Bounds<Pixels>, 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<ElementId> {
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<Pixels>,
_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<Pixels>,
_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 в байтовый диапазон текста. /// Переводит диапазон LSP в байтовый диапазон текста.
/// ///
/// Пустой диапазон (например, «ожидалось `;`») расширяется на следующий /// Пустой диапазон (например, «ожидалось `;`») расширяется на следующий
@@ -2132,7 +2378,7 @@ fn contrast_foreground(background: Hsla) -> Hsla {
/// Сохраняет активную вкладку, печатая ошибку в stderr. /// Сохраняет активную вкладку, печатая ошибку в stderr.
fn save_active_tab() { fn save_active_tab() {
if let Err(err) = AppApi::global().save() { if let Err(err) = AppApi::global().editor().save() {
eprintln!("Не удалось сохранить вкладку: {err}"); eprintln!("Не удалось сохранить вкладку: {err}");
} }
} }
@@ -2140,19 +2386,20 @@ fn save_active_tab() {
/// Выполняет встроенную команду приложения. /// Выполняет встроенную команду приложения.
fn run_app_command(command: AppCommand) { fn run_app_command(command: AppCommand) {
let app = AppApi::global(); let app = AppApi::global();
let editor = app.editor();
match command { match command {
AppCommand::Save => save_active_tab(), AppCommand::Save => save_active_tab(),
AppCommand::NextTab => { AppCommand::NextTab => {
app.next_tab(); editor.next_tab();
} }
AppCommand::PreviousTab => { AppCommand::PreviousTab => {
app.previous_tab(); editor.previous_tab();
} }
AppCommand::OpenTab => { AppCommand::OpenTab => {
app.open_tab(); editor.open_tab();
} }
AppCommand::CloseTab => { AppCommand::CloseTab => {
app.close_tab(app.active_tab_index()); editor.close_tab(editor.active_tab_index());
} }
AppCommand::ToggleLeftPanel => { AppCommand::ToggleLeftPanel => {
app.left_panel().toggle(); app.left_panel().toggle();
@@ -2178,6 +2425,57 @@ fn dismisses_completion(keystroke: &Keystroke) -> bool {
modified || matches!(keystroke.key.as_str(), "left" | "right" | "home" | "end") modified || matches!(keystroke.key.as_str(), "left" | "right" | "home" | "end")
} }
/// Выполняет привязки того объекта, на котором сейчас фокус.
///
/// Глобальные привязки обрабатываются отдельно и всегда; здесь — только
/// дополнительный слой: редактор, если фокус в нём, либо блок панели, если
/// фокус на нём. Фокус в других элементах (панель инструментов, вкладки,
/// строка состояния) дополнительного слоя не добавляет.
fn run_focused_keymap(
editor: &Entity<EditorState>,
editor_keymap: &Rc<RefCell<KeyMap>>,
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<Rc<RefCell<KeyMap>>> {
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` для события клавиатуры. /// Вызывает обработчики одного слоя `KeyMap` для события клавиатуры.
/// ///
/// Возвращает `true`, если хотя бы одна привязка сработала. Заём карты /// Возвращает `true`, если хотя бы одна привязка сработала. Заём карты
+6
View File
@@ -22,6 +22,12 @@ pub const WINDOW_TITLE: &str = "qode";
/// Ширина одного уровня отступа в редакторе. /// Ширина одного уровня отступа в редакторе.
pub const TAB_SIZE: usize = 4; 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; pub const RIGHT_PANEL_WIDTH: f32 = 240.0;
+7 -2
View File
@@ -7,6 +7,8 @@
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use crate::gutter::GutterState;
/// Счётчик идентификаторов документов. /// Счётчик идентификаторов документов.
static NEXT_ID: AtomicU64 = AtomicU64::new(1); static NEXT_ID: AtomicU64 = AtomicU64::new(1);
@@ -126,15 +128,17 @@ pub struct Document {
/// Имя иконки вкладки из каталога Lucide (см. `crate::icons`). /// Имя иконки вкладки из каталога Lucide (см. `crate::icons`).
/// ///
/// `None` — показывается иконка по умолчанию. Назначается через /// `None` — показывается иконка по умолчанию. Назначается через
/// `AppApi::set_tab_icon`. /// `EditorApi::set_tab_icon`.
pub icon: Option<String>, pub icon: Option<String>,
/// Язык подсветки синтаксиса. /// Язык подсветки синтаксиса.
/// ///
/// `None` — подсветка выключена (поведение по умолчанию). Включается /// `None` — подсветка выключена (поведение по умолчанию). Включается
/// только явно через `AppApi::set_language`, например из Lua-сценария. /// только явно через `EditorApi::set_language`, например из Lua-сценария.
pub language: Option<Language>, pub language: Option<Language>,
/// Позиция курсора в байтах на момент ухода с вкладки. /// Позиция курсора в байтах на момент ухода с вкладки.
pub cursor: usize, pub cursor: usize,
/// Иконки на поле редактора для этой вкладки.
pub gutter: GutterState,
/// Содержимое на момент последнего «сохранения». /// Содержимое на момент последнего «сохранения».
saved_content: String, saved_content: String,
} }
@@ -150,6 +154,7 @@ impl Document {
icon: None, icon: None,
language: None, language: None,
cursor: 0, cursor: 0,
gutter: GutterState::new(),
} }
} }
+121
View File
@@ -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<Hsla>,
}
/// Иконки gutter документа по номеру строки.
///
/// Одна строка несёт не больше одной иконки: повторный вызов заменяет её.
/// `BTreeMap` держит строки отсортированными, поэтому вид рисует их сверху
/// вниз без дополнительной сортировки.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GutterState {
markers: BTreeMap<usize, GutterMarker>,
}
impl GutterState {
/// Создаёт пустое поле.
pub fn new() -> Self {
Self::default()
}
/// Ставит иконку на строку `line`, заменяя прежнюю.
///
/// Имя иконки проверяет API: здесь хранится уже каноническое имя.
pub fn set(&mut self, line: usize, icon: impl Into<String>, color: Option<Hsla>) {
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<Item = (usize, &GutterMarker)> {
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<usize> = 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.));
}
}
+134 -4
View File
@@ -12,6 +12,13 @@
//! автодополнения. Оба слоя проходят через одну [`KeyMap`], поэтому сценарий //! автодополнения. Оба слоя проходят через одну [`KeyMap`], поэтому сценарий
//! может переопределить и удалить любую встроенную привязку. //! может переопределить и удалить любую встроенную привязку.
//! //!
//! У каждого объекта со своим `map_key` (приложение, редактор, блок панели)
//! отдельная [`KeyMap`]. Кроме слоя привязка хранит строку режимов: пустая
//! строка — все режимы, `"-"` — только режим по умолчанию, `"-iv"` — режим по
//! умолчанию, `i` и `v`. Текущий режим карты — один символ, по умолчанию `-`.
//! Режимы динамические: [`KeyMap::set_mode`] просто меняет метку, а
//! [`KeyMap::handlers_for`] отбирает подходящие привязки.
//!
//! Клавиша сравнивается с `Keystroke.key` из GPUI. Это не результат ввода, а //! Клавиша сравнивается с `Keystroke.key` из GPUI. Это не результат ввода, а
//! имя клавиши, которое платформа уже нормализует: //! имя клавиши, которое платформа уже нормализует:
//! //!
@@ -348,38 +355,97 @@ pub enum KeyHandler {
Completion(CompletionCommand), Completion(CompletionCommand),
} }
/// Режим по умолчанию. Тире — такой же идентификатор режима, как и
/// остальные однобуквенные режимы (`n`, `i`, `v` и т. п.).
pub const DEFAULT_MODE: char = '-';
/// Привязка вместе с обработчиком. /// Привязка вместе с обработчиком.
#[derive(Clone)] #[derive(Clone)]
struct Binding { struct Binding {
context: KeyContext, context: KeyContext,
binding: KeyBinding, binding: KeyBinding,
/// Режимы, в которых действует привязка. Пустая строка — все режимы.
modes: String,
handler: KeyHandler, handler: KeyHandler,
} }
/// Все назначенные привязки клавиш. impl Binding {
#[derive(Default)] /// Действует ли привязка в режиме `mode`.
fn accepts(&self, mode: char) -> bool {
self.modes.is_empty() || self.modes.contains(mode)
}
}
/// Все назначенные привязки клавиш и текущий режим объекта.
///
/// У каждого объекта со своим `map_key` (приложение, редактор, блок панели)
/// отдельная карта: режимы одного объекта не влияют на другой.
pub struct KeyMap { pub struct KeyMap {
bindings: Vec<Binding>, bindings: Vec<Binding>,
mode: char,
}
impl Default for KeyMap {
fn default() -> Self {
Self {
bindings: Vec::new(),
mode: DEFAULT_MODE,
}
}
} }
impl KeyMap { 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<String>,
binding: KeyBinding,
handler: KeyHandler,
) {
let modes = modes.into();
if let Some(existing) = self if let Some(existing) = self
.bindings .bindings
.iter_mut() .iter_mut()
.find(|existing| existing.context == context && existing.binding == binding) .find(|existing| existing.context == context && existing.binding == binding)
{ {
existing.modes = modes;
existing.handler = handler; existing.handler = handler;
} else { } else {
self.bindings.push(Binding { self.bindings.push(Binding {
context, context,
binding, binding,
modes,
handler, 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`, если привязка была. /// Возвращает `true`, если привязка была.
@@ -404,7 +470,9 @@ impl KeyMap {
self.bindings self.bindings
.iter() .iter()
.filter(|binding| { .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()) .map(|binding| binding.handler.clone())
.collect() .collect()
@@ -418,6 +486,7 @@ impl KeyMap {
for spec in ["<C-s>", "<D-s>"] { for spec in ["<C-s>", "<D-s>"] {
self.set( self.set(
KeyContext::Global, KeyContext::Global,
"",
parse_default(spec), parse_default(spec),
KeyHandler::App(AppCommand::Save), KeyHandler::App(AppCommand::Save),
); );
@@ -425,6 +494,7 @@ impl KeyMap {
for spec in ["<C-space>", "<D-space>"] { for spec in ["<C-space>", "<D-space>"] {
self.set( self.set(
KeyContext::Completion, KeyContext::Completion,
"",
parse_default(spec), parse_default(spec),
KeyHandler::Completion(CompletionCommand::Trigger), KeyHandler::Completion(CompletionCommand::Trigger),
); );
@@ -445,6 +515,7 @@ impl KeyMap {
] { ] {
self.set( self.set(
KeyContext::Completion, KeyContext::Completion,
"",
parse_default(spec), parse_default(spec),
KeyHandler::Completion(command), KeyHandler::Completion(command),
); );
@@ -718,4 +789,63 @@ mod tests {
.is_empty() .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');
}
} }
+2 -2
View File
@@ -10,7 +10,7 @@
//! * файловый вотчер, который пересылает серверу `workspace/didChangeWatchedFiles`. //! * файловый вотчер, который пересылает серверу `workspace/didChangeWatchedFiles`.
//! //!
//! Всё остальное — какие серверы запускать, с какими аргументами и когда — //! Всё остальное — какие серверы запускать, с какими аргументами и когда —
//! решает Lua-сценарий через [`AppApi::run_lsp`](crate::api::AppApi::run_lsp). //! решает Lua-сценарий через [`EditorApi::run_lsp`](crate::api::EditorApi::run_lsp).
//! //!
//! Обмен с сервером идёт в фоновых потоках: поток-читатель разбирает ответы и //! Обмен с сервером идёт в фоновых потоках: поток-читатель разбирает ответы и
//! складывает их в каналы конкретных запросов, а UI ждёт ответ в //! складывает их в каналы конкретных запросов, а UI ждёт ответ в
@@ -140,7 +140,7 @@ pub struct WatcherConfig {
/// Описание запускаемого LSP-сервера. /// Описание запускаемого LSP-сервера.
/// ///
/// Создаётся из Lua-таблицы в [`AppApi::run_lsp`](crate::api::AppApi::run_lsp). /// Создаётся из Lua-таблицы в [`EditorApi::run_lsp`](crate::api::EditorApi::run_lsp).
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct LspConfig { pub struct LspConfig {
/// Условное имя, по которому сервер останавливают и перезапускают. /// Условное имя, по которому сервер останавливают и перезапускают.
+1
View File
@@ -10,6 +10,7 @@ mod app;
mod completion; mod completion;
mod constants; mod constants;
mod editor; mod editor;
mod gutter;
mod icons; mod icons;
mod keymap; mod keymap;
mod lsp; mod lsp;
+3 -2
View File
@@ -3,8 +3,9 @@
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`script_dir`] //! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`script_dir`]
//! (см. [`ScriptEngine::run_startup`]). Скриптам доступны: //! (см. [`ScriptEngine::run_startup`]). Скриптам доступны:
//! //!
//! * объект `app` — управление окном и редактором (методы [`AppApi`], вызываются //! * объект `app` — управление окном и панелями (методы [`AppApi`], вызываются
//! через двоеточие: `app:next_tab()`); //! через двоеточие: `app:next_tab()`); во вложенном `app.editor` живёт API
//! редактора ([`EditorApi`](crate::api::EditorApi)): `app.editor:save()`;
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`script_dir`] //! * функция `include(path)` — выполняет другой Lua-файл относительно [`script_dir`]
//! (или по абсолютному пути) в общем глобальном окружении; //! (или по абсолютному пути) в общем глобальном окружении;
//! * стандартный `require`, который также ищет модули в [`script_dir`]. //! * стандартный `require`, который также ищет модули в [`script_dir`].
+378 -80
View File
@@ -36,12 +36,12 @@ fn lua_lsp_api_lists_no_servers_and_validates_config() {
std::fs::write( std::fs::write(
&script, &script,
r#" r#"
assert(#app:lsp_servers() == 0) assert(#app.editor:lsp_servers() == 0)
assert(app:stop_lsp("missing") == false) assert(app.editor:stop_lsp("missing") == false)
assert(app:restart_lsp("missing") == false) assert(app.editor:restart_lsp("missing") == false)
-- Без cmd сервер не запускается, а ошибка объясняет, чего не хватает. -- Без 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(not ok)
assert(tostring(err):find("cmd", 1, true), tostring(err)) 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 workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone()); let (api, _keymap) = install_api(workspace.clone());
let tabs = api.tabs(); let tabs = api.editor().tabs();
assert_eq!( assert_eq!(
tabs.iter() tabs.iter()
.map(|tab| (tab.index, tab.title.as_str())) .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!(!api.editor().close_tab(99));
assert_eq!(api.tabs().len(), 1); assert_eq!(api.editor().tabs().len(), 1);
api.open_tab(); api.editor().open_tab();
assert_eq!(api.tabs().len(), 2); assert_eq!(api.editor().tabs().len(), 2);
assert!(api.close_tab(1)); assert!(api.editor().close_tab(1));
assert_eq!(api.tabs().len(), 1); assert_eq!(api.editor().tabs().len(), 1);
assert_eq!(workspace.borrow().documents().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); let (api, _keymap) = install_api(workspace);
// По умолчанию у всех вкладок иконка file. // По умолчанию у всех вкладок иконка file.
assert_eq!(api.tabs()[0].icon, "file"); assert_eq!(api.editor().tabs()[0].icon, "file");
assert_eq!(api.tab_icon(0).as_deref(), Some("file")); assert_eq!(api.editor().tab_icon(0).as_deref(), Some("file"));
assert!(api.set_tab_icon(0, "file-code")); assert!(api.editor().set_tab_icon(0, "file-code"));
assert_eq!(api.tabs()[0].icon, "file-code"); assert_eq!(api.editor().tabs()[0].icon, "file-code");
assert_eq!(api.tab_icon(0).as_deref(), Some("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!(api.editor().set_tab_icon(0, "icons/book-open.svg"));
assert_eq!(api.tab_icon(0).as_deref(), Some("book-open")); assert_eq!(api.editor().tab_icon(0).as_deref(), Some("book-open"));
// Неизвестная иконка и несуществующая вкладка ничего не меняют. // Неизвестная иконка и несуществующая вкладка ничего не меняют.
assert!(!api.set_tab_icon(0, "definitely-not-an-icon")); assert!(!api.editor().set_tab_icon(0, "definitely-not-an-icon"));
assert_eq!(api.tab_icon(0).as_deref(), Some("book-open")); assert_eq!(api.editor().tab_icon(0).as_deref(), Some("book-open"));
assert!(!api.set_tab_icon(99, "file")); assert!(!api.editor().set_tab_icon(99, "file"));
assert!(api.clear_tab_icon(0)); assert!(api.editor().clear_tab_icon(0));
assert_eq!(api.tab_icon(0).as_deref(), Some("file")); assert_eq!(api.editor().tab_icon(0).as_deref(), Some("file"));
assert!(!api.clear_tab_icon(99)); assert!(!api.editor().clear_tab_icon(99));
assert_eq!(api.tab_icon(99), None); assert_eq!(api.editor().tab_icon(99), None);
} }
#[test] #[test]
@@ -184,14 +184,14 @@ fn lua_script_can_set_tab_icons() {
std::fs::write( std::fs::write(
&script, &script,
r#" r#"
assert(app:tab_icon(0) == "file") assert(app.editor:tab_icon(0) == "file")
assert(app:set_tab_icon(0, "book-open")) assert(app.editor:set_tab_icon(0, "book-open"))
assert(app:tab_icon(0) == "book-open") assert(app.editor:tab_icon(0) == "book-open")
app:open_tab() app.editor:open_tab()
assert(app:tabs()[2].icon == "file") assert(app.editor:tabs()[2].icon == "file")
assert(not app:set_tab_icon(0, "no-such-icon")) assert(not app.editor:set_tab_icon(0, "no-such-icon"))
assert(app:clear_tab_icon(0)) assert(app.editor:clear_tab_icon(0))
assert(app:tab_icon(0) == "file") assert(app.editor:tab_icon(0) == "file")
"#, "#,
) )
.unwrap(); .unwrap();
@@ -203,6 +203,87 @@ fn lua_script_can_set_tab_icons() {
let _ = std::fs::remove_dir_all(&dir); 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] #[test]
fn api_save_writes_active_tab_to_file_named_after_it() { 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()); assert!(workspace.borrow().active().is_dirty());
let (api, _keymap) = install_api(workspace.clone()); let (api, _keymap) = install_api(workspace.clone());
api.save().expect("сохранение должно удаться"); api.editor().save().expect("сохранение должно удаться");
assert_eq!( assert_eq!(
std::fs::read_to_string(&path).expect("файл должен существовать"), 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 workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone()); let (api, _keymap) = install_api(workspace.clone());
api.open_tab(); api.editor().open_tab();
api.set_active_tab(0); api.editor().set_active_tab(0);
assert_eq!(api.active_tab_index(), 0); assert_eq!(api.editor().active_tab_index(), 0);
assert_eq!(api.next_tab(), 1); assert_eq!(api.editor().next_tab(), 1);
assert_eq!(api.active_tab_index(), 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.editor().previous_tab(), 1);
assert_eq!(api.previous_tab(), 0); assert_eq!(api.editor().previous_tab(), 0);
assert_eq!(workspace.borrow().active_index(), 0); assert_eq!(workspace.borrow().active_index(), 0);
} }
@@ -572,7 +653,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() {
std::fs::write( std::fs::write(
&script, &script,
r#" r#"
app:on_lsp_diagnostics(0, function(servers) app.editor:on_lsp_diagnostics(0, function(servers)
assert(#servers == 1, #servers) assert(#servers == 1, #servers)
local entry = servers[1] local entry = servers[1]
assert(entry.server == "test") assert(entry.server == "test")
@@ -589,7 +670,7 @@ fn lua_diagnostics_handler_receives_every_server_and_file() {
assert(first.message == "плохо") assert(first.message == "плохо")
assert(entry.diagnostics[2].severity_name == "warning") assert(entry.diagnostics[2].severity_name == "warning")
app:open_tab() app.editor:open_tab()
end) end)
"#, "#,
) )
@@ -597,7 +678,10 @@ fn lua_diagnostics_handler_receives_every_server_and_file() {
let engine = ScriptEngine::new(api.clone()).expect("движок Lua"); let engine = ScriptEngine::new(api.clone()).expect("движок Lua");
engine.run_file(&script).expect("регистрация обработчика"); 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 uri = crate::lsp::path_to_uri(Path::new("/tmp/qode-diagnostics.go"));
let diagnostics = vec![ 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].path.as_deref(), Some("/tmp/qode-diagnostics.go"));
assert_eq!(report[0].diagnostics.len(), 2); assert_eq!(report[0].diagnostics.len(), 2);
assert!(api.emit_diagnostics(report)); assert!(api.editor().emit_diagnostics(report));
assert_eq!( assert_eq!(
workspace.borrow().documents().len(), workspace.borrow().documents().len(),
2, 2,
@@ -696,13 +780,13 @@ fn lua_script_can_include_files_and_control_the_app() {
let main = dir.join("main.lua"); let main = dir.join("main.lua");
std::fs::write( std::fs::write(
&helper, &helper,
"function lua_config() app:set_active_text(\"from lua\") end\n", "function lua_config() app.editor:set_active_text(\"from lua\") end\n",
) )
.expect("helper.lua"); .expect("helper.lua");
std::fs::write( std::fs::write(
&main, &main,
format!( format!(
"include({:?})\nlua_config()\napp: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() helper.to_string_lossy()
), ),
) )
@@ -720,12 +804,12 @@ fn lua_script_can_include_files_and_control_the_app() {
assert_eq!( assert_eq!(
state.active_index(), state.active_index(),
1, 1,
"app:open_tab() не переключил вкладку" "app.editor:open_tab() не переключил вкладку"
); );
assert_eq!( assert_eq!(
state.documents()[1].language, state.documents()[1].language,
Some(Language::Python), Some(Language::Python),
"app:set_language() не включил подсветку из Lua" "app.editor:set_language() не включил подсветку из Lua"
); );
let _ = std::fs::remove_dir_all(&dir); 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"); let path = dir.join("main.go");
std::fs::write(&path, "package main\n").unwrap(); 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(); let state = workspace.borrow();
assert_eq!(index, state.active_index()); 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(); 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); assert_eq!(workspace.borrow().documents().len(), tabs);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
@@ -770,8 +854,14 @@ fn lua_open_controls_focus_and_cursor() {
// Без второго аргумента фокус остаётся прежним. // Без второго аргумента фокус остаётся прежним.
let plain = dir.join("plain.lua"); let plain = dir.join("plain.lua");
std::fs::write(&plain, format!("app:open({:?})\n", path.to_string_lossy())).unwrap(); std::fs::write(
engine.run_file(&plain).expect("app:open без позиции"); &plain,
format!("app.editor:open({:?})\n", path.to_string_lossy()),
)
.unwrap();
engine
.run_file(&plain)
.expect("app.editor:open без позиции");
assert_eq!( assert_eq!(
workspace.borrow_mut().take_focus_after_sync(), workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Keep) Some(FocusAfterSync::Keep)
@@ -781,10 +871,12 @@ fn lua_open_controls_focus_and_cursor() {
let focus = dir.join("focus.lua"); let focus = dir.join("focus.lua");
std::fs::write( std::fs::write(
&focus, &focus,
format!("app:open({:?}, {{}})\n", path.to_string_lossy()), format!("app.editor:open({:?}, {{}})\n", path.to_string_lossy()),
) )
.unwrap(); .unwrap();
engine.run_file(&focus).expect("app:open с пустой таблицей"); engine
.run_file(&focus)
.expect("app.editor:open с пустой таблицей");
assert_eq!( assert_eq!(
workspace.borrow_mut().take_focus_after_sync(), workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(None)) Some(FocusAfterSync::Editor(None))
@@ -794,10 +886,10 @@ fn lua_open_controls_focus_and_cursor() {
let at = dir.join("at.lua"); let at = dir.join("at.lua");
std::fs::write( std::fs::write(
&at, &at,
format!("app:open({:?}, {{2, 5}})\n", path.to_string_lossy()), format!("app.editor:open({:?}, {{2, 5}})\n", path.to_string_lossy()),
) )
.unwrap(); .unwrap();
engine.run_file(&at).expect("app:open с позицией"); engine.run_file(&at).expect("app.editor:open с позицией");
assert_eq!( assert_eq!(
workspace.borrow_mut().take_focus_after_sync(), workspace.borrow_mut().take_focus_after_sync(),
Some(FocusAfterSync::Editor(Some(19))) Some(FocusAfterSync::Editor(Some(19)))
@@ -815,7 +907,7 @@ fn api_open_missing_file_creates_an_empty_buffer() {
let dir = temp_script_dir("open-missing"); let dir = temp_script_dir("open-missing");
let path = dir.join("new.go"); let path = dir.join("new.go");
let index = api.open(&path).expect("новый буфер"); let index = api.editor().open(&path).expect("новый буфер");
let state = workspace.borrow(); let state = workspace.borrow();
assert_eq!(index, state.active_index()); assert_eq!(index, state.active_index());
assert_eq!(state.active().name, path.to_string_lossy()); 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( std::fs::write(
&script, &script,
format!( 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() path.to_string_lossy()
), ),
) )
.unwrap(); .unwrap();
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua"); let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
engine.run_file(&script).expect("app:open из Lua"); engine.run_file(&script).expect("app.editor:open из Lua");
let state = workspace.borrow(); let state = workspace.borrow();
assert_eq!(state.active().name, path.to_string_lossy()); assert_eq!(state.active().name, path.to_string_lossy());
@@ -866,7 +958,7 @@ fn lua_engine_loads_init_from_a_custom_config_dir() {
let dir = temp_script_dir("config-dir"); let dir = temp_script_dir("config-dir");
std::fs::write( std::fs::write(
dir.join("helper.lua"), dir.join("helper.lua"),
"function config_value() app:set_active_text(\"custom config\") end\n", "function config_value() app.editor:set_active_text(\"custom config\") end\n",
) )
.expect("helper.lua"); .expect("helper.lua");
std::fs::write( std::fs::write(
@@ -895,11 +987,11 @@ fn lua_key_bindings_match_press_release_and_modifiers() {
std::fs::write( std::fs::write(
&script, &script,
r#" r#"
app:map_key("a", function() app:set_active_text("a pressed") end) app:map_key("", "a", function() app.editor:set_active_text("a pressed") end)
app:map_key("!1", function() app:set_active_text("1 released") end) app:map_key("", "!1", function() app.editor:set_active_text("1 released") end)
app:map_key("<S-c>", function() app:set_active_text("shift c") end) app:map_key("", "<S-c>", function() app.editor:set_active_text("shift c") end)
app:map_key("<C-z>", function() app:set_active_text("ctrl z") end) app:map_key("", "<C-z>", function() app.editor:set_active_text("ctrl z") end)
app:map_key("<A-!r>", function() app:set_active_text("alt r released") end) app:map_key("", "<A-!r>", function() app.editor:set_active_text("alt r released") end)
"#, "#,
) )
.expect("keys.lua"); .expect("keys.lua");
@@ -997,11 +1089,11 @@ fn lua_keymap_accepts_command_names_and_nil() {
std::fs::write( std::fs::write(
&script, &script,
r#" r#"
app:map_key("<C-n>", "next_tab") app:map_key("", "<C-n>", "next_tab")
app:map_completion_key("<C-n>", "completion_next") app:map_completion_key("<C-n>", "completion_next")
local ok, err = pcall(function() local ok, err = pcall(function()
app:map_key("<C-x>", "no_such_command") app:map_key("", "<C-x>", "no_such_command")
end) end)
assert(not ok) assert(not ok)
assert(tostring(err):find("доступны", 1, true), tostring(err)) assert(tostring(err):find("доступны", 1, true), tostring(err))
@@ -1042,7 +1134,7 @@ fn lua_keymap_accepts_command_names_and_nil() {
// nil удаляет привязку, не оставляя заглушку. // nil удаляет привязку, не оставляя заглушку.
std::fs::write( std::fs::write(
&script, &script,
"app:map_key(\"<C-n>\", nil)\napp:map_completion_key(\"<C-n>\", nil)\n", "app:map_key(\"\", \"<C-n>\", nil)\napp:map_completion_key(\"<C-n>\", nil)\n",
) )
.expect("keys.lua"); .expect("keys.lua");
engine.run_file(&script).expect("привязки должны удалиться"); 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 workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone()); 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_eq!(workspace.borrow().documents()[0].language, None);
assert!(api.set_language(0, "rs")); assert!(api.editor().set_language(0, "rs"));
assert_eq!(api.language(0), Some("rust".to_owned())); assert_eq!(api.editor().language(0), Some("rust".to_owned()));
assert_eq!( assert_eq!(
workspace.borrow().documents()[0].language, workspace.borrow().documents()[0].language,
Some(Language::Rust) Some(Language::Rust)
); );
// Неизвестный язык и несуществующая вкладка. // Неизвестный язык и несуществующая вкладка.
assert!(!api.set_language(0, "cobol")); assert!(!api.editor().set_language(0, "cobol"));
assert!(!api.set_language(99, "rust")); assert!(!api.editor().set_language(99, "rust"));
assert!(api.clear_language(0)); assert!(api.editor().clear_language(0));
assert_eq!(api.language(0), None); assert_eq!(api.editor().language(0), None);
} }
#[test] #[test]
@@ -1144,8 +1236,13 @@ fn keymap_bindings_are_parsed_and_replaced() {
.load("return function() end") .load("return function() end")
.eval::<mlua::Function>() .eval::<mlua::Function>()
.unwrap(); .unwrap();
keymap.set(KeyContext::Global, plain.clone(), KeyHandler::Lua(first)); keymap.set(
keymap.set(KeyContext::Global, plain, KeyHandler::Lua(second)); KeyContext::Global,
"",
plain.clone(),
KeyHandler::Lua(first),
);
keymap.set(KeyContext::Global, "", plain, KeyHandler::Lua(second));
let stroke = Keystroke { let stroke = Keystroke {
modifiers: Modifiers::default(), 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"); let script = dir.join("keys.lua");
std::fs::write( std::fs::write(
&script, &script,
r#"app:map_key("<A-r>", function() app:set_active_text("alt r pressed") end)"#, r#"app:map_key("", "<A-r>", function() app.editor:set_active_text("alt r pressed") end)"#,
) )
.expect("keys.lua"); .expect("keys.lua");
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua"); let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
@@ -1199,6 +1296,178 @@ fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext
let _ = std::fs::remove_dir_all(&dir); 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("", "<A-g>", function() app.editor:set_gutter_icon(0, "circle") end)
app.editor:map_key("", "<A-r>", function() app.editor:set_gutter_icon(0, "play") end)
local tree = app.left_panel:add_tree()
tree:append({ { id = "a", label = "a" } }, "")
tree:map_key("", "<A-t>", function() app.editor:set_gutter_icon(0, "file-code") end)
app.left_panel:open()
"#,
)
.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("-", "<A-a>", function() app.editor:set_active_text("default") end)
app:map_key("i", "<A-b>", function() app.editor:set_active_text("insert") end)
app:map_key("", "<A-c>", function() app.editor:set_active_text("all") end)
assert(app.editor:mode() == "-")
app.editor:set_mode("n")
assert(app.editor:mode() == "n")
app.editor:map_key("n", "<A-d>", function() app.editor:set_active_text("editor n") end)
app.editor:map_key("i", "<A-e>", function() app.editor:set_active_text("editor i") end)
local tree = app.left_panel:add_tree()
assert(tree:mode() == "-")
tree:set_mode("v")
assert(tree:mode() == "v")
tree:map_key("v", "<A-f>", function() app.editor:set_active_text("tree v") end)
"#,
)
.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<RefCell<KeyMap>>, 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] #[gpui_kit::test]
fn opened_panels_render_without_panicking(cx: &mut TestAppContext) { fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
cx.update(gpui_kit::init); 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); 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] #[gpui_kit::test]
fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) { fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
cx.update(gpui_kit::init); cx.update(gpui_kit::init);
+18
View File
@@ -18,6 +18,8 @@ use mlua::Function;
use gpui_kit::{App, FocusHandle, ScrollHandle}; use gpui_kit::{App, FocusHandle, ScrollHandle};
use crate::keymap::KeyMap;
/// Общая ссылка на состояние блока дерева. /// Общая ссылка на состояние блока дерева.
pub type TreeHandle = Rc<RefCell<TreeState>>; pub type TreeHandle = Rc<RefCell<TreeState>>;
@@ -107,6 +109,8 @@ pub struct TreeState {
focus_requested: bool, focus_requested: bool,
/// Прокрутка списка: выбранная строка всегда прокручивается в видимую часть. /// Прокрутка списка: выбранная строка всегда прокручивается в видимую часть.
scroll: ScrollHandle, scroll: ScrollHandle,
/// Привязки клавиш блока и его текущий режим.
keymap: Rc<RefCell<KeyMap>>,
} }
impl TreeState { impl TreeState {
@@ -156,6 +160,20 @@ impl TreeState {
.clone() .clone()
} }
/// Уже созданный фокус-обработчик, если дерево хотя бы раз рисовалось.
///
/// В отличие от [`Self::focus_handle`] ничего не создаёт: вид проверяет
/// по нему, владеет ли блок клавиатурным фокусом прямо сейчас.
pub fn existing_focus_handle(&self) -> Option<FocusHandle> {
self.focus_handle.clone()
}
/// Карта клавиш блока: `tree:map_key`, `tree:set_mode` и отрисовка вида
/// работают с одной и той же картой.
pub fn keymap_handle(&self) -> Rc<RefCell<KeyMap>> {
self.keymap.clone()
}
/// Забирает отложенный запрос фокуса. /// Забирает отложенный запрос фокуса.
pub fn take_focus_request(&mut self) -> bool { pub fn take_focus_request(&mut self) -> bool {
std::mem::take(&mut self.focus_requested) std::mem::take(&mut self.focus_requested)