files
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
# Lua API
|
# Lua API
|
||||||
|
|
||||||
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект `app`
|
Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект `app`
|
||||||
для управления окном, вкладками, подсветкой и LSP-серверами.
|
для управления окном, вкладками, панелями, файловой системой, подсветкой и
|
||||||
|
LSP-серверами.
|
||||||
|
|
||||||
## Запуск и каталог сценариев
|
## Запуск и каталог сценариев
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ cargo run -- main.go
|
|||||||
|
|
||||||
| Метод | Описание |
|
| Метод | Описание |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `app:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs" }, ...}` |
|
| `app:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs", icon = "file" }, ...}` |
|
||||||
| `app:active_tab()` | Индекс активной вкладки |
|
| `app:active_tab()` | Индекс активной вкладки |
|
||||||
| `app:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует |
|
| `app:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует |
|
||||||
| `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
|
| `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
|
||||||
@@ -34,6 +35,25 @@ cargo run -- main.go
|
|||||||
| `app:open_tab()` | Открыть пустую вкладку, вернуть индекс |
|
| `app:open_tab()` | Открыть пустую вкладку, вернуть индекс |
|
||||||
| `app:open(path)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем |
|
| `app:open(path)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем |
|
||||||
| `app:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта |
|
| `app:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта |
|
||||||
|
| `app:set_tab_icon(index, name)` | Назначить иконку вкладке; `false`, если вкладки нет или иконка неизвестна |
|
||||||
|
| `app:clear_tab_icon(index)` | Вернуть иконку по умолчанию (`file`) |
|
||||||
|
| `app:tab_icon(index)` | Имя иконки вкладки или `nil`, если вкладки нет |
|
||||||
|
|
||||||
|
### Иконки вкладок
|
||||||
|
|
||||||
|
Слева от названия вкладки показывается иконка; по умолчанию это `file`.
|
||||||
|
Имя — название иконки [Lucide](https://lucide.dev/icons/), регистр не важен.
|
||||||
|
Например:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
app:set_tab_icon(0, "file-code") -- main.rs
|
||||||
|
app:set_tab_icon(1, "book-open") -- notes.md
|
||||||
|
app:clear_tab_icon(1) -- снова "file"
|
||||||
|
```
|
||||||
|
|
||||||
|
Подчёркивания и пробелы считаются дефисом, а `icons/` и `.svg` можно
|
||||||
|
не писать: `file_code`, `file-code` и `icons/file-code.svg` равнозначны.
|
||||||
|
Неизвестное имя не назначается — метод вернёт `false`.
|
||||||
|
|
||||||
### Текст
|
### Текст
|
||||||
|
|
||||||
@@ -63,6 +83,24 @@ app:set_language(0, "rust")
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `app:save()` | Сохранить активную вкладку в её файл |
|
| `app:save()` | Сохранить активную вкладку в её файл |
|
||||||
|
|
||||||
|
### Файловая система
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `app:list_dir(path)` | Содержимое каталога: `{{ id = "src/main.rs", name = "main.rs", is_dir = false }, ...}` |
|
||||||
|
|
||||||
|
Путь отсчитывается от текущего рабочего каталога процесса. Пустая строка или
|
||||||
|
`"."` означают сам текущий каталог. Поле `id` — путь элемента относительно
|
||||||
|
текущего каталога: его можно передать в `app:open(id)` и использовать как
|
||||||
|
идентификатор узла дерева. Элементы уже отсортированы по имени без учёта
|
||||||
|
регистра.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
for _, entry in ipairs(app:list_dir("src")) do
|
||||||
|
print(entry.name, entry.is_dir)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
### Привязка клавиш
|
### Привязка клавиш
|
||||||
|
|
||||||
`app:map_key(keys, fn)` назначает обработчик на нажатие или отпускание клавиши
|
`app:map_key(keys, fn)` назначает обработчик на нажатие или отпускание клавиши
|
||||||
@@ -190,6 +228,100 @@ app.right_panel:toggle()
|
|||||||
app.bottom_panel:close()
|
app.bottom_panel:close()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Блоки панелей
|
||||||
|
|
||||||
|
Панель может содержать блоки. Блок создаётся через API панели и живёт вместе
|
||||||
|
с ней; сейчас поддерживается один вид блока — абстрактное дерево.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local tree = app.left_panel:add_tree()
|
||||||
|
```
|
||||||
|
|
||||||
|
Редактор отвечает только за отрисовку дерева. Что означает каждый узел,
|
||||||
|
решает сценарий: это могут быть файлы проекта, задачи, настройки — что угодно.
|
||||||
|
Файловое дерево проекта в `init.lua` собирается из `app:list_dir`, но сам
|
||||||
|
редактор о файловой системе в блоке ничего не знает.
|
||||||
|
|
||||||
|
| Метод | Описание |
|
||||||
|
| --- | --- |
|
||||||
|
| `tree:append(nodes, parent)` | Добавить узлы к `parent`; `parent = ""` — корень |
|
||||||
|
| `tree:remove(id)` | Удалить узел и всех потомков рекурсивно; `false`, если узла нет |
|
||||||
|
| `tree:on_toggle(fn)` | Обработчик раскрытия: `fn(expanded, id, loaded)` |
|
||||||
|
| `tree:on_click(fn)` | Обработчик клика по конечной ноде: `fn(id)` |
|
||||||
|
| `tree:selected()` | Идентификатор выбранной строки или `nil` |
|
||||||
|
| `tree:focus(id)` | Раскрыть всех родителей, выбрать узел и передать фокус дереву |
|
||||||
|
|
||||||
|
Узел — таблица:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
id = "src/main.rs", -- строковый идентификатор (путь файла)
|
||||||
|
label = "main.rs", -- отображаемый текст
|
||||||
|
icon = "file-code", -- имя иконки Lucide (необязательно)
|
||||||
|
expandable = false, -- раскрываемая ли нода (папка)
|
||||||
|
expanded = false, -- раскрыта ли она сейчас
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`append` добавляет узлы, а не заменяет дерево. Повторная передача того же `id`
|
||||||
|
обновляет описание узла, но не создаёт копию.
|
||||||
|
|
||||||
|
Дети каждого уровня сортируются по `label` без учёта регистра. Сначала идут
|
||||||
|
раскрываемые узлы (`expandable = true`), затем конечные; внутри каждой группы —
|
||||||
|
по алфавиту.
|
||||||
|
|
||||||
|
В обработчик `on_toggle` приходит `loaded = true`, если дочерние узлы уже
|
||||||
|
добавляли в эту ноду; при первом раскрытии — `false`.
|
||||||
|
|
||||||
|
Клик по строке выбирает её. Выбранную строку можно перемещать стрелками
|
||||||
|
`↑`/`↓`; `↑`/`↓` двигают выбор, `→` раскрывает папку или переходит к первому
|
||||||
|
ребёнку, `←` сворачивает папку или выбирает родителя, `Enter` раскрывает папку
|
||||||
|
или вызывает `on_click` для конечной ноды. Выбор сохраняется, пока панель
|
||||||
|
закрыта; при повторном открытии панели фокус возвращается на выбранную строку.
|
||||||
|
|
||||||
|
Пример отложенной загрузки дерева:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local tree = app.left_panel:add_tree()
|
||||||
|
|
||||||
|
local function load_dir(path)
|
||||||
|
local nodes = {}
|
||||||
|
for _, entry in ipairs(app:list_dir(path)) do
|
||||||
|
nodes[#nodes + 1] = {
|
||||||
|
id = entry.id,
|
||||||
|
label = entry.name,
|
||||||
|
icon = entry.is_dir and "folder" or "file",
|
||||||
|
expandable = entry.is_dir,
|
||||||
|
expanded = false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
tree:append(nodes, path)
|
||||||
|
end
|
||||||
|
|
||||||
|
tree:on_toggle(function(expanded, id, loaded)
|
||||||
|
if expanded and not loaded then
|
||||||
|
load_dir(id)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
tree:on_click(function(id)
|
||||||
|
app:open(id)
|
||||||
|
end)
|
||||||
|
|
||||||
|
app.left_panel:open()
|
||||||
|
load_dir("")
|
||||||
|
|
||||||
|
-- При открытии панели вернуть фокус на последнюю выбранную строку.
|
||||||
|
app:map_key("<C-b>", function()
|
||||||
|
if app.left_panel:toggle() then
|
||||||
|
local selected = tree:selected()
|
||||||
|
if selected then
|
||||||
|
tree:focus(selected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
## Подключение других Lua-файлов
|
## Подключение других Lua-файлов
|
||||||
|
|
||||||
Файлы подключаются из каталога сценариев:
|
Файлы подключаются из каталога сценариев:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
print("[lua] init.lua выполнен")
|
print("[lua] init.lua выполнен")
|
||||||
|
|
||||||
-- Доступные методы объекта app (номера вкладок — с нуля):
|
-- Доступные методы объекта app (номера вкладок — с нуля):
|
||||||
-- app:tabs() -- {{ index = 0, title = "main.rs" }, ...}
|
-- app:tabs() -- {{ index = 0, title = "main.rs", icon = "file" }, ...}
|
||||||
-- app:active_tab() -- индекс активной вкладки
|
-- app:active_tab() -- индекс активной вкладки
|
||||||
-- app:set_active_tab(index) -- true, если вкладка существует
|
-- app:set_active_tab(index) -- true, если вкладка существует
|
||||||
-- app:next_tab() -- переключить на следующую, вернуть индекс
|
-- app:next_tab() -- переключить на следующую, вернуть индекс
|
||||||
@@ -23,7 +23,15 @@ print("[lua] init.lua выполнен")
|
|||||||
-- app:open_tab() -- открыть пустую вкладку, вернуть индекс
|
-- app:open_tab() -- открыть пустую вкладку, вернуть индекс
|
||||||
-- app:open(path) -- открыть файл с диска, вернуть индекс вкладки;
|
-- app:open(path) -- открыть файл с диска, вернуть индекс вкладки;
|
||||||
-- -- если файла нет — пустой буфер с таким именем
|
-- -- если файла нет — пустой буфер с таким именем
|
||||||
|
-- app:list_dir(path) -- {{ id = "src/main.rs", name = "main.rs",
|
||||||
|
-- -- is_dir = false }, ...}; пустой путь —
|
||||||
|
-- -- текущий каталог, id — путь от него
|
||||||
-- app:close_tab(index) -- true, если вкладка закрыта
|
-- 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:active_text() -- текст активной вкладки
|
||||||
-- app:set_active_text(text) -- заменить текст активной вкладки
|
-- app:set_active_text(text) -- заменить текст активной вкладки
|
||||||
-- app:set_language(i, "rust") -- включить подсветку (rust|go|js|json|python)
|
-- app:set_language(i, "rust") -- включить подсветку (rust|go|js|json|python)
|
||||||
@@ -92,6 +100,18 @@ print("[lua] init.lua выполнен")
|
|||||||
-- app.bottom_panel:toggle()
|
-- app.bottom_panel:toggle()
|
||||||
-- app.bottom_panel:is_open()
|
-- app.bottom_panel:is_open()
|
||||||
--
|
--
|
||||||
|
-- Блоки панелей. Сценарий сам решает, что показывает блок: редактор умеет
|
||||||
|
-- рисовать только абстрактное дерево.
|
||||||
|
-- local tree = app.left_panel:add_tree()
|
||||||
|
-- tree:append(nodes, parent_id) -- nodes = { { id=..., label=..., icon=...,
|
||||||
|
-- -- expandable=..., expanded=... }, ... };
|
||||||
|
-- -- parent_id = "" — корень дерева
|
||||||
|
-- tree:remove(id) -- удалить ноду и всех потомков
|
||||||
|
-- tree:on_toggle(fn) -- fn(expanded, id, loaded)
|
||||||
|
-- tree:on_click(fn) -- fn(id) — клик по конечной ноде
|
||||||
|
-- tree:selected() -- id выбранной строки или nil
|
||||||
|
-- tree:focus(id) -- раскрыть родителей, выбрать и дать фокус
|
||||||
|
--
|
||||||
-- Методы добавляются в src/api.rs: всё, что есть в impl AppApi, помеченном
|
-- Методы добавляются в src/api.rs: всё, что есть в impl AppApi, помеченном
|
||||||
-- #[mlua::userdata_impl], автоматически доступно из Lua.
|
-- #[mlua::userdata_impl], автоматически доступно из Lua.
|
||||||
--
|
--
|
||||||
@@ -136,9 +156,56 @@ end
|
|||||||
-- app:map_key("<C-s>", function() app:save() end)
|
-- app:map_key("<C-s>", function() app:save() end)
|
||||||
-- app:map_key("<!Esc>", function() app:close() end)
|
-- app:map_key("<!Esc>", function() app:close() end)
|
||||||
|
|
||||||
|
-- Файловое дерево проекта.
|
||||||
|
--
|
||||||
|
-- Редактор рисует абстрактное дерево; смысл узлов (файлы, папки, что угодно)
|
||||||
|
-- задаёт этот сценарий. Здесь корень — текущий каталог, а каждая папка
|
||||||
|
-- дозагружается при первом раскрытии.
|
||||||
|
local project_tree = app.left_panel:add_tree()
|
||||||
|
|
||||||
|
local function load_dir(tree, path)
|
||||||
|
local nodes = {}
|
||||||
|
for _, entry in ipairs(app:list_dir(path)) do
|
||||||
|
nodes[#nodes + 1] = {
|
||||||
|
id = entry.id,
|
||||||
|
label = entry.name,
|
||||||
|
icon = entry.is_dir and "folder" or "file",
|
||||||
|
expandable = entry.is_dir,
|
||||||
|
expanded = false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
tree:append(nodes, path)
|
||||||
|
end
|
||||||
|
|
||||||
|
project_tree:on_toggle(function(expanded, id, loaded)
|
||||||
|
-- loaded == false только при первом раскрытии: позже дети уже в дереве.
|
||||||
|
if expanded and not loaded then
|
||||||
|
load_dir(project_tree, id)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
project_tree:on_click(function(id)
|
||||||
|
app:open(id)
|
||||||
|
end)
|
||||||
|
|
||||||
|
app.left_panel:open()
|
||||||
|
load_dir(project_tree, "")
|
||||||
|
|
||||||
|
-- Открытие/закрытие панели с деревом. При открытии возвращаем фокус на
|
||||||
|
-- последнюю выбранную строку, чтобы можно было сразу продолжить навигацию.
|
||||||
|
app:map_key("<C-b>", function()
|
||||||
|
local opened = app.left_panel:toggle()
|
||||||
|
if opened then
|
||||||
|
local selected = project_tree:selected()
|
||||||
|
if selected then
|
||||||
|
project_tree:focus(selected)
|
||||||
|
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>", function() app.right_panel:toggle() end)
|
app:map_key("<C-e>", function() app.left_panel:toggle() end)
|
||||||
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
|
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
|
||||||
|
|
||||||
app:open_tab()
|
app:open_tab()
|
||||||
|
|||||||
+241
@@ -18,9 +18,11 @@ use serde_json::Number;
|
|||||||
|
|
||||||
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
|
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
|
||||||
use crate::editor::{Language, Workspace};
|
use crate::editor::{Language, Workspace};
|
||||||
|
use crate::icons;
|
||||||
use crate::keymap::{KeyBinding, KeyMap};
|
use crate::keymap::{KeyBinding, KeyMap};
|
||||||
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri};
|
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri};
|
||||||
use crate::panel::{PanelHandle, PanelState};
|
use crate::panel::{PanelHandle, PanelState};
|
||||||
|
use crate::tree::{TreeHandle, TreeNode};
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// Текущий экземпляр API. `thread_local`, потому что UI GPUI привязан к
|
/// Текущий экземпляр API. `thread_local`, потому что UI GPUI привязан к
|
||||||
@@ -68,6 +70,22 @@ pub struct TabInfo {
|
|||||||
pub index: usize,
|
pub index: usize,
|
||||||
/// Название — то же, что показано в заголовке вкладки.
|
/// Название — то же, что показано в заголовке вкладки.
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
/// Имя иконки вкладки; по умолчанию — `file`.
|
||||||
|
pub icon: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Элемент каталога, возвращаемый `app:list_dir(path)`.
|
||||||
|
///
|
||||||
|
/// `id` — путь относительно текущего каталога процесса: его можно сразу
|
||||||
|
/// положить в `id` узла дерева и передать в `app:open(id)`.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct DirEntryInfo {
|
||||||
|
/// Путь элемента относительно текущего каталога.
|
||||||
|
pub id: String,
|
||||||
|
/// Имя файла или каталога без пути.
|
||||||
|
pub name: String,
|
||||||
|
/// Является ли элемент каталогом.
|
||||||
|
pub is_dir: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Глобальный объект с функциями управления приложением.
|
/// Глобальный объект с функциями управления приложением.
|
||||||
@@ -113,6 +131,93 @@ impl PanelApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// API одного блока дерева.
|
||||||
|
///
|
||||||
|
/// Объект возвращает `app.left_panel:add_tree()`. Редактор не знает, что
|
||||||
|
/// означают узлы: сценарий сам решает, будет это файловое дерево проекта или
|
||||||
|
/// что-то ещё, и передаёт готовый список узлов в [`TreeApi::append`].
|
||||||
|
#[derive(mlua::UserData)]
|
||||||
|
pub struct TreeApi {
|
||||||
|
#[lua(skip)]
|
||||||
|
state: TreeHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeApi {
|
||||||
|
fn new(state: TreeHandle) -> Self {
|
||||||
|
Self { state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[mlua::userdata_impl]
|
||||||
|
impl TreeApi {
|
||||||
|
/// Добавляет узлы к родительскому узлу `parent`.
|
||||||
|
///
|
||||||
|
/// `nodes` — массив таблиц:
|
||||||
|
///
|
||||||
|
/// ```lua
|
||||||
|
/// {
|
||||||
|
/// id = "src/main.rs", -- строковый идентификатор (путь файла)
|
||||||
|
/// label = "main.rs", -- отображаемый текст
|
||||||
|
/// icon = "file-code", -- имя иконки Lucide (необязательно)
|
||||||
|
/// expandable = false, -- раскрываемая ли нода (папка)
|
||||||
|
/// expanded = false, -- раскрыта ли она сейчас
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Пустая строка в `parent` означает корень дерева. Существующие узлы не
|
||||||
|
/// удаляются: повторная передача того же `id` обновляет узел.
|
||||||
|
pub fn append(&self, nodes: mlua::Table, parent: Option<String>) -> mlua::Result<()> {
|
||||||
|
let nodes = parse_tree_nodes(&nodes)?;
|
||||||
|
let parent = parent.unwrap_or_default();
|
||||||
|
self.state
|
||||||
|
.borrow_mut()
|
||||||
|
.append(&parent, nodes)
|
||||||
|
.map_err(mlua::Error::RuntimeError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаляет узел по `id` вместе со всеми дочерними узлами рекурсивно.
|
||||||
|
///
|
||||||
|
/// Возвращает `false`, если узла с таким идентификатором нет.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn remove(&self, id: String) -> bool {
|
||||||
|
self.state.borrow_mut().remove(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Назначает обработчик раскрытия/сворачивания узла.
|
||||||
|
///
|
||||||
|
/// Обработчик получает `(expanded, id, loaded)`, где `expanded` — новая
|
||||||
|
/// нода раскрыта, `id` — идентификатор узла, `loaded` — были ли в узел уже
|
||||||
|
/// загружены дочерние элементы. После первого добавления детей всегда
|
||||||
|
/// приходит `true`.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn on_toggle(&self, callback: mlua::Function) {
|
||||||
|
self.state.borrow_mut().set_toggle_callback(Some(callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Назначает обработчик клика по конечной ноде.
|
||||||
|
///
|
||||||
|
/// Обработчик получает `(id)` — идентификатор узла.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn on_click(&self, callback: mlua::Function) {
|
||||||
|
self.state.borrow_mut().set_click_callback(Some(callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Идентификатор выбранной строки или `nil`.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn selected(&self) -> Option<String> {
|
||||||
|
self.state.borrow().selected().map(str::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Раскрывает всех закрытых родителей узла `id`, выбирает его и передаёт
|
||||||
|
/// фокус дереву.
|
||||||
|
///
|
||||||
|
/// Возвращает `false`, если узла с таким идентификатором нет.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn focus(&self, id: String) -> bool {
|
||||||
|
self.state.borrow_mut().focus(&id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[mlua::userdata_impl]
|
#[mlua::userdata_impl]
|
||||||
impl PanelApi {
|
impl PanelApi {
|
||||||
/// Открывает панель.
|
/// Открывает панель.
|
||||||
@@ -138,6 +243,17 @@ impl PanelApi {
|
|||||||
pub fn is_open(&self) -> bool {
|
pub fn is_open(&self) -> bool {
|
||||||
self.state.borrow().is_open()
|
self.state.borrow().is_open()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Добавляет в панель блок дерева и возвращает его API.
|
||||||
|
///
|
||||||
|
/// ```lua
|
||||||
|
/// local tree = app.left_panel:add_tree()
|
||||||
|
/// tree:append({ { id = "a", label = "a.txt" } }, "")
|
||||||
|
/// ```
|
||||||
|
#[lua(infallible, name = "add_tree")]
|
||||||
|
pub fn add_tree(&self) -> TreeApi {
|
||||||
|
TreeApi::new(self.state.borrow_mut().add_tree())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[mlua::userdata_impl]
|
#[mlua::userdata_impl]
|
||||||
@@ -208,6 +324,10 @@ impl AppApi {
|
|||||||
.map(|(index, doc)| TabInfo {
|
.map(|(index, doc)| TabInfo {
|
||||||
index,
|
index,
|
||||||
title: doc.name.clone(),
|
title: doc.name.clone(),
|
||||||
|
icon: doc
|
||||||
|
.icon
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| icons::DEFAULT_TAB_ICON.to_owned()),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -362,6 +482,60 @@ impl AppApi {
|
|||||||
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
|
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Читает содержимое каталога (Rust-версия `app:list_dir`).
|
||||||
|
///
|
||||||
|
/// Пустой путь или `"."` означают текущий рабочий каталог процесса.
|
||||||
|
/// Элементы сортируются по имени без учёта регистра. В `id` попадает путь
|
||||||
|
/// относительно текущего каталога, если `path` тоже относительный.
|
||||||
|
#[lua(skip)]
|
||||||
|
pub fn list_dir(&self, path: impl AsRef<Path>) -> std::io::Result<Vec<DirEntryInfo>> {
|
||||||
|
let raw = path.as_ref().as_os_str().to_string_lossy().into_owned();
|
||||||
|
let root = raw.is_empty() || raw.as_str() == ".";
|
||||||
|
let directory = if raw.is_empty() {
|
||||||
|
Path::new(".")
|
||||||
|
} else {
|
||||||
|
path.as_ref()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(directory)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false);
|
||||||
|
let id = if root {
|
||||||
|
name.clone()
|
||||||
|
} else {
|
||||||
|
Path::new(&raw)
|
||||||
|
.join(&name)
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/")
|
||||||
|
};
|
||||||
|
entries.push(DirEntryInfo { id, name, is_dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
a.name
|
||||||
|
.to_lowercase()
|
||||||
|
.cmp(&b.name.to_lowercase())
|
||||||
|
.then_with(|| a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Список содержимого каталога `path` относительно текущего каталога.
|
||||||
|
///
|
||||||
|
/// Каждый элемент — таблица `{ id = "src/main.rs", name = "main.rs",
|
||||||
|
/// is_dir = false }`. Пустая строка или `"."` — текущий каталог. Метод
|
||||||
|
/// предназначен для построения деревьев и списков из Lua.
|
||||||
|
#[lua(name = "list_dir")]
|
||||||
|
fn lua_list_dir(&self, path: Option<String>) -> mlua::Result<Vec<DirEntryInfo>> {
|
||||||
|
let path = path.unwrap_or_default();
|
||||||
|
self.list_dir(&path).map_err(|err| {
|
||||||
|
let shown = if path.is_empty() { "." } else { path.as_str() };
|
||||||
|
mlua::Error::RuntimeError(format!("не удалось прочитать каталог {shown}: {err}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
|
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
|
||||||
///
|
///
|
||||||
/// Возвращает `false`, если вкладки с таким номером нет.
|
/// Возвращает `false`, если вкладки с таким номером нет.
|
||||||
@@ -375,6 +549,50 @@ impl AppApi {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Назначает иконку вкладке `index`.
|
||||||
|
///
|
||||||
|
/// `icon` — имя иконки Lucide: `file`, `file-code`, `book-open`, `settings`
|
||||||
|
/// и т. п. Регистр не важен; `_` и пробелы равны дефису, префикс `icons/`
|
||||||
|
/// и расширение `.svg` можно не писать.
|
||||||
|
///
|
||||||
|
/// Возвращает `false`, если вкладки нет или иконка с таким именем не входит
|
||||||
|
/// в каталог.
|
||||||
|
#[lua(skip)]
|
||||||
|
pub fn set_tab_icon(&self, index: usize, icon: &str) -> bool {
|
||||||
|
let Some(name) = icons::canonical_name(icon) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.workspace.borrow_mut().set_tab_icon(index, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lua-версия [`AppApi::set_tab_icon`].
|
||||||
|
#[lua(infallible, name = "set_tab_icon")]
|
||||||
|
fn lua_set_tab_icon(&self, index: usize, icon: String) -> bool {
|
||||||
|
self.set_tab_icon(index, &icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает вкладке иконку по умолчанию (`file`).
|
||||||
|
///
|
||||||
|
/// Возвращает `false`, если вкладки с таким номером нет.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn clear_tab_icon(&self, index: usize) -> bool {
|
||||||
|
self.workspace.borrow_mut().clear_tab_icon(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Имя иконки вкладки: назначенное через [`AppApi::set_tab_icon`] или
|
||||||
|
/// `file` по умолчанию. `nil`, если вкладки нет.
|
||||||
|
#[lua(infallible)]
|
||||||
|
pub fn tab_icon(&self, index: usize) -> Option<String> {
|
||||||
|
let workspace = self.workspace.borrow();
|
||||||
|
workspace.document(index)?;
|
||||||
|
Some(
|
||||||
|
workspace
|
||||||
|
.tab_icon(index)
|
||||||
|
.unwrap_or(icons::DEFAULT_TAB_ICON)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Включает подсветку синтаксиса для вкладки `index`.
|
/// Включает подсветку синтаксиса для вкладки `index`.
|
||||||
///
|
///
|
||||||
/// `language` — название языка: `rust`, `go`, `js`, `json` или `python`
|
/// `language` — название языка: `rust`, `go`, `js`, `json` или `python`
|
||||||
@@ -650,6 +868,29 @@ fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Разбирает массив узлов дерева, переданный в `TreeApi::append`.
|
||||||
|
fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> {
|
||||||
|
let mut parsed = Vec::new();
|
||||||
|
for pair in nodes.sequence_values::<mlua::Table>() {
|
||||||
|
let table = pair?;
|
||||||
|
let id: String = table.get("id")?;
|
||||||
|
let label = table.get::<String>("label").unwrap_or_else(|_| id.clone());
|
||||||
|
let icon = table
|
||||||
|
.get::<Option<String>>("icon")?
|
||||||
|
.and_then(|name| icons::canonical_name(&name).map(str::to_owned));
|
||||||
|
let expandable = table.get::<Option<bool>>("expandable")?.unwrap_or(false);
|
||||||
|
let expanded = table.get::<Option<bool>>("expanded")?.unwrap_or(false);
|
||||||
|
parsed.push(TreeNode {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
expandable,
|
||||||
|
expanded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
fn lua_strings(value: mlua::Value) -> mlua::Result<Vec<String>> {
|
fn lua_strings(value: mlua::Value) -> mlua::Result<Vec<String>> {
|
||||||
match value {
|
match value {
|
||||||
mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]),
|
mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]),
|
||||||
|
|||||||
+338
-55
@@ -5,7 +5,7 @@
|
|||||||
//! а `Editor` показывает активный документ; синхронизация выполняется в
|
//! а `Editor` показывает активный документ; синхронизация выполняется в
|
||||||
//! `render`, когда меняется [`Workspace::revision`].
|
//! `render`, когда меняется [`Workspace::revision`].
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -18,8 +18,8 @@ use gpui_kit::component::resizable::HandleEdge;
|
|||||||
use gpui_kit::component::tab::{Tab, TabBar};
|
use gpui_kit::component::tab::{Tab, TabBar};
|
||||||
use gpui_kit::component::text::TextView;
|
use gpui_kit::component::text::TextView;
|
||||||
use gpui_kit::component::{
|
use gpui_kit::component::{
|
||||||
ActiveTheme, Icon, IconName, Sizable, StyledExt, TitleBar, h_flex, h_resizable,
|
ActiveTheme, Icon, Sizable, StyledExt, TitleBar, h_flex, h_resizable, resizable_panel,
|
||||||
resizable_panel, resize_handle, v_resizable,
|
resize_handle, v_resizable,
|
||||||
};
|
};
|
||||||
use gpui_kit::*;
|
use gpui_kit::*;
|
||||||
use lsp_types::{CompletionItem, Hover, TextEdit};
|
use lsp_types::{CompletionItem, Hover, TextEdit};
|
||||||
@@ -32,10 +32,12 @@ use crate::completion::{
|
|||||||
};
|
};
|
||||||
use crate::constants::{MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE, WINDOW_TITLE};
|
use crate::constants::{MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE, WINDOW_TITLE};
|
||||||
use crate::editor::Workspace;
|
use crate::editor::Workspace;
|
||||||
|
use crate::icons;
|
||||||
use crate::keymap::KeyMap;
|
use crate::keymap::KeyMap;
|
||||||
use crate::lsp::{LspManager, document_uri, position_to_offset};
|
use crate::lsp::{LspManager, document_uri, position_to_offset};
|
||||||
use crate::panel::PanelHandle;
|
use crate::panel::{PanelBlock, PanelHandle};
|
||||||
use crate::script::ScriptEngine;
|
use crate::script::ScriptEngine;
|
||||||
|
use crate::tree::{TreeAction, TreeHandle, TreeKeyResult};
|
||||||
|
|
||||||
/// Payload перетаскивания разделителя левой панели.
|
/// Payload перетаскивания разделителя левой панели.
|
||||||
///
|
///
|
||||||
@@ -140,6 +142,9 @@ pub struct MyApp {
|
|||||||
right_panel: PanelHandle,
|
right_panel: PanelHandle,
|
||||||
/// Состояние нижней панели, общее с `app.bottom_panel` в Lua.
|
/// Состояние нижней панели, общее с `app.bottom_panel` в Lua.
|
||||||
bottom_panel: PanelHandle,
|
bottom_panel: PanelHandle,
|
||||||
|
/// Открытость панелей в предыдущем кадре: по переходу «закрыта →
|
||||||
|
/// открыта» вид возвращает фокус в дерево, которое было выбрано раньше.
|
||||||
|
panel_open_state: Cell<[bool; 3]>,
|
||||||
/// Виджет редактора, показывающий активный документ.
|
/// Виджет редактора, показывающий активный документ.
|
||||||
editor: Entity<EditorState>,
|
editor: Entity<EditorState>,
|
||||||
/// Ревизия [`Workspace`], уже перенесённая в `editor`.
|
/// Ревизия [`Workspace`], уже перенесённая в `editor`.
|
||||||
@@ -273,6 +278,7 @@ impl MyApp {
|
|||||||
left_panel,
|
left_panel,
|
||||||
right_panel,
|
right_panel,
|
||||||
bottom_panel,
|
bottom_panel,
|
||||||
|
panel_open_state: Cell::new([false; 3]),
|
||||||
editor,
|
editor,
|
||||||
synced_revision: 0,
|
synced_revision: 0,
|
||||||
displayed_id,
|
displayed_id,
|
||||||
@@ -1057,6 +1063,225 @@ impl MyApp {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Содержимое панели: блоки в порядке добавления.
|
||||||
|
///
|
||||||
|
/// Если блоков нет, правая панель показывает список LSP-серверов, как и
|
||||||
|
/// раньше, а остальные панели остаются пустыми.
|
||||||
|
fn render_panel_content(
|
||||||
|
&self,
|
||||||
|
panel: &PanelHandle,
|
||||||
|
lsp_fallback: bool,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let blocks = panel.borrow().blocks().to_vec();
|
||||||
|
if blocks.is_empty() {
|
||||||
|
return if lsp_fallback {
|
||||||
|
self.render_right_panel(cx)
|
||||||
|
} else {
|
||||||
|
div().size_full().into_any_element()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut content = div().size_full().flex().flex_col().min_h_0();
|
||||||
|
for block in blocks {
|
||||||
|
let element = match block {
|
||||||
|
PanelBlock::Tree(tree) => self.render_tree_block(&tree, window, cx),
|
||||||
|
};
|
||||||
|
content = content.child(div().w_full().flex_1().min_h_0().child(element));
|
||||||
|
}
|
||||||
|
content.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рисует один блок абстрактного дерева.
|
||||||
|
///
|
||||||
|
/// Узлы и их иерархию целиком задаёт Lua; вид только сортирует видимые
|
||||||
|
/// строки (это делает [`crate::tree::TreeState::rows`]) и превращает
|
||||||
|
/// клики в вызовы обработчиков.
|
||||||
|
fn render_tree_block(
|
||||||
|
&self,
|
||||||
|
tree: &TreeHandle,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let (rows, selected, focus_handle, focus_requested, scroll) = {
|
||||||
|
let mut state = tree.borrow_mut();
|
||||||
|
let focus_handle = state.focus_handle(cx);
|
||||||
|
let scroll = state.scroll_handle();
|
||||||
|
let focus_requested = state.take_focus_request();
|
||||||
|
let rows = state.rows();
|
||||||
|
let selected = state.selected().map(str::to_owned);
|
||||||
|
(rows, selected, focus_handle, focus_requested, scroll)
|
||||||
|
};
|
||||||
|
if focus_requested {
|
||||||
|
focus_handle.focus(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let entity = cx.entity();
|
||||||
|
let tree_ptr = Rc::as_ptr(tree) as usize;
|
||||||
|
let mut list = div()
|
||||||
|
.id(ElementId::Name(format!("tree-block-{tree_ptr}").into()))
|
||||||
|
.track_focus(&focus_handle)
|
||||||
|
.track_scroll(&scroll)
|
||||||
|
.on_key_down({
|
||||||
|
let handle = tree.clone();
|
||||||
|
let entity = entity.clone();
|
||||||
|
move |event, _window, cx| {
|
||||||
|
let result = handle.borrow_mut().handle_key(&event.keystroke.key);
|
||||||
|
let handled = match result {
|
||||||
|
TreeKeyResult::Ignored => false,
|
||||||
|
TreeKeyResult::Handled => true,
|
||||||
|
TreeKeyResult::Action(action) => {
|
||||||
|
run_tree_action(action);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if handled {
|
||||||
|
cx.stop_propagation();
|
||||||
|
entity.update(cx, |_, cx| cx.notify());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.size_full()
|
||||||
|
.overflow_y_scroll()
|
||||||
|
.py_1();
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
list = list.child(
|
||||||
|
div()
|
||||||
|
.p_2()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child("Дерево пусто"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let handle = tree.clone();
|
||||||
|
let entity = entity.clone();
|
||||||
|
let id = row.id.clone();
|
||||||
|
let indent = 8.0 + row.depth as f32 * 14.0;
|
||||||
|
let icon = icons::from_name(row.icon.as_deref().unwrap_or(if row.expandable {
|
||||||
|
"folder"
|
||||||
|
} else {
|
||||||
|
"file"
|
||||||
|
}))
|
||||||
|
.unwrap_or(if row.expandable {
|
||||||
|
AssetIcon::Folder
|
||||||
|
} else {
|
||||||
|
AssetIcon::File
|
||||||
|
});
|
||||||
|
let chevron = row.expandable.then_some(if row.expanded {
|
||||||
|
AssetIcon::ChevronDown
|
||||||
|
} else {
|
||||||
|
AssetIcon::ChevronRight
|
||||||
|
});
|
||||||
|
let is_selected = selected.as_deref() == Some(row.id.as_str());
|
||||||
|
let hover_bg = cx.theme().list_hover;
|
||||||
|
let selected_bg = cx.theme().list_active;
|
||||||
|
|
||||||
|
let mut line = h_flex()
|
||||||
|
.id(ElementId::Name(
|
||||||
|
format!("tree-row-{tree_ptr}-{}", row.id).into(),
|
||||||
|
))
|
||||||
|
.w_full()
|
||||||
|
.items_center()
|
||||||
|
.gap_1()
|
||||||
|
.pl(px(indent))
|
||||||
|
.pr_2()
|
||||||
|
.py_1()
|
||||||
|
.rounded_sm()
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(move |style| {
|
||||||
|
if is_selected {
|
||||||
|
style
|
||||||
|
} else {
|
||||||
|
style.bg(hover_bg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_click({
|
||||||
|
let focus_handle = focus_handle.clone();
|
||||||
|
move |_, window, cx| {
|
||||||
|
let action = {
|
||||||
|
let mut state = handle.borrow_mut();
|
||||||
|
state.focus(&id);
|
||||||
|
state.activate(&id)
|
||||||
|
};
|
||||||
|
// Фокус применяем сразу, а отложенный запрос из
|
||||||
|
// `TreeState::focus` переносит его в следующий кадр,
|
||||||
|
// если синхронизация редактора успеет забрать фокус.
|
||||||
|
focus_handle.focus(window, cx);
|
||||||
|
if let Some(action) = action {
|
||||||
|
run_tree_action(action);
|
||||||
|
}
|
||||||
|
entity.update(cx, |_, cx| cx.notify());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if is_selected {
|
||||||
|
line = line.bg(selected_bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
line = match chevron {
|
||||||
|
Some(chevron) => line.child(
|
||||||
|
div().size(px(14.)).flex_shrink_0().child(
|
||||||
|
Icon::new(chevron)
|
||||||
|
.xsmall()
|
||||||
|
.text_color(cx.theme().muted_foreground),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
None => line.child(div().w(px(14.)).flex_shrink_0()),
|
||||||
|
};
|
||||||
|
line = line
|
||||||
|
.child(
|
||||||
|
Icon::new(icon)
|
||||||
|
.small()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_color(cx.theme().sidebar_foreground),
|
||||||
|
)
|
||||||
|
.child(div().text_sm().min_w_0().truncate().child(row.label));
|
||||||
|
|
||||||
|
list = list.child(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
div().size_full().min_h_0().child(list).into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает фокус в дерево, когда его панель открывается.
|
||||||
|
///
|
||||||
|
/// Фокус получает только панель, которая была закрыта в прошлом кадре и
|
||||||
|
/// открылась сейчас; внутри неё выбирается первое дерево с выбранной
|
||||||
|
/// строкой. Если строку ещё ни разу не выбирали, фокус не забирается.
|
||||||
|
fn sync_panel_focus(&self, open: [bool; 3], window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let previous = self.panel_open_state.get();
|
||||||
|
let panels = [&self.left_panel, &self.right_panel, &self.bottom_panel];
|
||||||
|
for (index, panel) in panels.into_iter().enumerate() {
|
||||||
|
if !open[index] || previous[index] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let tree = panel
|
||||||
|
.borrow()
|
||||||
|
.blocks()
|
||||||
|
.iter()
|
||||||
|
.find_map(|block| match block {
|
||||||
|
PanelBlock::Tree(tree) => Some(tree.clone()),
|
||||||
|
});
|
||||||
|
let Some(tree) = tree else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let handle = {
|
||||||
|
let mut state = tree.borrow_mut();
|
||||||
|
if state.selected().is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
state.focus_handle(cx)
|
||||||
|
};
|
||||||
|
handle.focus(window, cx);
|
||||||
|
}
|
||||||
|
self.panel_open_state.set(open);
|
||||||
|
}
|
||||||
|
|
||||||
/// Переносит изменения модели в виджет редактора.
|
/// Переносит изменения модели в виджет редактора.
|
||||||
///
|
///
|
||||||
/// `set_value` сбрасывает выделение и прокрутку, поэтому вызывается только
|
/// `set_value` сбрасывает выделение и прокрутку, поэтому вызывается только
|
||||||
@@ -1115,8 +1340,13 @@ impl MyApp {
|
|||||||
/// Редактор с левой, правой и нижней панелями.
|
/// Редактор с левой, правой и нижней панелями.
|
||||||
///
|
///
|
||||||
/// Все панели меняют размер перетаскиванием разделителя, а закрытые
|
/// Все панели меняют размер перетаскиванием разделителя, а закрытые
|
||||||
/// панели не занимают места. Правая панель показывает статус LSP.
|
/// панели не занимают места. В каждой панели рисуются добавленные
|
||||||
fn render_workspace(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
|
/// сценарием блоки; правая панель без блоков показывает статус LSP.
|
||||||
|
fn render_workspace(
|
||||||
|
&mut self,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
let (left_open, left_size) = {
|
let (left_open, left_size) = {
|
||||||
let panel = self.left_panel.borrow();
|
let panel = self.left_panel.borrow();
|
||||||
(panel.is_open(), panel.size())
|
(panel.is_open(), panel.size())
|
||||||
@@ -1135,16 +1365,25 @@ impl MyApp {
|
|||||||
(panel.is_open(), panel.size())
|
(panel.is_open(), panel.size())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Панель могли открыть из Lua или кнопкой: если до этого в её дереве
|
||||||
|
// была выбрана строка, возвращаем туда фокус.
|
||||||
|
self.sync_panel_focus([left_open, right_open, bottom_open], window, cx);
|
||||||
|
|
||||||
// Нижняя панель занимает ширину редактора, а правая тянется на всю
|
// Нижняя панель занимает ширину редактора, а правая тянется на всю
|
||||||
// высоту — как в большинстве редакторов кода.
|
// высоту — как в большинстве редакторов кода.
|
||||||
let editor_area = v_resizable("editor-area")
|
let editor_area =
|
||||||
.child(resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()))
|
v_resizable("editor-area")
|
||||||
.child(
|
.child(
|
||||||
resizable_panel()
|
resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()),
|
||||||
.size(px(bottom_size))
|
)
|
||||||
.visible(bottom_open)
|
.child(
|
||||||
.child(div().size_full().bg(cx.theme().sidebar)),
|
resizable_panel()
|
||||||
);
|
.size(px(bottom_size))
|
||||||
|
.visible(bottom_open)
|
||||||
|
.child(div().size_full().bg(cx.theme().sidebar).child(
|
||||||
|
self.render_panel_content(&self.bottom_panel, false, window, cx),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
let area = h_resizable("main-area")
|
let area = h_resizable("main-area")
|
||||||
.child(resizable_panel().child(editor_area))
|
.child(resizable_panel().child(editor_area))
|
||||||
@@ -1156,7 +1395,7 @@ impl MyApp {
|
|||||||
div()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
.bg(cx.theme().sidebar)
|
.bg(cx.theme().sidebar)
|
||||||
.child(self.render_right_panel(cx)),
|
.child(self.render_panel_content(&self.right_panel, true, window, cx)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1169,6 +1408,7 @@ impl MyApp {
|
|||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.relative()
|
.relative()
|
||||||
.bg(cx.theme().sidebar)
|
.bg(cx.theme().sidebar)
|
||||||
|
.child(self.render_panel_content(&self.left_panel, false, window, cx))
|
||||||
.child(
|
.child(
|
||||||
resize_handle("left-panel-handle", Axis::Horizontal)
|
resize_handle("left-panel-handle", Axis::Horizontal)
|
||||||
.inside(HandleEdge::Trailing)
|
.inside(HandleEdge::Trailing)
|
||||||
@@ -1192,7 +1432,8 @@ impl MyApp {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Полоса вкладок: активная вкладка, маркер несохранённых изменений и «+».
|
/// Полоса вкладок в стиле folder tabs: иконка, название, маркер
|
||||||
|
/// несохранённых изменений и крестик закрытия, а также «+».
|
||||||
fn render_tabs(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_tabs(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let (tabs, active) = {
|
let (tabs, active) = {
|
||||||
let workspace = self.workspace.borrow();
|
let workspace = self.workspace.borrow();
|
||||||
@@ -1200,65 +1441,79 @@ impl MyApp {
|
|||||||
workspace
|
workspace
|
||||||
.documents()
|
.documents()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|doc| (doc.name.clone(), doc.is_dirty()))
|
.map(|doc| {
|
||||||
|
(
|
||||||
|
doc.name.clone(),
|
||||||
|
doc.is_dirty(),
|
||||||
|
icons::tab_icon(doc.icon.as_deref()),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
workspace.active_index(),
|
workspace.active_index(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let entity = cx.entity();
|
let entity = cx.entity();
|
||||||
let mut bar = TabBar::new("tabs").selected_index(active);
|
let mut bar = TabBar::new("tabs").selected_index(active).folder();
|
||||||
|
|
||||||
for (index, (name, dirty)) in tabs.into_iter().enumerate() {
|
for (index, (name, dirty, icon)) in tabs.into_iter().enumerate() {
|
||||||
// У несохранённого документа вместо крестика горит точка — как в
|
// Точка и крестик живут рядом: у несохранённой вкладки закрытие
|
||||||
// исходном приложении.
|
// тоже должно оставаться доступным.
|
||||||
let suffix: AnyElement = if dirty {
|
let mut suffix = h_flex().items_center().gap_1();
|
||||||
div()
|
if dirty {
|
||||||
.size(px(6.))
|
suffix = suffix.child(
|
||||||
.rounded_full()
|
div()
|
||||||
.bg(cx.theme().primary)
|
.size(px(6.))
|
||||||
.into_any_element()
|
.rounded_full()
|
||||||
} else {
|
.bg(cx.theme().primary)
|
||||||
let entity = entity.clone();
|
.flex_shrink_0(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
suffix = suffix.child(
|
||||||
Button::new(format!("close-tab-{index}"))
|
Button::new(format!("close-tab-{index}"))
|
||||||
.xsmall()
|
.xsmall()
|
||||||
.ghost()
|
.ghost()
|
||||||
.icon(IconName::Close)
|
// Полный каталог Lucide, а не компактный набор компонентов.
|
||||||
|
.icon(AssetIcon::X)
|
||||||
.tooltip("Закрыть вкладку")
|
.tooltip("Закрыть вкладку")
|
||||||
.on_click(move |_, _, cx| {
|
.on_click({
|
||||||
// Иначе клик закроет вкладку и тут же переключит на неё
|
let entity = entity.clone();
|
||||||
// соседнюю по уже устаревшему индексу.
|
move |_, _, cx| {
|
||||||
cx.stop_propagation();
|
// Иначе клик закроет вкладку и тут же переключит
|
||||||
entity.update(cx, |this, cx| {
|
// на соседнюю по устаревшему индексу.
|
||||||
let uri = this
|
cx.stop_propagation();
|
||||||
.workspace
|
entity.update(cx, |_, cx| {
|
||||||
.borrow()
|
AppApi::global().close_tab(index);
|
||||||
.document(index)
|
cx.notify();
|
||||||
.and_then(|doc| document_uri(&doc.name));
|
});
|
||||||
this.workspace.borrow_mut().close(index);
|
}
|
||||||
if let Some(uri) = uri {
|
}),
|
||||||
this.lsp.close_document(&uri);
|
);
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
};
|
|
||||||
|
|
||||||
bar = bar.child(Tab::new().label(name).suffix(suffix));
|
bar = bar.child(
|
||||||
|
Tab::new()
|
||||||
|
.prefix(
|
||||||
|
Icon::new(icon)
|
||||||
|
.small()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_color(cx.theme().tab_foreground),
|
||||||
|
)
|
||||||
|
.label(name)
|
||||||
|
.suffix(suffix),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bar.suffix(
|
bar.suffix(
|
||||||
Button::new("new-tab")
|
Button::new("new-tab")
|
||||||
.xsmall()
|
.xsmall()
|
||||||
.ghost()
|
.ghost()
|
||||||
.icon(IconName::Plus)
|
.icon(AssetIcon::Plus)
|
||||||
.tooltip("Новая вкладка")
|
.tooltip("Новая вкладка")
|
||||||
.on_click({
|
.on_click({
|
||||||
let entity = entity.clone();
|
let entity = entity.clone();
|
||||||
move |_, _, cx| {
|
move |_, _, cx| {
|
||||||
entity.update(cx, |this, cx| {
|
entity.update(cx, |_, cx| {
|
||||||
this.workspace.borrow_mut().open_new();
|
AppApi::global().open_tab();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1267,8 +1522,8 @@ impl MyApp {
|
|||||||
.on_click({
|
.on_click({
|
||||||
let entity = entity.clone();
|
let entity = entity.clone();
|
||||||
move |index, _, cx| {
|
move |index, _, cx| {
|
||||||
entity.update(cx, |this, cx| {
|
entity.update(cx, |_, cx| {
|
||||||
this.workspace.borrow_mut().set_active(*index);
|
AppApi::global().set_active_tab(*index);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1395,6 +1650,34 @@ fn is_completion_shortcut(keystroke: &Keystroke) -> bool {
|
|||||||
&& !keystroke.modifiers.alt
|
&& !keystroke.modifiers.alt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Вызывает Lua-обработчик, назначенный блоку дерева.
|
||||||
|
///
|
||||||
|
/// Ошибка Lua не должна ломать отрисовку: она печатается в stderr, как и
|
||||||
|
/// ошибки обработчиков клавиш.
|
||||||
|
fn run_tree_action(action: TreeAction) {
|
||||||
|
match action {
|
||||||
|
TreeAction::Toggle {
|
||||||
|
id,
|
||||||
|
expanded,
|
||||||
|
loaded,
|
||||||
|
callback,
|
||||||
|
} => {
|
||||||
|
if let Some(callback) = callback
|
||||||
|
&& let Err(err) = callback.call::<()>((expanded, id, loaded))
|
||||||
|
{
|
||||||
|
eprintln!("Lua: ошибка в обработчике дерева: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TreeAction::Click { id, callback } => {
|
||||||
|
if let Some(callback) = callback
|
||||||
|
&& let Err(err) = callback.call::<()>(id)
|
||||||
|
{
|
||||||
|
eprintln!("Lua: ошибка в обработчике дерева: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Сохраняет активную вкладку, печатая ошибку в stderr.
|
/// Сохраняет активную вкладку, печатая ошибку в stderr.
|
||||||
fn save_active_tab() {
|
fn save_active_tab() {
|
||||||
if let Err(err) = AppApi::global().save() {
|
if let Err(err) = AppApi::global().save() {
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ pub struct Document {
|
|||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
/// Имя иконки вкладки из каталога Lucide (см. `crate::icons`).
|
||||||
|
///
|
||||||
|
/// `None` — показывается иконка по умолчанию. Назначается через
|
||||||
|
/// `AppApi::set_tab_icon`.
|
||||||
|
pub icon: Option<String>,
|
||||||
/// Язык подсветки синтаксиса.
|
/// Язык подсветки синтаксиса.
|
||||||
///
|
///
|
||||||
/// `None` — подсветка выключена (поведение по умолчанию). Включается
|
/// `None` — подсветка выключена (поведение по умолчанию). Включается
|
||||||
@@ -166,6 +171,7 @@ impl Document {
|
|||||||
name: name.into(),
|
name: name.into(),
|
||||||
saved_content: content.clone(),
|
saved_content: content.clone(),
|
||||||
content,
|
content,
|
||||||
|
icon: None,
|
||||||
language: None,
|
language: None,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
}
|
}
|
||||||
@@ -380,6 +386,43 @@ impl Workspace {
|
|||||||
self.active_mut().mark_saved();
|
self.active_mut().mark_saved();
|
||||||
self.bump();
|
self.bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Назначает вкладке иконку из каталога; возвращает `false`, если вкладки нет.
|
||||||
|
pub fn set_tab_icon(&mut self, index: usize, icon: impl Into<String>) -> bool {
|
||||||
|
let icon = icon.into();
|
||||||
|
let changed = match self.documents.get_mut(index) {
|
||||||
|
Some(document) => {
|
||||||
|
let changed = document.icon.as_deref() != Some(icon.as_str());
|
||||||
|
if changed {
|
||||||
|
document.icon = Some(icon);
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
if changed {
|
||||||
|
self.bump();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает вкладке иконку по умолчанию; `false`, если вкладки нет.
|
||||||
|
pub fn clear_tab_icon(&mut self, index: usize) -> bool {
|
||||||
|
let changed = match self.documents.get_mut(index) {
|
||||||
|
Some(document) => document.icon.take().is_some(),
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
if changed {
|
||||||
|
self.bump();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Имя иконки вкладки, если оно назначено.
|
||||||
|
pub fn tab_icon(&self, index: usize) -> Option<&str> {
|
||||||
|
self.document(index)
|
||||||
|
.and_then(|document| document.icon.as_deref())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAMPLE_RUST: &str = r#"//! Демонстрационный файл.
|
const SAMPLE_RUST: &str = r#"//! Демонстрационный файл.
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//! Иконки вкладок.
|
||||||
|
//!
|
||||||
|
//! Имена совпадают с названиями Lucide (`file`, `file-code`, `book-open`) —
|
||||||
|
//! именно их принимает `app:set_tab_icon(index, name)`. Неизвестное имя не
|
||||||
|
//! подменяется молча: API возвращает `false`, а вкладка продолжает показывать
|
||||||
|
//! иконку по умолчанию.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use gpui_kit::assets::IconName;
|
||||||
|
|
||||||
|
/// Иконка вкладки, пока сценарий не назначил другую.
|
||||||
|
pub const DEFAULT_TAB_ICON: &str = "file";
|
||||||
|
|
||||||
|
/// Каталог иконок по каноническому имени.
|
||||||
|
static ICONS: LazyLock<HashMap<String, IconName>> = LazyLock::new(|| {
|
||||||
|
IconName::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|icon| (asset_name(*icon), *icon))
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Имя иконки в каталоге: `icons/file-code.svg` → `file-code`.
|
||||||
|
fn asset_name(icon: IconName) -> String {
|
||||||
|
let path = icon.path();
|
||||||
|
let path = path.as_str();
|
||||||
|
let stem = path.strip_prefix("icons/").unwrap_or(path);
|
||||||
|
stem.strip_suffix(".svg").unwrap_or(stem).to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Приводит имя из API к виду каталога.
|
||||||
|
///
|
||||||
|
/// Регистр не важен; `_` и пробелы считаются дефисом, префикс `icons/` и
|
||||||
|
/// расширение `.svg` можно не писать.
|
||||||
|
fn normalize(name: &str) -> String {
|
||||||
|
let mut normalized = name.trim().to_ascii_lowercase().replace(['_', ' '], "-");
|
||||||
|
if let Some(rest) = normalized.strip_prefix("icons/") {
|
||||||
|
normalized = rest.to_owned();
|
||||||
|
}
|
||||||
|
if let Some(rest) = normalized.strip_suffix(".svg") {
|
||||||
|
normalized = rest.to_owned();
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Каноническое имя известной иконки.
|
||||||
|
pub fn canonical_name(name: &str) -> Option<&'static str> {
|
||||||
|
let normalized = normalize(name);
|
||||||
|
ICONS
|
||||||
|
.get_key_value(&normalized)
|
||||||
|
.map(|(name, _)| name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Иконка по имени из API; `None`, если такого имени нет в каталоге.
|
||||||
|
pub fn from_name(name: &str) -> Option<IconName> {
|
||||||
|
let normalized = normalize(name);
|
||||||
|
ICONS.get(&normalized).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Иконка вкладки по умолчанию.
|
||||||
|
pub fn default_icon() -> IconName {
|
||||||
|
IconName::File
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Иконка вкладки: назначенная сценарием или стандартная.
|
||||||
|
pub fn tab_icon(name: Option<&str>) -> IconName {
|
||||||
|
name.and_then(from_name).unwrap_or_else(default_icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_are_normalized_before_lookup() {
|
||||||
|
assert_eq!(canonical_name("file"), Some("file"));
|
||||||
|
assert_eq!(canonical_name(" File "), Some("file"));
|
||||||
|
assert_eq!(canonical_name("file_code"), Some("file-code"));
|
||||||
|
assert_eq!(canonical_name("icons/file-code.svg"), Some("file-code"));
|
||||||
|
assert_eq!(canonical_name("not-an-icon"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_icon_is_in_the_catalog() {
|
||||||
|
let icon = default_icon();
|
||||||
|
assert_eq!(from_name(DEFAULT_TAB_ICON), Some(icon));
|
||||||
|
assert_eq!(tab_icon(None), icon);
|
||||||
|
assert_eq!(tab_icon(Some("unknown")), icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,12 @@ mod app;
|
|||||||
mod completion;
|
mod completion;
|
||||||
mod constants;
|
mod constants;
|
||||||
mod editor;
|
mod editor;
|
||||||
|
mod icons;
|
||||||
mod keymap;
|
mod keymap;
|
||||||
mod lsp;
|
mod lsp;
|
||||||
mod panel;
|
mod panel;
|
||||||
mod script;
|
mod script;
|
||||||
|
mod tree;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
+28
-2
@@ -4,21 +4,34 @@
|
|||||||
//! и `app.bottom_panel`, а вид [`MyApp`](crate::app::MyApp) читает его при
|
//! и `app.bottom_panel`, а вид [`MyApp`](crate::app::MyApp) читает его при
|
||||||
//! отрисовке. Как и документы, состояние лежит в `Rc<RefCell<_>>`, чтобы вид и
|
//! отрисовке. Как и документы, состояние лежит в `Rc<RefCell<_>>`, чтобы вид и
|
||||||
//! API видели одни и те же панели.
|
//! API видели одни и те же панели.
|
||||||
|
//!
|
||||||
|
//! Панель состоит из блоков. Блок — это независимый виджет, который сценарий
|
||||||
|
//! создаёт через API панели (сейчас поддерживается [`PanelBlock::Tree`]).
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::tree::{TreeHandle, new_tree};
|
||||||
|
|
||||||
/// Общая ссылка на состояние панели.
|
/// Общая ссылка на состояние панели.
|
||||||
pub type PanelHandle = Rc<RefCell<PanelState>>;
|
pub type PanelHandle = Rc<RefCell<PanelState>>;
|
||||||
|
|
||||||
/// Открыта ли панель и каким размером она раскрывается.
|
/// Блок, добавленный в панель.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum PanelBlock {
|
||||||
|
/// Абстрактное дерево, наполняемое из Lua.
|
||||||
|
Tree(TreeHandle),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Открыта ли панель, каким размером она раскрывается и какие блоки содержит.
|
||||||
///
|
///
|
||||||
/// Размер — начальный: после перетаскивания разделителя виджет
|
/// Размер — начальный: после перетаскивания разделителя виджет
|
||||||
/// `ResizablePanelGroup` запоминает фактический размер панели сам.
|
/// `ResizablePanelGroup` запоминает фактический размер панели сам.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone)]
|
||||||
pub struct PanelState {
|
pub struct PanelState {
|
||||||
visible: bool,
|
visible: bool,
|
||||||
size: f32,
|
size: f32,
|
||||||
|
blocks: Vec<PanelBlock>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PanelState {
|
impl PanelState {
|
||||||
@@ -27,6 +40,7 @@ impl PanelState {
|
|||||||
Self {
|
Self {
|
||||||
visible: false,
|
visible: false,
|
||||||
size: size.max(0.0),
|
size: size.max(0.0),
|
||||||
|
blocks: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,4 +74,16 @@ impl PanelState {
|
|||||||
self.visible = !self.visible;
|
self.visible = !self.visible;
|
||||||
self.visible
|
self.visible
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Блоки панели в порядке добавления.
|
||||||
|
pub fn blocks(&self) -> &[PanelBlock] {
|
||||||
|
&self.blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавляет блок дерева и возвращает доступ к нему.
|
||||||
|
pub fn add_tree(&mut self) -> TreeHandle {
|
||||||
|
let tree = new_tree();
|
||||||
|
self.blocks.push(PanelBlock::Tree(tree.clone()));
|
||||||
|
tree
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -17,19 +17,32 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use mlua::{Lua, Table};
|
use mlua::{Lua, Table};
|
||||||
|
|
||||||
use crate::api::{AppApi, TabInfo};
|
use crate::api::{AppApi, DirEntryInfo, TabInfo};
|
||||||
use crate::constants::{STARTUP_SCRIPT, script_dir};
|
use crate::constants::{STARTUP_SCRIPT, script_dir};
|
||||||
|
|
||||||
/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs" }`.
|
/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs", icon = "file" }`.
|
||||||
impl mlua::IntoLua for TabInfo {
|
impl mlua::IntoLua for TabInfo {
|
||||||
fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
|
fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
|
||||||
let tab = lua.create_table()?;
|
let tab = lua.create_table()?;
|
||||||
tab.set("index", self.index)?;
|
tab.set("index", self.index)?;
|
||||||
tab.set("title", self.title)?;
|
tab.set("title", self.title)?;
|
||||||
|
tab.set("icon", self.icon)?;
|
||||||
Ok(mlua::Value::Table(tab))
|
Ok(mlua::Value::Table(tab))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Элемент каталога превращается в Lua-таблицу
|
||||||
|
/// `{ id = "src/main.rs", name = "main.rs", is_dir = false }`.
|
||||||
|
impl mlua::IntoLua for DirEntryInfo {
|
||||||
|
fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
|
||||||
|
let entry = lua.create_table()?;
|
||||||
|
entry.set("id", self.id)?;
|
||||||
|
entry.set("name", self.name)?;
|
||||||
|
entry.set("is_dir", self.is_dir)?;
|
||||||
|
Ok(mlua::Value::Table(entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Движок Lua и состояние его глобального окружения.
|
/// Движок Lua и состояние его глобального окружения.
|
||||||
pub struct ScriptEngine {
|
pub struct ScriptEngine {
|
||||||
lua: Lua,
|
lua: Lua,
|
||||||
|
|||||||
+253
@@ -13,7 +13,9 @@ use crate::api::{AppApi, WindowCommand, drain_window_commands};
|
|||||||
use crate::app::MyApp;
|
use crate::app::MyApp;
|
||||||
use crate::editor::{Language, Workspace};
|
use crate::editor::{Language, Workspace};
|
||||||
use crate::keymap::{KeyBinding, KeyMap, Modifiers as KeyModifiers};
|
use crate::keymap::{KeyBinding, KeyMap, Modifiers as KeyModifiers};
|
||||||
|
use crate::panel::PanelBlock;
|
||||||
use crate::script::ScriptEngine;
|
use crate::script::ScriptEngine;
|
||||||
|
use crate::tree::TreeNode;
|
||||||
|
|
||||||
fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>>) {
|
fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>>) {
|
||||||
let keymap = Rc::new(RefCell::new(KeyMap::default()));
|
let keymap = Rc::new(RefCell::new(KeyMap::default()));
|
||||||
@@ -137,6 +139,62 @@ fn api_lists_tabs_and_closes_them_by_number() {
|
|||||||
assert_eq!(workspace.borrow().documents().len(), 1);
|
assert_eq!(workspace.borrow().documents().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_sets_and_clears_tab_icons() {
|
||||||
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
|
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!(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.set_tab_icon(0, "icons/book-open.svg"));
|
||||||
|
assert_eq!(api.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.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_script_can_set_tab_icons() {
|
||||||
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
|
let (api, _keymap) = install_api(workspace.clone());
|
||||||
|
|
||||||
|
let dir = temp_script_dir("tab-icons");
|
||||||
|
let script = dir.join("icons.lua");
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||||||
|
engine.run_file(&script).expect("скрипт иконок");
|
||||||
|
|
||||||
|
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() {
|
||||||
// Тест пишет во временный файл, а не в каталог проекта.
|
// Тест пишет во временный файл, а не в каталог проекта.
|
||||||
@@ -297,6 +355,103 @@ fn lua_script_can_control_panels() {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_tree_block_appends_sorts_and_removes_nodes() {
|
||||||
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
|
let (api, _keymap) = install_api(workspace);
|
||||||
|
|
||||||
|
let dir = temp_script_dir("tree-block");
|
||||||
|
let script = dir.join("tree.lua");
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
r#"
|
||||||
|
local tree = app.left_panel:add_tree()
|
||||||
|
tree:append({
|
||||||
|
{ id = "b", label = "banana", expandable = false },
|
||||||
|
{ id = "src", label = "src", icon = "folder", expandable = true, expanded = true },
|
||||||
|
{ id = "a", label = "Apple", expandable = false },
|
||||||
|
}, "")
|
||||||
|
tree:append({
|
||||||
|
{ id = "src/main.rs", label = "main.rs", icon = "file-code" },
|
||||||
|
}, "src")
|
||||||
|
|
||||||
|
local toggled, clicked = 0, 0
|
||||||
|
tree:on_toggle(function(expanded, id, loaded) toggled = toggled + 1 end)
|
||||||
|
tree:on_click(function(id) clicked = clicked + 1 end)
|
||||||
|
assert(toggled == 0 and clicked == 0)
|
||||||
|
assert(tree:remove("missing") == false)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("tree.lua");
|
||||||
|
|
||||||
|
let engine = ScriptEngine::new(api.clone()).expect("движок Lua");
|
||||||
|
engine
|
||||||
|
.run_file(&script)
|
||||||
|
.expect("дерево должно наполняться из Lua");
|
||||||
|
drop(engine);
|
||||||
|
|
||||||
|
let panel = api.left_panel_handle();
|
||||||
|
let panel = panel.borrow();
|
||||||
|
let PanelBlock::Tree(tree) = &panel.blocks()[0];
|
||||||
|
let rows = tree.borrow().rows();
|
||||||
|
assert_eq!(
|
||||||
|
rows.iter()
|
||||||
|
.map(|row| (row.label.as_str(), row.depth))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![("src", 0), ("main.rs", 1), ("Apple", 0), ("banana", 0)]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(tree.borrow_mut().remove("src"));
|
||||||
|
assert_eq!(
|
||||||
|
tree.borrow()
|
||||||
|
.rows()
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.label.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["Apple", "banana"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lua_list_dir_returns_sorted_entries_with_paths() {
|
||||||
|
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||||
|
let (api, _keymap) = install_api(workspace);
|
||||||
|
|
||||||
|
let dir = temp_script_dir("list-dir");
|
||||||
|
std::fs::create_dir(dir.join("sub")).expect("подкаталог");
|
||||||
|
std::fs::write(dir.join("b.txt"), "b").expect("b.txt");
|
||||||
|
std::fs::write(dir.join("A.txt"), "a").expect("A.txt");
|
||||||
|
let path = dir.to_string_lossy().into_owned();
|
||||||
|
let quoted = format!("{path:?}");
|
||||||
|
|
||||||
|
let scripts = temp_script_dir("list-dir-script");
|
||||||
|
let script = scripts.join("list.lua");
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
local entries = app:list_dir({quoted})
|
||||||
|
assert(#entries == 3, #entries)
|
||||||
|
assert(entries[1].name == "A.txt")
|
||||||
|
assert(entries[2].name == "b.txt")
|
||||||
|
assert(entries[3].name == "sub")
|
||||||
|
assert(entries[1].is_dir == false)
|
||||||
|
assert(entries[3].is_dir == true)
|
||||||
|
assert(entries[1].id == {quoted} .. "/A.txt")
|
||||||
|
"#
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("list.lua");
|
||||||
|
|
||||||
|
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||||||
|
engine.run_file(&script).expect("app:list_dir");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let _ = std::fs::remove_dir_all(&scripts);
|
||||||
|
}
|
||||||
|
|
||||||
/// Временный каталог с уникальным именем для Lua-сценариев.
|
/// Временный каталог с уникальным именем для Lua-сценариев.
|
||||||
fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
|
fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::env::temp_dir().join(format!(
|
||||||
@@ -700,6 +855,104 @@ fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
|
|||||||
assert!(api.bottom_panel_handle().borrow().is_open());
|
assert!(api.bottom_panel_handle().borrow().is_open());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
|
||||||
|
cx.update(gpui_kit::init);
|
||||||
|
|
||||||
|
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx));
|
||||||
|
|
||||||
|
let api = AppApi::global();
|
||||||
|
let tree = api.left_panel_handle().borrow_mut().add_tree();
|
||||||
|
tree.borrow_mut()
|
||||||
|
.append(
|
||||||
|
"",
|
||||||
|
vec![TreeNode {
|
||||||
|
id: "src".to_owned(),
|
||||||
|
label: "src".to_owned(),
|
||||||
|
icon: Some("folder".to_owned()),
|
||||||
|
expandable: true,
|
||||||
|
expanded: true,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
tree.borrow_mut()
|
||||||
|
.append(
|
||||||
|
"src",
|
||||||
|
vec![TreeNode {
|
||||||
|
id: "src/main.rs".to_owned(),
|
||||||
|
label: "main.rs".to_owned(),
|
||||||
|
icon: Some("file-code".to_owned()),
|
||||||
|
expandable: false,
|
||||||
|
expanded: false,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
api.left_panel().open();
|
||||||
|
|
||||||
|
visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |_, cx| cx.notify());
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
assert!(api.left_panel_handle().borrow().is_open());
|
||||||
|
assert_eq!(tree.borrow().rows().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui_kit::test]
|
||||||
|
fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
|
||||||
|
cx.update(gpui_kit::init);
|
||||||
|
|
||||||
|
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx));
|
||||||
|
|
||||||
|
let api = AppApi::global();
|
||||||
|
let tree = api.left_panel_handle().borrow_mut().add_tree();
|
||||||
|
tree.borrow_mut()
|
||||||
|
.append(
|
||||||
|
"",
|
||||||
|
vec![
|
||||||
|
TreeNode {
|
||||||
|
id: "src".to_owned(),
|
||||||
|
label: "src".to_owned(),
|
||||||
|
icon: Some("folder".to_owned()),
|
||||||
|
expandable: true,
|
||||||
|
expanded: false,
|
||||||
|
},
|
||||||
|
TreeNode {
|
||||||
|
id: "readme".to_owned(),
|
||||||
|
label: "README.md".to_owned(),
|
||||||
|
icon: Some("file".to_owned()),
|
||||||
|
expandable: false,
|
||||||
|
expanded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
api.left_panel().open();
|
||||||
|
tree.borrow_mut().focus("src");
|
||||||
|
|
||||||
|
visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |_, cx| cx.notify());
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert_eq!(tree.borrow().selected(), Some("src"));
|
||||||
|
|
||||||
|
// Панель закрыли и открыли снова: фокус должен вернуться на выбранную
|
||||||
|
// строку, иначе стрелка уйдёт в редактор.
|
||||||
|
api.left_panel().close();
|
||||||
|
visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |_, cx| cx.notify());
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
api.left_panel().open();
|
||||||
|
visual.update(|_window, cx| {
|
||||||
|
app.update(cx, |_, cx| cx.notify());
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
|
||||||
|
visual.simulate_keystrokes("down");
|
||||||
|
assert_eq!(tree.borrow().selected(), Some("readme"));
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui_kit::test]
|
#[gpui_kit::test]
|
||||||
fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
|
fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
|
||||||
cx.update(gpui_kit::init);
|
cx.update(gpui_kit::init);
|
||||||
|
|||||||
+684
@@ -0,0 +1,684 @@
|
|||||||
|
//! Абстрактное дерево для блоков панелей.
|
||||||
|
//!
|
||||||
|
//! Редактор ничего не знает о файловой системе: он хранит узлы дерева с
|
||||||
|
//! идентификатором, подписью, иконкой и флагом «раскрываемый», рисует их и
|
||||||
|
//! сообщает Lua о раскрытии и кликах. Какой именно смысл у узлов — файлы,
|
||||||
|
//! задачи, настройки — решает сценарий, который наполняет дерево через
|
||||||
|
//! [`TreeApi`](crate::api::TreeApi).
|
||||||
|
//!
|
||||||
|
//! Дерево хранится плоско: у каждого узла есть родитель и список детей.
|
||||||
|
//! [`TreeState::rows`] разворачивает видимые узлы в отсортированный по
|
||||||
|
//! подписи список, который и рисует вид.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use mlua::Function;
|
||||||
|
|
||||||
|
use gpui_kit::{App, FocusHandle, ScrollHandle};
|
||||||
|
|
||||||
|
/// Общая ссылка на состояние блока дерева.
|
||||||
|
pub type TreeHandle = Rc<RefCell<TreeState>>;
|
||||||
|
|
||||||
|
/// Создаёт пустое дерево и общую ссылку на него.
|
||||||
|
pub fn new_tree() -> TreeHandle {
|
||||||
|
Rc::new(RefCell::new(TreeState::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Один узел, переданный из Lua.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct TreeNode {
|
||||||
|
/// Условный идентификатор: путь файла или произвольный ключ.
|
||||||
|
pub id: String,
|
||||||
|
/// Текст, который видит пользователь.
|
||||||
|
pub label: String,
|
||||||
|
/// Имя иконки Lucide; `None` — иконка по умолчанию.
|
||||||
|
pub icon: Option<String>,
|
||||||
|
/// Раскрываемая ли нода (папка или иной контейнер).
|
||||||
|
pub expandable: bool,
|
||||||
|
/// Раскрыта ли нода сейчас.
|
||||||
|
pub expanded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Узел с сохранёнными связями между элементами дерева.
|
||||||
|
struct NodeRecord {
|
||||||
|
node: TreeNode,
|
||||||
|
parent: Option<String>,
|
||||||
|
children: Vec<String>,
|
||||||
|
/// В узел уже добавляли дочерние элементы хотя бы один раз.
|
||||||
|
loaded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Строка, которую вид рисует в текущем кадре.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct TreeRow {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
/// Уровень вложенности: 0 — корень.
|
||||||
|
pub depth: usize,
|
||||||
|
/// Нода раскрывается.
|
||||||
|
pub expandable: bool,
|
||||||
|
/// Нода раскрыта прямо сейчас.
|
||||||
|
pub expanded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Что должен сделать вид после клика по узлу.
|
||||||
|
pub enum TreeAction {
|
||||||
|
/// Раскрыть или свернуть контейнер.
|
||||||
|
Toggle {
|
||||||
|
id: String,
|
||||||
|
expanded: bool,
|
||||||
|
/// Были ли в узел уже загружены дочерние элементы до этого клика.
|
||||||
|
loaded: bool,
|
||||||
|
callback: Option<Function>,
|
||||||
|
},
|
||||||
|
/// Клик по конечной ноде.
|
||||||
|
Click {
|
||||||
|
id: String,
|
||||||
|
callback: Option<Function>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Что вернула обработка клавиши дерева.
|
||||||
|
pub enum TreeKeyResult {
|
||||||
|
/// Клавиша не относится к навигации по дереву.
|
||||||
|
Ignored,
|
||||||
|
/// Клавиша обработана (выделение изменилось или осталось на краю).
|
||||||
|
Handled,
|
||||||
|
/// Нужно вызвать обработчик раскрытия или клика.
|
||||||
|
Action(TreeAction),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Состояние абстрактного дерева.
|
||||||
|
pub struct TreeState {
|
||||||
|
nodes: HashMap<String, NodeRecord>,
|
||||||
|
roots: Vec<String>,
|
||||||
|
toggle_callback: Option<Function>,
|
||||||
|
click_callback: Option<Function>,
|
||||||
|
/// Выбранная строка; сохраняется, пока панель закрыта.
|
||||||
|
selected: Option<String>,
|
||||||
|
/// Фокус создаётся лениво: состояние дерева может появиться до первого
|
||||||
|
/// кадра, когда `App` ещё недоступен.
|
||||||
|
focus_handle: Option<FocusHandle>,
|
||||||
|
/// Следующий кадр должен передать фокус этому дереву.
|
||||||
|
focus_requested: bool,
|
||||||
|
/// Прокрутка списка: выбранная строка всегда прокручивается в видимую часть.
|
||||||
|
scroll: ScrollHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TreeState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
nodes: HashMap::new(),
|
||||||
|
roots: Vec::new(),
|
||||||
|
toggle_callback: None,
|
||||||
|
click_callback: None,
|
||||||
|
selected: None,
|
||||||
|
focus_handle: None,
|
||||||
|
focus_requested: false,
|
||||||
|
scroll: ScrollHandle::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeState {
|
||||||
|
/// Создаёт пустое дерево.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработчик раскрытия/сворачивания: `(expanded, id, loaded)`.
|
||||||
|
pub fn set_toggle_callback(&mut self, callback: Option<Function>) {
|
||||||
|
self.toggle_callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработчик клика по конечной ноде: `(id)`.
|
||||||
|
pub fn set_click_callback(&mut self, callback: Option<Function>) {
|
||||||
|
self.click_callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Идентификатор выбранной строки.
|
||||||
|
pub fn selected(&self) -> Option<&str> {
|
||||||
|
self.selected.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Раскрывает всех закрытых родителей `id`, выбирает узел и просит вид
|
||||||
|
/// передать ему фокус в следующем кадре.
|
||||||
|
///
|
||||||
|
/// Возвращает `false`, если узла с таким `id` нет.
|
||||||
|
pub fn focus(&mut self, id: &str) -> bool {
|
||||||
|
if !self.nodes.contains_key(id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.expand_ancestors(id);
|
||||||
|
self.selected = Some(id.to_owned());
|
||||||
|
self.focus_requested = true;
|
||||||
|
if let Some(index) = self.rows().iter().position(|row| row.id == id) {
|
||||||
|
self.scroll.scroll_to_item(index);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработчик прокрутки списка дерева.
|
||||||
|
pub fn scroll_handle(&self) -> ScrollHandle {
|
||||||
|
self.scroll.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Фокус-обработчик дерева; создаётся при первом кадре.
|
||||||
|
pub fn focus_handle(&mut self, cx: &App) -> FocusHandle {
|
||||||
|
self.focus_handle
|
||||||
|
.get_or_insert_with(|| cx.focus_handle())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Забирает отложенный запрос фокуса.
|
||||||
|
pub fn take_focus_request(&mut self) -> bool {
|
||||||
|
std::mem::take(&mut self.focus_requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обрабатывает клавишу навигации по дереву.
|
||||||
|
pub fn handle_key(&mut self, key: &str) -> TreeKeyResult {
|
||||||
|
match key.trim().to_lowercase().as_str() {
|
||||||
|
"up" | "arrowup" => {
|
||||||
|
self.move_selection(-1);
|
||||||
|
TreeKeyResult::Handled
|
||||||
|
}
|
||||||
|
"down" | "arrowdown" => {
|
||||||
|
self.move_selection(1);
|
||||||
|
TreeKeyResult::Handled
|
||||||
|
}
|
||||||
|
"enter" | "return" | "kp_enter" => match self.activate_selected() {
|
||||||
|
Some(action) => TreeKeyResult::Action(action),
|
||||||
|
None => TreeKeyResult::Ignored,
|
||||||
|
},
|
||||||
|
"left" | "arrowleft" => self.collapse_or_select_parent(),
|
||||||
|
"right" | "arrowright" => self.expand_or_select_child(),
|
||||||
|
_ => TreeKeyResult::Ignored,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавляет узлы к `parent`; пустой `parent` — корень дерева.
|
||||||
|
///
|
||||||
|
/// Существующие узлы не удаляются и не дублируются: повторная передача
|
||||||
|
/// того же `id` обновляет описание узла. После вызова контейнер считается
|
||||||
|
/// загруженным, поэтому при следующем раскрытии в обработчик придёт
|
||||||
|
/// `loaded = true`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Возвращает текст ошибки для некорректного `id` (пустой идентификатор),
|
||||||
|
/// неизвестного родителя или попытки сделать узел потомком самого себя.
|
||||||
|
pub fn append(&mut self, parent: &str, nodes: Vec<TreeNode>) -> Result<(), String> {
|
||||||
|
if !parent.is_empty() && !self.nodes.contains_key(parent) {
|
||||||
|
return Err(format!("родительская нода {parent:?} не найдена"));
|
||||||
|
}
|
||||||
|
for node in &nodes {
|
||||||
|
if node.id.is_empty() {
|
||||||
|
return Err("идентификатор ноды не может быть пустым".to_owned());
|
||||||
|
}
|
||||||
|
if node.id == parent || self.would_cycle(&node.id, parent) {
|
||||||
|
return Err(format!(
|
||||||
|
"нода {:?} не может быть потомком самой себя",
|
||||||
|
node.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for node in nodes {
|
||||||
|
let requested_parent = (!parent.is_empty()).then(|| parent.to_owned());
|
||||||
|
let id = node.id.clone();
|
||||||
|
|
||||||
|
if self.nodes.contains_key(&id) {
|
||||||
|
let old_parent = self.nodes.get(&id).and_then(|record| record.parent.clone());
|
||||||
|
// Повторный append обновляет описание узла, но не создаёт копию.
|
||||||
|
if let Some(record) = self.nodes.get_mut(&id) {
|
||||||
|
record.node = node;
|
||||||
|
}
|
||||||
|
if old_parent != requested_parent {
|
||||||
|
self.detach(&id);
|
||||||
|
self.attach(&requested_parent, &id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.nodes.insert(
|
||||||
|
id.clone(),
|
||||||
|
NodeRecord {
|
||||||
|
node,
|
||||||
|
parent: requested_parent.clone(),
|
||||||
|
children: Vec::new(),
|
||||||
|
loaded: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.attach(&requested_parent, &id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(record) = self.nodes.get_mut(parent) {
|
||||||
|
record.loaded = true;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаляет узел и все его дочерние узлы рекурсивно.
|
||||||
|
///
|
||||||
|
/// Возвращает `false`, если узла с таким `id` нет.
|
||||||
|
pub fn remove(&mut self, id: &str) -> bool {
|
||||||
|
if !self.nodes.contains_key(id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.detach(id);
|
||||||
|
|
||||||
|
let mut stack = vec![id.to_owned()];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
if let Some(record) = self.nodes.remove(¤t) {
|
||||||
|
stack.extend(record.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Видимые строки дерева в порядке отрисовки.
|
||||||
|
///
|
||||||
|
/// Дети каждого уровня сортируются по отображаемому тексту без учёта
|
||||||
|
/// регистра; дети свёрнутых узлов не показываются.
|
||||||
|
pub fn rows(&self) -> Vec<TreeRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for id in self.sorted(&self.roots) {
|
||||||
|
self.push_rows(&id, 0, &mut rows);
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обрабатывает клик по узлу и возвращает действие для вида.
|
||||||
|
pub fn activate(&mut self, id: &str) -> Option<TreeAction> {
|
||||||
|
let toggle_callback = self.toggle_callback.clone();
|
||||||
|
let click_callback = self.click_callback.clone();
|
||||||
|
|
||||||
|
// Раскрытие или открытие файла может изменить документы и заставить
|
||||||
|
// редактор забрать фокус; дерево возвращает его себе в следующем кадре.
|
||||||
|
self.focus_requested = true;
|
||||||
|
|
||||||
|
let record = self.nodes.get_mut(id)?;
|
||||||
|
if record.node.expandable {
|
||||||
|
let loaded = record.loaded;
|
||||||
|
record.node.expanded = !record.node.expanded;
|
||||||
|
let expanded = record.node.expanded;
|
||||||
|
Some(TreeAction::Toggle {
|
||||||
|
id: id.to_owned(),
|
||||||
|
expanded,
|
||||||
|
loaded,
|
||||||
|
callback: toggle_callback,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Some(TreeAction::Click {
|
||||||
|
id: id.to_owned(),
|
||||||
|
callback: click_callback,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Дочерние `id`, отсортированные по подписи: сначала раскрываемые узлы
|
||||||
|
/// (папки), затем конечные; внутри группы — по алфавиту без учёта регистра.
|
||||||
|
fn sorted(&self, ids: &[String]) -> Vec<String> {
|
||||||
|
let mut entries = ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
self.nodes.get(id).map(|record| {
|
||||||
|
(
|
||||||
|
record.node.expandable,
|
||||||
|
record.node.label.to_lowercase(),
|
||||||
|
id.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
b.0.cmp(&a.0)
|
||||||
|
.then_with(|| a.1.cmp(&b.1))
|
||||||
|
.then_with(|| a.2.cmp(&b.2))
|
||||||
|
});
|
||||||
|
entries.into_iter().map(|(_, _, id)| id).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сдвигает выборку по видимым строкам.
|
||||||
|
fn move_selection(&mut self, delta: isize) {
|
||||||
|
let rows = self.rows();
|
||||||
|
if rows.is_empty() {
|
||||||
|
self.selected = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current = self
|
||||||
|
.selected
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|id| rows.iter().position(|row| row.id == id));
|
||||||
|
let next = match current {
|
||||||
|
Some(ix) if delta < 0 => ix.saturating_sub(1),
|
||||||
|
Some(ix) => (ix + 1).min(rows.len() - 1),
|
||||||
|
None if delta < 0 => rows.len() - 1,
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.selected = Some(rows[next].id.clone());
|
||||||
|
self.scroll.scroll_to_item(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter: раскрывает папку или вызывает обработчик клика конечной ноды.
|
||||||
|
fn activate_selected(&mut self) -> Option<TreeAction> {
|
||||||
|
let id = self.selected.clone()?;
|
||||||
|
self.activate(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Стрелка влево: сворачивает папку, иначе выбирает родителя.
|
||||||
|
fn collapse_or_select_parent(&mut self) -> TreeKeyResult {
|
||||||
|
let Some(id) = self.selected.clone() else {
|
||||||
|
return TreeKeyResult::Ignored;
|
||||||
|
};
|
||||||
|
let expanded = self
|
||||||
|
.nodes
|
||||||
|
.get(&id)
|
||||||
|
.is_some_and(|record| record.node.expandable && record.node.expanded);
|
||||||
|
if expanded {
|
||||||
|
return match self.activate(&id) {
|
||||||
|
Some(action) => TreeKeyResult::Action(action),
|
||||||
|
None => TreeKeyResult::Handled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(parent) = self.nodes.get(&id).and_then(|record| record.parent.clone()) {
|
||||||
|
self.selected = Some(parent.clone());
|
||||||
|
if let Some(index) = self.rows().iter().position(|row| row.id == parent) {
|
||||||
|
self.scroll.scroll_to_item(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TreeKeyResult::Handled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Стрелка вправо: раскрывает папку, иначе выбирает первого ребёнка.
|
||||||
|
fn expand_or_select_child(&mut self) -> TreeKeyResult {
|
||||||
|
let Some(id) = self.selected.clone() else {
|
||||||
|
return TreeKeyResult::Ignored;
|
||||||
|
};
|
||||||
|
let Some(record) = self.nodes.get(&id) else {
|
||||||
|
return TreeKeyResult::Ignored;
|
||||||
|
};
|
||||||
|
if record.node.expandable && !record.node.expanded {
|
||||||
|
return match self.activate(&id) {
|
||||||
|
Some(action) => TreeKeyResult::Action(action),
|
||||||
|
None => TreeKeyResult::Handled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if record.node.expanded
|
||||||
|
&& let Some(child) = self.sorted(&record.children).first().cloned()
|
||||||
|
{
|
||||||
|
let index = self.rows().iter().position(|row| row.id == child);
|
||||||
|
self.selected = Some(child);
|
||||||
|
if let Some(index) = index {
|
||||||
|
self.scroll.scroll_to_item(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TreeKeyResult::Handled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Раскрывает всех родителей узла, не трогая его собственное состояние.
|
||||||
|
fn expand_ancestors(&mut self, id: &str) {
|
||||||
|
let mut parents = Vec::new();
|
||||||
|
let mut current = self.nodes.get(id).and_then(|record| record.parent.clone());
|
||||||
|
while let Some(parent) = current {
|
||||||
|
current = self
|
||||||
|
.nodes
|
||||||
|
.get(&parent)
|
||||||
|
.and_then(|record| record.parent.clone());
|
||||||
|
parents.push(parent);
|
||||||
|
}
|
||||||
|
for parent in parents {
|
||||||
|
if let Some(record) = self.nodes.get_mut(&parent) {
|
||||||
|
record.node.expanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавляет `id` и его раскрытых потомков в плоский список.
|
||||||
|
fn push_rows(&self, id: &str, depth: usize, rows: &mut Vec<TreeRow>) {
|
||||||
|
let Some(record) = self.nodes.get(id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
rows.push(TreeRow {
|
||||||
|
id: record.node.id.clone(),
|
||||||
|
label: record.node.label.clone(),
|
||||||
|
icon: record.node.icon.clone(),
|
||||||
|
depth,
|
||||||
|
expandable: record.node.expandable,
|
||||||
|
expanded: record.node.expanded,
|
||||||
|
});
|
||||||
|
|
||||||
|
if record.node.expanded {
|
||||||
|
for child in self.sorted(&record.children) {
|
||||||
|
self.push_rows(&child, depth + 1, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет, окажется ли `parent` внутри поддерева `node_id`.
|
||||||
|
fn would_cycle(&self, node_id: &str, parent: &str) -> bool {
|
||||||
|
let mut current = parent;
|
||||||
|
while !current.is_empty() {
|
||||||
|
if current == node_id {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match self
|
||||||
|
.nodes
|
||||||
|
.get(current)
|
||||||
|
.and_then(|record| record.parent.as_deref())
|
||||||
|
{
|
||||||
|
Some(next) => current = next,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Присоединяет узел к родителю или корню.
|
||||||
|
fn attach(&mut self, parent: &Option<String>, id: &str) {
|
||||||
|
match parent {
|
||||||
|
Some(parent) => {
|
||||||
|
if let Some(record) = self.nodes.get_mut(parent) {
|
||||||
|
record.children.push(id.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => self.roots.push(id.to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отсоединяет узел от родителя или корня.
|
||||||
|
fn detach(&mut self, id: &str) {
|
||||||
|
let Some(parent) = self.nodes.get(id).and_then(|record| record.parent.clone()) else {
|
||||||
|
self.roots.retain(|root| root != id);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(record) = self.nodes.get_mut(&parent) {
|
||||||
|
record.children.retain(|child| child != id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn node(id: &str, label: &str, expandable: bool) -> TreeNode {
|
||||||
|
TreeNode {
|
||||||
|
id: id.to_owned(),
|
||||||
|
label: label.to_owned(),
|
||||||
|
icon: None,
|
||||||
|
expandable,
|
||||||
|
expanded: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rows_are_sorted_by_label_and_follow_expansion() {
|
||||||
|
let mut tree = TreeState::new();
|
||||||
|
tree.append(
|
||||||
|
"",
|
||||||
|
vec![
|
||||||
|
node("b", "banana", false),
|
||||||
|
node("folder", "Alpha", true),
|
||||||
|
node("a", "apple", false),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
tree.append(
|
||||||
|
"folder",
|
||||||
|
vec![node("z", "zebra", false), node("c", "cat", false)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Свёрнутая папка не показывает детей.
|
||||||
|
let rows = tree.rows();
|
||||||
|
assert_eq!(
|
||||||
|
rows.iter()
|
||||||
|
.map(|row| row.label.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["Alpha", "apple", "banana"]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
tree.activate("folder"),
|
||||||
|
Some(TreeAction::Toggle {
|
||||||
|
expanded: true,
|
||||||
|
loaded: true,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
let rows = tree.rows();
|
||||||
|
assert_eq!(
|
||||||
|
rows.iter()
|
||||||
|
.map(|row| (row.label.as_str(), row.depth))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
("Alpha", 0),
|
||||||
|
("cat", 1),
|
||||||
|
("zebra", 1),
|
||||||
|
("apple", 0),
|
||||||
|
("banana", 0)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_does_not_erase_and_marks_the_parent_loaded() {
|
||||||
|
let mut tree = TreeState::new();
|
||||||
|
tree.append("", vec![node("src", "src", true)]).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
tree.activate("src"),
|
||||||
|
Some(TreeAction::Toggle {
|
||||||
|
expanded: true,
|
||||||
|
loaded: false,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
// Сворачиваем, чтобы проверить флаг загрузки после append.
|
||||||
|
tree.activate("src");
|
||||||
|
tree.append("src", vec![node("main.rs", "main.rs", false)])
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
tree.activate("src"),
|
||||||
|
Some(TreeAction::Toggle {
|
||||||
|
expanded: true,
|
||||||
|
loaded: true,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert_eq!(tree.rows().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_deletes_the_subtree() {
|
||||||
|
let mut tree = TreeState::new();
|
||||||
|
tree.append(
|
||||||
|
"",
|
||||||
|
vec![node("src", "src", true), node("readme", "README.md", false)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
tree.append("src", vec![node("ui", "ui", true)]).unwrap();
|
||||||
|
tree.append("ui", vec![node("tree", "tree.rs", false)])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(tree.remove("src"));
|
||||||
|
let rows = tree.rows();
|
||||||
|
assert_eq!(
|
||||||
|
rows.iter()
|
||||||
|
.map(|row| row.label.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["README.md"]
|
||||||
|
);
|
||||||
|
assert!(!tree.remove("src"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_expands_ancestors_and_selects_the_node() {
|
||||||
|
let mut tree = TreeState::new();
|
||||||
|
tree.append("", vec![node("src", "src", true)]).unwrap();
|
||||||
|
tree.append("src", vec![node("ui", "ui", true)]).unwrap();
|
||||||
|
tree.append("ui", vec![node("tree", "tree.rs", false)])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Все родители свёрнуты — видна только корневая папка.
|
||||||
|
assert_eq!(tree.rows().len(), 1);
|
||||||
|
assert!(!tree.focus("missing"));
|
||||||
|
|
||||||
|
assert!(tree.focus("tree"));
|
||||||
|
assert_eq!(tree.selected(), Some("tree"));
|
||||||
|
assert!(tree.take_focus_request());
|
||||||
|
assert_eq!(
|
||||||
|
tree.rows()
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.label.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["src", "ui", "tree.rs"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyboard_moves_selection_and_activates_nodes() {
|
||||||
|
let mut tree = TreeState::new();
|
||||||
|
tree.append(
|
||||||
|
"",
|
||||||
|
vec![node("src", "src", true), node("readme", "README.md", false)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Папки идут раньше файлов, поэтому первая строка — src.
|
||||||
|
assert!(matches!(tree.handle_key("down"), TreeKeyResult::Handled));
|
||||||
|
assert_eq!(tree.selected(), Some("src"));
|
||||||
|
assert!(matches!(tree.handle_key("down"), TreeKeyResult::Handled));
|
||||||
|
assert_eq!(tree.selected(), Some("readme"));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
tree.handle_key("enter"),
|
||||||
|
TreeKeyResult::Action(TreeAction::Click { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(matches!(tree.handle_key("up"), TreeKeyResult::Handled));
|
||||||
|
assert_eq!(tree.selected(), Some("src"));
|
||||||
|
assert!(matches!(
|
||||||
|
tree.handle_key("right"),
|
||||||
|
TreeKeyResult::Action(TreeAction::Toggle { expanded: true, .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
tree.handle_key("left"),
|
||||||
|
TreeKeyResult::Action(TreeAction::Toggle {
|
||||||
|
expanded: false,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_parents_and_cycles_are_rejected() {
|
||||||
|
let mut tree = TreeState::new();
|
||||||
|
assert!(tree.append("missing", vec![node("a", "a", false)]).is_err());
|
||||||
|
tree.append("", vec![node("a", "a", true)]).unwrap();
|
||||||
|
tree.append("a", vec![node("b", "b", true)]).unwrap();
|
||||||
|
assert!(tree.append("b", vec![node("a", "a", true)]).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user