gutter and keys

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