files
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
# Lua API
|
||||
|
||||
Приложение выполняет `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:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует |
|
||||
| `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс |
|
||||
@@ -34,6 +35,25 @@ cargo run -- main.go
|
||||
| `app:open_tab()` | Открыть пустую вкладку, вернуть индекс |
|
||||
| `app:open(path)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем |
|
||||
| `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: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)` назначает обработчик на нажатие или отпускание клавиши
|
||||
@@ -190,6 +228,100 @@ app.right_panel:toggle()
|
||||
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-файлов
|
||||
|
||||
Файлы подключаются из каталога сценариев:
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
print("[lua] init.lua выполнен")
|
||||
|
||||
-- Доступные методы объекта app (номера вкладок — с нуля):
|
||||
-- app:tabs() -- {{ index = 0, title = "main.rs" }, ...}
|
||||
-- app:tabs() -- {{ index = 0, title = "main.rs", icon = "file" }, ...}
|
||||
-- app:active_tab() -- индекс активной вкладки
|
||||
-- app:set_active_tab(index) -- true, если вкладка существует
|
||||
-- app:next_tab() -- переключить на следующую, вернуть индекс
|
||||
@@ -23,7 +23,15 @@ print("[lua] init.lua выполнен")
|
||||
-- app:open_tab() -- открыть пустую вкладку, вернуть индекс
|
||||
-- app:open(path) -- открыть файл с диска, вернуть индекс вкладки;
|
||||
-- -- если файла нет — пустой буфер с таким именем
|
||||
-- app:list_dir(path) -- {{ id = "src/main.rs", name = "main.rs",
|
||||
-- -- is_dir = false }, ...}; пустой путь —
|
||||
-- -- текущий каталог, id — путь от него
|
||||
-- app:close_tab(index) -- true, если вкладка закрыта
|
||||
-- app:set_tab_icon(i, name) -- иконка вкладки из каталога Lucide
|
||||
-- -- (file, file-code, book-open, ...);
|
||||
-- -- false — нет вкладки или иконки
|
||||
-- app:clear_tab_icon(i) -- вернуть иконку по умолчанию (file)
|
||||
-- app:tab_icon(i) -- имя иконки вкладки или nil
|
||||
-- app:active_text() -- текст активной вкладки
|
||||
-- app:set_active_text(text) -- заменить текст активной вкладки
|
||||
-- app:set_language(i, "rust") -- включить подсветку (rust|go|js|json|python)
|
||||
@@ -92,6 +100,18 @@ print("[lua] init.lua выполнен")
|
||||
-- app.bottom_panel:toggle()
|
||||
-- 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, помеченном
|
||||
-- #[mlua::userdata_impl], автоматически доступно из Lua.
|
||||
--
|
||||
@@ -136,9 +156,56 @@ end
|
||||
-- app:map_key("<C-s>", function() app:save() 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-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: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::editor::{Language, Workspace};
|
||||
use crate::icons;
|
||||
use crate::keymap::{KeyBinding, KeyMap};
|
||||
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri};
|
||||
use crate::panel::{PanelHandle, PanelState};
|
||||
use crate::tree::{TreeHandle, TreeNode};
|
||||
|
||||
thread_local! {
|
||||
/// Текущий экземпляр API. `thread_local`, потому что UI GPUI привязан к
|
||||
@@ -68,6 +70,22 @@ pub struct TabInfo {
|
||||
pub index: usize,
|
||||
/// Название — то же, что показано в заголовке вкладки.
|
||||
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]
|
||||
impl PanelApi {
|
||||
/// Открывает панель.
|
||||
@@ -138,6 +243,17 @@ impl PanelApi {
|
||||
pub fn is_open(&self) -> bool {
|
||||
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]
|
||||
@@ -208,6 +324,10 @@ impl AppApi {
|
||||
.map(|(index, doc)| TabInfo {
|
||||
index,
|
||||
title: doc.name.clone(),
|
||||
icon: doc
|
||||
.icon
|
||||
.clone()
|
||||
.unwrap_or_else(|| icons::DEFAULT_TAB_ICON.to_owned()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -362,6 +482,60 @@ impl AppApi {
|
||||
.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`].
|
||||
///
|
||||
/// Возвращает `false`, если вкладки с таким номером нет.
|
||||
@@ -375,6 +549,50 @@ impl AppApi {
|
||||
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`.
|
||||
///
|
||||
/// `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>> {
|
||||
match value {
|
||||
mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]),
|
||||
|
||||
+338
-55
@@ -5,7 +5,7 @@
|
||||
//! а `Editor` показывает активный документ; синхронизация выполняется в
|
||||
//! `render`, когда меняется [`Workspace::revision`].
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
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::text::TextView;
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme, Icon, IconName, Sizable, StyledExt, TitleBar, h_flex, h_resizable,
|
||||
resizable_panel, resize_handle, v_resizable,
|
||||
ActiveTheme, Icon, Sizable, StyledExt, TitleBar, h_flex, h_resizable, resizable_panel,
|
||||
resize_handle, v_resizable,
|
||||
};
|
||||
use gpui_kit::*;
|
||||
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::editor::Workspace;
|
||||
use crate::icons;
|
||||
use crate::keymap::KeyMap;
|
||||
use crate::lsp::{LspManager, document_uri, position_to_offset};
|
||||
use crate::panel::PanelHandle;
|
||||
use crate::panel::{PanelBlock, PanelHandle};
|
||||
use crate::script::ScriptEngine;
|
||||
use crate::tree::{TreeAction, TreeHandle, TreeKeyResult};
|
||||
|
||||
/// Payload перетаскивания разделителя левой панели.
|
||||
///
|
||||
@@ -140,6 +142,9 @@ pub struct MyApp {
|
||||
right_panel: PanelHandle,
|
||||
/// Состояние нижней панели, общее с `app.bottom_panel` в Lua.
|
||||
bottom_panel: PanelHandle,
|
||||
/// Открытость панелей в предыдущем кадре: по переходу «закрыта →
|
||||
/// открыта» вид возвращает фокус в дерево, которое было выбрано раньше.
|
||||
panel_open_state: Cell<[bool; 3]>,
|
||||
/// Виджет редактора, показывающий активный документ.
|
||||
editor: Entity<EditorState>,
|
||||
/// Ревизия [`Workspace`], уже перенесённая в `editor`.
|
||||
@@ -273,6 +278,7 @@ impl MyApp {
|
||||
left_panel,
|
||||
right_panel,
|
||||
bottom_panel,
|
||||
panel_open_state: Cell::new([false; 3]),
|
||||
editor,
|
||||
synced_revision: 0,
|
||||
displayed_id,
|
||||
@@ -1057,6 +1063,225 @@ impl MyApp {
|
||||
.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` сбрасывает выделение и прокрутку, поэтому вызывается только
|
||||
@@ -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 panel = self.left_panel.borrow();
|
||||
(panel.is_open(), panel.size())
|
||||
@@ -1135,16 +1365,25 @@ impl MyApp {
|
||||
(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")
|
||||
.child(resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()))
|
||||
.child(
|
||||
resizable_panel()
|
||||
.size(px(bottom_size))
|
||||
.visible(bottom_open)
|
||||
.child(div().size_full().bg(cx.theme().sidebar)),
|
||||
);
|
||||
let editor_area =
|
||||
v_resizable("editor-area")
|
||||
.child(
|
||||
resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()),
|
||||
)
|
||||
.child(
|
||||
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")
|
||||
.child(resizable_panel().child(editor_area))
|
||||
@@ -1156,7 +1395,7 @@ impl MyApp {
|
||||
div()
|
||||
.size_full()
|
||||
.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()
|
||||
.relative()
|
||||
.bg(cx.theme().sidebar)
|
||||
.child(self.render_panel_content(&self.left_panel, false, window, cx))
|
||||
.child(
|
||||
resize_handle("left-panel-handle", Axis::Horizontal)
|
||||
.inside(HandleEdge::Trailing)
|
||||
@@ -1192,7 +1432,8 @@ impl MyApp {
|
||||
})
|
||||
}
|
||||
|
||||
/// Полоса вкладок: активная вкладка, маркер несохранённых изменений и «+».
|
||||
/// Полоса вкладок в стиле folder tabs: иконка, название, маркер
|
||||
/// несохранённых изменений и крестик закрытия, а также «+».
|
||||
fn render_tabs(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (tabs, active) = {
|
||||
let workspace = self.workspace.borrow();
|
||||
@@ -1200,65 +1441,79 @@ impl MyApp {
|
||||
workspace
|
||||
.documents()
|
||||
.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<_>>(),
|
||||
workspace.active_index(),
|
||||
)
|
||||
};
|
||||
|
||||
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() {
|
||||
// У несохранённого документа вместо крестика горит точка — как в
|
||||
// исходном приложении.
|
||||
let suffix: AnyElement = if dirty {
|
||||
div()
|
||||
.size(px(6.))
|
||||
.rounded_full()
|
||||
.bg(cx.theme().primary)
|
||||
.into_any_element()
|
||||
} else {
|
||||
let entity = entity.clone();
|
||||
for (index, (name, dirty, icon)) in tabs.into_iter().enumerate() {
|
||||
// Точка и крестик живут рядом: у несохранённой вкладки закрытие
|
||||
// тоже должно оставаться доступным.
|
||||
let mut suffix = h_flex().items_center().gap_1();
|
||||
if dirty {
|
||||
suffix = suffix.child(
|
||||
div()
|
||||
.size(px(6.))
|
||||
.rounded_full()
|
||||
.bg(cx.theme().primary)
|
||||
.flex_shrink_0(),
|
||||
);
|
||||
}
|
||||
suffix = suffix.child(
|
||||
Button::new(format!("close-tab-{index}"))
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.icon(IconName::Close)
|
||||
// Полный каталог Lucide, а не компактный набор компонентов.
|
||||
.icon(AssetIcon::X)
|
||||
.tooltip("Закрыть вкладку")
|
||||
.on_click(move |_, _, cx| {
|
||||
// Иначе клик закроет вкладку и тут же переключит на неё
|
||||
// соседнюю по уже устаревшему индексу.
|
||||
cx.stop_propagation();
|
||||
entity.update(cx, |this, cx| {
|
||||
let uri = this
|
||||
.workspace
|
||||
.borrow()
|
||||
.document(index)
|
||||
.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()
|
||||
};
|
||||
.on_click({
|
||||
let entity = entity.clone();
|
||||
move |_, _, cx| {
|
||||
// Иначе клик закроет вкладку и тут же переключит
|
||||
// на соседнюю по устаревшему индексу.
|
||||
cx.stop_propagation();
|
||||
entity.update(cx, |_, cx| {
|
||||
AppApi::global().close_tab(index);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
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(
|
||||
Button::new("new-tab")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.icon(IconName::Plus)
|
||||
.icon(AssetIcon::Plus)
|
||||
.tooltip("Новая вкладка")
|
||||
.on_click({
|
||||
let entity = entity.clone();
|
||||
move |_, _, cx| {
|
||||
entity.update(cx, |this, cx| {
|
||||
this.workspace.borrow_mut().open_new();
|
||||
entity.update(cx, |_, cx| {
|
||||
AppApi::global().open_tab();
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
@@ -1267,8 +1522,8 @@ impl MyApp {
|
||||
.on_click({
|
||||
let entity = entity.clone();
|
||||
move |index, _, cx| {
|
||||
entity.update(cx, |this, cx| {
|
||||
this.workspace.borrow_mut().set_active(*index);
|
||||
entity.update(cx, |_, cx| {
|
||||
AppApi::global().set_active_tab(*index);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
@@ -1395,6 +1650,34 @@ fn is_completion_shortcut(keystroke: &Keystroke) -> bool {
|
||||
&& !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.
|
||||
fn save_active_tab() {
|
||||
if let Err(err) = AppApi::global().save() {
|
||||
|
||||
@@ -147,6 +147,11 @@ pub struct Document {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
/// Имя иконки вкладки из каталога Lucide (см. `crate::icons`).
|
||||
///
|
||||
/// `None` — показывается иконка по умолчанию. Назначается через
|
||||
/// `AppApi::set_tab_icon`.
|
||||
pub icon: Option<String>,
|
||||
/// Язык подсветки синтаксиса.
|
||||
///
|
||||
/// `None` — подсветка выключена (поведение по умолчанию). Включается
|
||||
@@ -166,6 +171,7 @@ impl Document {
|
||||
name: name.into(),
|
||||
saved_content: content.clone(),
|
||||
content,
|
||||
icon: None,
|
||||
language: None,
|
||||
cursor: 0,
|
||||
}
|
||||
@@ -380,6 +386,43 @@ impl Workspace {
|
||||
self.active_mut().mark_saved();
|
||||
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#"//! Демонстрационный файл.
|
||||
|
||||
@@ -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 constants;
|
||||
mod editor;
|
||||
mod icons;
|
||||
mod keymap;
|
||||
mod lsp;
|
||||
mod panel;
|
||||
mod script;
|
||||
mod tree;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
+28
-2
@@ -4,21 +4,34 @@
|
||||
//! и `app.bottom_panel`, а вид [`MyApp`](crate::app::MyApp) читает его при
|
||||
//! отрисовке. Как и документы, состояние лежит в `Rc<RefCell<_>>`, чтобы вид и
|
||||
//! API видели одни и те же панели.
|
||||
//!
|
||||
//! Панель состоит из блоков. Блок — это независимый виджет, который сценарий
|
||||
//! создаёт через API панели (сейчас поддерживается [`PanelBlock::Tree`]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::tree::{TreeHandle, new_tree};
|
||||
|
||||
/// Общая ссылка на состояние панели.
|
||||
pub type PanelHandle = Rc<RefCell<PanelState>>;
|
||||
|
||||
/// Открыта ли панель и каким размером она раскрывается.
|
||||
/// Блок, добавленный в панель.
|
||||
#[derive(Clone)]
|
||||
pub enum PanelBlock {
|
||||
/// Абстрактное дерево, наполняемое из Lua.
|
||||
Tree(TreeHandle),
|
||||
}
|
||||
|
||||
/// Открыта ли панель, каким размером она раскрывается и какие блоки содержит.
|
||||
///
|
||||
/// Размер — начальный: после перетаскивания разделителя виджет
|
||||
/// `ResizablePanelGroup` запоминает фактический размер панели сам.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone)]
|
||||
pub struct PanelState {
|
||||
visible: bool,
|
||||
size: f32,
|
||||
blocks: Vec<PanelBlock>,
|
||||
}
|
||||
|
||||
impl PanelState {
|
||||
@@ -27,6 +40,7 @@ impl PanelState {
|
||||
Self {
|
||||
visible: false,
|
||||
size: size.max(0.0),
|
||||
blocks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,4 +74,16 @@ impl PanelState {
|
||||
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 crate::api::{AppApi, TabInfo};
|
||||
use crate::api::{AppApi, DirEntryInfo, TabInfo};
|
||||
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 {
|
||||
fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
|
||||
let tab = lua.create_table()?;
|
||||
tab.set("index", self.index)?;
|
||||
tab.set("title", self.title)?;
|
||||
tab.set("icon", self.icon)?;
|
||||
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 и состояние его глобального окружения.
|
||||
pub struct ScriptEngine {
|
||||
lua: Lua,
|
||||
|
||||
+253
@@ -13,7 +13,9 @@ use crate::api::{AppApi, WindowCommand, drain_window_commands};
|
||||
use crate::app::MyApp;
|
||||
use crate::editor::{Language, Workspace};
|
||||
use crate::keymap::{KeyBinding, KeyMap, Modifiers as KeyModifiers};
|
||||
use crate::panel::PanelBlock;
|
||||
use crate::script::ScriptEngine;
|
||||
use crate::tree::TreeNode;
|
||||
|
||||
fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>>) {
|
||||
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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
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);
|
||||
}
|
||||
|
||||
#[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-сценариев.
|
||||
fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
|
||||
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());
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
|
||||
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