Clean up project structure
This commit is contained in:
604
brittle-ui/src/lib_tab.rs
Normal file
604
brittle-ui/src/lib_tab.rs
Normal file
@@ -0,0 +1,604 @@
|
||||
//! The Library tab: three-pane layout with tree, list, and detail panes.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use brittle_keymap::actions;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use brittle_model::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary};
|
||||
|
||||
use crate::{
|
||||
command_bar::SearchQuery,
|
||||
lib_tree::{flatten_tree, LibraryTree, TreeRow},
|
||||
pub_detail::PubDetail,
|
||||
pub_list::PubList,
|
||||
ActionEvent,
|
||||
};
|
||||
|
||||
// ── Pane enum ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Which of the three panes currently has keyboard focus.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Pane {
|
||||
Tree,
|
||||
List,
|
||||
Detail,
|
||||
}
|
||||
|
||||
impl Pane {
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Pane::Tree => Pane::List,
|
||||
Pane::List => Pane::Detail,
|
||||
Pane::Detail => Pane::Tree,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Pane::Tree => Pane::Detail,
|
||||
Pane::List => Pane::Tree,
|
||||
Pane::Detail => Pane::List,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Root component for the Library tab.
|
||||
///
|
||||
/// Owns all UI state, wires keymap actions, and drives async data loading.
|
||||
#[component]
|
||||
pub fn LibTab() -> impl IntoView {
|
||||
// ── Context signals ───────────────────────────────────────────────────────
|
||||
let keymap_action = use_context::<crate::KeymapAction>()
|
||||
.expect("KeymapAction context missing")
|
||||
.0;
|
||||
|
||||
let search_query = use_context::<SearchQuery>()
|
||||
.map(|sq| sq.0)
|
||||
.unwrap_or_else(|| RwSignal::new(String::new()));
|
||||
|
||||
// ── UI state ──────────────────────────────────────────────────────────────
|
||||
let focused = RwSignal::new(Pane::Tree);
|
||||
|
||||
// Tree state
|
||||
let root_libs = RwSignal::new(Vec::<Library>::new());
|
||||
let children_cache = RwSignal::new(HashMap::<String, Vec<Library>>::new());
|
||||
let expanded = RwSignal::new(HashSet::<String>::new());
|
||||
let tree_cursor = RwSignal::new(0usize);
|
||||
|
||||
// List state
|
||||
let list_items = RwSignal::new(Vec::<ReferenceSummary>::new());
|
||||
let list_cursor = RwSignal::new(0usize);
|
||||
|
||||
// Detail state
|
||||
let detail_ref = RwSignal::new(Option::<Reference>::None);
|
||||
|
||||
// ── Derived / computed ────────────────────────────────────────────────────
|
||||
|
||||
// Flattened visible tree rows (recomputed when tree data or expand set changes).
|
||||
let tree_rows = Memo::new(move |_| {
|
||||
flatten_tree(&root_libs.get(), &children_cache.get(), &expanded.get(), 0)
|
||||
});
|
||||
|
||||
// The library selected by the tree cursor.
|
||||
let selected_library = Memo::new(move |_| {
|
||||
let pos = tree_cursor.get();
|
||||
tree_rows.with(|rows| {
|
||||
rows.get(pos).and_then(|r| {
|
||||
uuid::Uuid::parse_str(&r.id).ok().map(LibraryId)
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
// The reference ID selected by the list cursor.
|
||||
let selected_ref_id = Memo::new(move |_| {
|
||||
let pos = list_cursor.get();
|
||||
list_items.with(|items| items.get(pos).map(|r| r.id.clone()))
|
||||
});
|
||||
|
||||
// ── Initial data load (and reload on repository change) ───────────────────
|
||||
let reload_trigger = use_context::<crate::ReloadTrigger>().map(|r| r.0);
|
||||
Effect::new(move |_| {
|
||||
if let Some(t) = reload_trigger { t.get(); } // track the trigger
|
||||
spawn_local(async move {
|
||||
match crate::tauri::list_root_libraries().await {
|
||||
Ok(libs) => root_libs.set(libs),
|
||||
Err(e) => leptos::logging::log!("load libraries: {e}"),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reactive data loads ───────────────────────────────────────────────────
|
||||
|
||||
// Reload list whenever the selected library or search query changes.
|
||||
Effect::new(move |_| {
|
||||
let lib_id = selected_library.get();
|
||||
let query = search_query.get();
|
||||
spawn_local(async move {
|
||||
let result = match (&lib_id, query.is_empty()) {
|
||||
(Some(id), true) => crate::tauri::list_library_references_recursive(id).await,
|
||||
(Some(id), false) => crate::tauri::search_library_references(id, &query).await,
|
||||
(None, true) => crate::tauri::list_references().await,
|
||||
(None, false) => crate::tauri::search_references(&query).await,
|
||||
};
|
||||
match result {
|
||||
Ok(items) => list_items.set(items),
|
||||
Err(e) => {
|
||||
leptos::logging::error!("load publications: {e}");
|
||||
list_items.set(vec![]);
|
||||
}
|
||||
}
|
||||
list_cursor.set(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Load full reference when list cursor moves.
|
||||
Effect::new(move |_| {
|
||||
let ref_id: Option<ReferenceId> = selected_ref_id.get();
|
||||
spawn_local(async move {
|
||||
let loaded = match ref_id {
|
||||
Some(id) => crate::tauri::get_reference(&id).await.ok(),
|
||||
None => None,
|
||||
};
|
||||
detail_ref.set(loaded);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Keymap wiring ─────────────────────────────────────────────────────────
|
||||
Effect::new(move |_| {
|
||||
let Some(ev) = keymap_action.get() else { return };
|
||||
|
||||
// ACTION_OPEN in the List or Detail pane opens a PDF tab for the selected reference.
|
||||
if ev.name == actions::ACTION_OPEN
|
||||
&& matches!(focused.get_untracked(), Pane::List | Pane::Detail)
|
||||
{
|
||||
if let Some(ref_id) = selected_ref_id.get_untracked() {
|
||||
let title = detail_ref.with_untracked(|r| {
|
||||
r.as_ref()
|
||||
.map(|r| r.cite_key.clone())
|
||||
.unwrap_or_else(|| ref_id.to_string())
|
||||
});
|
||||
if let Some(ctx) = use_context::<crate::OpenPdfContext>() {
|
||||
ctx.0.set(Some(crate::PdfOpenRequest {
|
||||
ref_id: ref_id.to_string(),
|
||||
title,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
handle_action(
|
||||
&ev,
|
||||
focused,
|
||||
TreeState { cursor: tree_cursor, rows: tree_rows, expanded },
|
||||
ListState { cursor: list_cursor, items: list_items },
|
||||
);
|
||||
});
|
||||
|
||||
// ── View ──────────────────────────────────────────────────────────────────
|
||||
view! {
|
||||
<div class="lib-tab">
|
||||
<div class="pane pane-left">
|
||||
<LibraryTree
|
||||
root_libs=root_libs
|
||||
children_cache=children_cache
|
||||
expanded=expanded
|
||||
cursor=tree_cursor
|
||||
focused=focused
|
||||
/>
|
||||
</div>
|
||||
<div class="pane pane-center">
|
||||
<PubList
|
||||
items=list_items
|
||||
cursor=list_cursor
|
||||
focused=focused
|
||||
/>
|
||||
</div>
|
||||
<div class="pane pane-right" class:pane-focused=move || focused.get() == Pane::Detail>
|
||||
<PubDetail reference=detail_ref />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action handler ────────────────────────────────────────────────────────────
|
||||
|
||||
struct TreeState {
|
||||
cursor: RwSignal<usize>,
|
||||
rows: Memo<Vec<TreeRow>>,
|
||||
expanded: RwSignal<HashSet<String>>,
|
||||
}
|
||||
|
||||
struct ListState {
|
||||
cursor: RwSignal<usize>,
|
||||
items: RwSignal<Vec<ReferenceSummary>>,
|
||||
}
|
||||
|
||||
fn handle_action(
|
||||
ev: &ActionEvent,
|
||||
focused: RwSignal<Pane>,
|
||||
tree: TreeState,
|
||||
list: ListState,
|
||||
) {
|
||||
let count = (ev.count as usize).max(1);
|
||||
let cur = focused.get_untracked();
|
||||
|
||||
match ev.name.as_str() {
|
||||
// ── Focus switching ───────────────────────────────────────────────────
|
||||
actions::FOCUS_LEFT => focused.set(Pane::Tree),
|
||||
actions::FOCUS_CENTER => focused.set(Pane::List),
|
||||
actions::FOCUS_RIGHT => focused.set(Pane::Detail),
|
||||
actions::FOCUS_NEXT => focused.update(|p| *p = p.next()),
|
||||
actions::FOCUS_PREV => focused.update(|p| *p = p.prev()),
|
||||
|
||||
// ── Tree navigation ───────────────────────────────────────────────────
|
||||
actions::NAV_DOWN if cur == Pane::Tree => {
|
||||
let len = tree.rows.with_untracked(Vec::len);
|
||||
if len > 0 {
|
||||
tree.cursor.update(|c| *c = (*c + count).min(len - 1));
|
||||
}
|
||||
}
|
||||
actions::NAV_UP if cur == Pane::Tree => {
|
||||
tree.cursor.update(|c| *c = c.saturating_sub(count));
|
||||
}
|
||||
actions::NAV_TOP if cur == Pane::Tree => tree.cursor.set(0),
|
||||
actions::NAV_BOTTOM if cur == Pane::Tree => {
|
||||
let len = tree.rows.with_untracked(Vec::len);
|
||||
if len > 0 {
|
||||
tree.cursor.set(len - 1);
|
||||
}
|
||||
}
|
||||
actions::NAV_PAGE_DOWN if cur == Pane::Tree => {
|
||||
let len = tree.rows.with_untracked(Vec::len);
|
||||
if len > 0 {
|
||||
tree.cursor.update(|c| *c = (*c + 10 * count).min(len - 1));
|
||||
}
|
||||
}
|
||||
actions::NAV_PAGE_UP if cur == Pane::Tree => {
|
||||
tree.cursor.update(|c| *c = c.saturating_sub(10 * count));
|
||||
}
|
||||
|
||||
// ── Tree expand / collapse ────────────────────────────────────────────
|
||||
n if (n == actions::TREE_EXPAND || n == actions::ACTION_OPEN) && cur == Pane::Tree => {
|
||||
tree_expand(tree.cursor, tree.rows, tree.expanded);
|
||||
}
|
||||
actions::TREE_COLLAPSE if cur == Pane::Tree => {
|
||||
tree_collapse(tree.cursor, tree.rows, tree.expanded);
|
||||
}
|
||||
actions::TREE_TOGGLE if cur == Pane::Tree => {
|
||||
let id = tree.rows.with_untracked(|rows| {
|
||||
rows.get(tree.cursor.get_untracked()).map(|r| r.id.clone())
|
||||
});
|
||||
if let Some(id) = id {
|
||||
let is_open = tree.expanded.with_untracked(|e| e.contains(&id));
|
||||
if is_open {
|
||||
tree.expanded.update(|e| { e.remove(&id); });
|
||||
} else {
|
||||
tree.expanded.update(|e| { e.insert(id.clone()); });
|
||||
// Child loading is handled reactively by LibraryTree's Effect.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── List navigation ───────────────────────────────────────────────────
|
||||
actions::NAV_DOWN if cur == Pane::List => {
|
||||
let len = list.items.with_untracked(Vec::len);
|
||||
if len > 0 {
|
||||
list.cursor.update(|c| *c = (*c + count).min(len - 1));
|
||||
}
|
||||
}
|
||||
actions::NAV_UP if cur == Pane::List => {
|
||||
list.cursor.update(|c| *c = c.saturating_sub(count));
|
||||
}
|
||||
actions::NAV_TOP if cur == Pane::List => list.cursor.set(0),
|
||||
actions::NAV_BOTTOM if cur == Pane::List => {
|
||||
let len = list.items.with_untracked(Vec::len);
|
||||
if len > 0 {
|
||||
list.cursor.set(len - 1);
|
||||
}
|
||||
}
|
||||
actions::NAV_PAGE_DOWN if cur == Pane::List => {
|
||||
let len = list.items.with_untracked(Vec::len);
|
||||
if len > 0 {
|
||||
list.cursor.update(|c| *c = (*c + 10 * count).min(len - 1));
|
||||
}
|
||||
}
|
||||
actions::NAV_PAGE_UP if cur == Pane::List => {
|
||||
list.cursor.update(|c| *c = c.saturating_sub(10 * count));
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tree helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn tree_expand(
|
||||
cursor: RwSignal<usize>,
|
||||
rows: Memo<Vec<TreeRow>>,
|
||||
expanded: RwSignal<HashSet<String>>,
|
||||
) {
|
||||
let id = rows.with_untracked(|r| r.get(cursor.get_untracked()).map(|row| row.id.clone()));
|
||||
if let Some(id) = id {
|
||||
expanded.update(|e| { e.insert(id.clone()); });
|
||||
// Child loading is handled reactively by LibraryTree's Effect.
|
||||
}
|
||||
}
|
||||
|
||||
fn tree_collapse(
|
||||
cursor: RwSignal<usize>,
|
||||
rows: Memo<Vec<TreeRow>>,
|
||||
expanded: RwSignal<HashSet<String>>,
|
||||
) {
|
||||
let id = rows.with_untracked(|r| r.get(cursor.get_untracked()).map(|row| row.id.clone()));
|
||||
if let Some(id) = id {
|
||||
expanded.update(|e| { e.remove(&id); });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use brittle_keymap::actions;
|
||||
use leptos::prelude::*;
|
||||
|
||||
fn ev(name: &str) -> ActionEvent {
|
||||
ActionEvent { name: name.to_owned(), count: 1, seq: 0 }
|
||||
}
|
||||
|
||||
fn ev_count(name: &str, count: u32) -> ActionEvent {
|
||||
ActionEvent { name: name.to_owned(), count, seq: 0 }
|
||||
}
|
||||
|
||||
fn make_tree_rows(n: usize) -> Vec<TreeRow> {
|
||||
(0..n)
|
||||
.map(|i| TreeRow {
|
||||
id: format!("row-{i}"),
|
||||
name: format!("Row {i}"),
|
||||
depth: 0,
|
||||
expanded: false,
|
||||
may_have_children: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn make_ref_summaries(n: usize) -> Vec<ReferenceSummary> {
|
||||
use brittle_model::{EntryType, ReferenceId};
|
||||
(0..n)
|
||||
.map(|_| ReferenceSummary {
|
||||
id: ReferenceId::new(),
|
||||
cite_key: "x".into(),
|
||||
entry_type: EntryType::Misc,
|
||||
title: None,
|
||||
authors: vec![],
|
||||
year: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Set up signals and call handle_action in a Leptos reactive owner.
|
||||
// Returns the signal values after the action.
|
||||
fn run<F>(focused_init: Pane, tree_len: usize, list_len: usize, ev: &ActionEvent, check: F)
|
||||
where
|
||||
F: FnOnce(RwSignal<Pane>, RwSignal<usize>, RwSignal<usize>, RwSignal<HashSet<String>>),
|
||||
{
|
||||
let owner = Owner::new();
|
||||
owner.with(|| {
|
||||
let focused = RwSignal::new(focused_init);
|
||||
let tree_cursor = RwSignal::new(0usize);
|
||||
let list_cursor = RwSignal::new(0usize);
|
||||
let expanded = RwSignal::new(HashSet::<String>::new());
|
||||
let rows_data = make_tree_rows(tree_len);
|
||||
let rows = Memo::new(move |_| rows_data.clone());
|
||||
let list_items = RwSignal::new(make_ref_summaries(list_len));
|
||||
|
||||
handle_action(
|
||||
ev,
|
||||
focused,
|
||||
TreeState { cursor: tree_cursor, rows, expanded },
|
||||
ListState { cursor: list_cursor, items: list_items },
|
||||
);
|
||||
|
||||
check(focused, tree_cursor, list_cursor, expanded);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Focus switching ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn focus_left_sets_tree() {
|
||||
run(Pane::List, 0, 0, &ev(actions::FOCUS_LEFT), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::Tree);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_center_sets_list() {
|
||||
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_CENTER), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::List);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_right_sets_detail() {
|
||||
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_RIGHT), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::Detail);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_next_cycles_forward() {
|
||||
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::List);
|
||||
});
|
||||
run(Pane::List, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::Detail);
|
||||
});
|
||||
run(Pane::Detail, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::Tree);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_prev_cycles_backward() {
|
||||
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_PREV), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::Detail);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tree navigation ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn nav_down_advances_tree_cursor() {
|
||||
run(Pane::Tree, 5, 0, &ev(actions::NAV_DOWN), |_, tc, _, _| {
|
||||
assert_eq!(tc.get_untracked(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_down_clamps_at_tree_end() {
|
||||
let owner = Owner::new();
|
||||
owner.with(|| {
|
||||
let focused = RwSignal::new(Pane::Tree);
|
||||
let tree_cursor = RwSignal::new(4usize); // already at last row
|
||||
let expanded = RwSignal::new(HashSet::<String>::new());
|
||||
let rows_data = make_tree_rows(5);
|
||||
let rows = Memo::new(move |_| rows_data.clone());
|
||||
let list_cursor = RwSignal::new(0usize);
|
||||
let list_items = RwSignal::new(vec![]);
|
||||
|
||||
handle_action(
|
||||
&ev(actions::NAV_DOWN),
|
||||
focused,
|
||||
TreeState { cursor: tree_cursor, rows, expanded },
|
||||
ListState { cursor: list_cursor, items: list_items },
|
||||
);
|
||||
|
||||
assert_eq!(tree_cursor.get_untracked(), 4); // stayed at 4
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_up_decrements_tree_cursor() {
|
||||
let owner = Owner::new();
|
||||
owner.with(|| {
|
||||
let focused = RwSignal::new(Pane::Tree);
|
||||
let tree_cursor = RwSignal::new(3usize);
|
||||
let expanded = RwSignal::new(HashSet::<String>::new());
|
||||
let rows_data = make_tree_rows(5);
|
||||
let rows = Memo::new(move |_| rows_data.clone());
|
||||
handle_action(
|
||||
&ev(actions::NAV_UP),
|
||||
focused,
|
||||
TreeState { cursor: tree_cursor, rows, expanded },
|
||||
ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) },
|
||||
);
|
||||
assert_eq!(tree_cursor.get_untracked(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_up_clamps_at_zero() {
|
||||
run(Pane::Tree, 5, 0, &ev(actions::NAV_UP), |_, tc, _, _| {
|
||||
assert_eq!(tc.get_untracked(), 0); // already 0, stays 0
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_top_jumps_tree_to_start() {
|
||||
let owner = Owner::new();
|
||||
owner.with(|| {
|
||||
let focused = RwSignal::new(Pane::Tree);
|
||||
let tree_cursor = RwSignal::new(4usize);
|
||||
let expanded = RwSignal::new(HashSet::<String>::new());
|
||||
let rows_data = make_tree_rows(5);
|
||||
let rows = Memo::new(move |_| rows_data.clone());
|
||||
handle_action(
|
||||
&ev(actions::NAV_TOP),
|
||||
focused,
|
||||
TreeState { cursor: tree_cursor, rows, expanded },
|
||||
ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) },
|
||||
);
|
||||
assert_eq!(tree_cursor.get_untracked(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_bottom_jumps_tree_to_end() {
|
||||
run(Pane::Tree, 5, 0, &ev(actions::NAV_BOTTOM), |_, tc, _, _| {
|
||||
assert_eq!(tc.get_untracked(), 4);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_down_with_count_advances_multiple() {
|
||||
run(Pane::Tree, 10, 0, &ev_count(actions::NAV_DOWN, 3), |_, tc, _, _| {
|
||||
assert_eq!(tc.get_untracked(), 3);
|
||||
});
|
||||
}
|
||||
|
||||
// ── List navigation ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn nav_down_advances_list_cursor_when_focused() {
|
||||
run(Pane::List, 0, 5, &ev(actions::NAV_DOWN), |_, _, lc, _| {
|
||||
assert_eq!(lc.get_untracked(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_down_on_tree_does_not_advance_list_cursor() {
|
||||
run(Pane::Tree, 3, 3, &ev(actions::NAV_DOWN), |_, tc, lc, _| {
|
||||
assert_eq!(tc.get_untracked(), 1); // tree moved
|
||||
assert_eq!(lc.get_untracked(), 0); // list unchanged
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_bottom_jumps_list_to_last_item() {
|
||||
run(Pane::List, 0, 5, &ev(actions::NAV_BOTTOM), |_, _, lc, _| {
|
||||
assert_eq!(lc.get_untracked(), 4);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tree expand/collapse ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tree_expand_action_inserts_id_into_expanded() {
|
||||
run(Pane::Tree, 3, 0, &ev(actions::TREE_EXPAND), |_, _, _, exp| {
|
||||
assert!(exp.with_untracked(|e| e.contains("row-0")));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_collapse_action_removes_id_from_expanded() {
|
||||
let owner = Owner::new();
|
||||
owner.with(|| {
|
||||
let focused = RwSignal::new(Pane::Tree);
|
||||
let tree_cursor = RwSignal::new(0usize);
|
||||
let expanded = RwSignal::new(HashSet::from_iter(["row-0".to_string()]));
|
||||
let rows_data = make_tree_rows(3);
|
||||
let rows = Memo::new(move |_| rows_data.clone());
|
||||
handle_action(
|
||||
&ev(actions::TREE_COLLAPSE),
|
||||
focused,
|
||||
TreeState { cursor: tree_cursor, rows, expanded },
|
||||
ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) },
|
||||
);
|
||||
assert!(!expanded.with_untracked(|e| e.contains("row-0")));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_actions_are_no_ops_when_list_focused() {
|
||||
run(Pane::List, 3, 0, &ev(actions::TREE_EXPAND), |_, _, _, exp| {
|
||||
assert!(exp.with_untracked(|e| e.is_empty()));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user