gutter and keys
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+314
-16
@@ -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`, если хотя бы одна привязка сработала. Заём карты
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
/// Условное имя, по которому сервер останавливают и перезапускают.
|
/// Условное имя, по которому сервер останавливают и перезапускают.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user