683 lines
25 KiB
Rust
683 lines
25 KiB
Rust
//! Абстрактное дерево для блоков панелей.
|
|
//!
|
|
//! Редактор ничего не знает о файловой системе: он хранит узлы дерева с
|
|
//! идентификатором, подписью, иконкой и флагом «раскрываемый», рисует их и
|
|
//! сообщает 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),
|
|
}
|
|
|
|
/// Состояние абстрактного дерева.
|
|
#[derive(Default)]
|
|
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 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.focus_requested = true;
|
|
self.select_and_scroll(id);
|
|
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(())
|
|
}
|
|
|
|
/// Удаляет все узлы и сбрасывает выбор.
|
|
///
|
|
/// Обработчики раскрытия и клика сохраняются: сценарий может наполнять
|
|
/// дерево заново, например при обновлении списка диагностики.
|
|
pub fn clear(&mut self) {
|
|
self.nodes.clear();
|
|
self.roots.clear();
|
|
self.selected = None;
|
|
}
|
|
|
|
/// Удаляет узел и все его дочерние узлы рекурсивно.
|
|
///
|
|
/// Возвращает `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 select_and_scroll(&mut self, id: &str) {
|
|
self.selected = Some(id.to_owned());
|
|
if let Some(index) = self.rows().iter().position(|row| row.id == id) {
|
|
self.scroll.scroll_to_item(index);
|
|
}
|
|
}
|
|
|
|
/// Сдвигает выборку по видимым строкам.
|
|
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,
|
|
};
|
|
let id = rows[next].id.clone();
|
|
self.select_and_scroll(&id);
|
|
}
|
|
|
|
/// 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.select_and_scroll(&parent);
|
|
}
|
|
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;
|
|
};
|
|
let expandable = record.node.expandable;
|
|
let expanded = record.node.expanded;
|
|
let first_child = expanded
|
|
.then(|| self.sorted(&record.children).first().cloned())
|
|
.flatten();
|
|
|
|
if expandable && !expanded {
|
|
return match self.activate(&id) {
|
|
Some(action) => TreeKeyResult::Action(action),
|
|
None => TreeKeyResult::Handled,
|
|
};
|
|
}
|
|
if let Some(child) = first_child {
|
|
self.select_and_scroll(&child);
|
|
}
|
|
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());
|
|
}
|
|
}
|