688 lines
26 KiB
Rust
688 lines
26 KiB
Rust
//! 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()));
|
|
|
|
let layout_config = use_context::<crate::LayoutContext>()
|
|
.expect("LayoutContext missing")
|
|
.0;
|
|
|
|
// ── UI state ──────────────────────────────────────────────────────────────
|
|
let focused = RwSignal::new(Pane::Tree);
|
|
|
|
// Resizing state
|
|
let left_width = RwSignal::new(220);
|
|
let right_width = RwSignal::new(300);
|
|
let resizing_left = RwSignal::new(false);
|
|
let resizing_right = RwSignal::new(false);
|
|
|
|
// Initial widths from layout config (fractions)
|
|
Effect::new(move |_| {
|
|
let config = layout_config.get();
|
|
if let Some(win) = web_sys::window() {
|
|
if let Ok(Some(total_width)) = win.inner_width().map(|v| v.as_f64()) {
|
|
left_width.set((total_width * config.left_pane_fraction as f64) as i32);
|
|
right_width.set((total_width * config.right_pane_fraction as f64) as i32);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
|
|
// ── Resizing Logic ────────────────────────────────────────────────────────
|
|
let on_mousemove = window_event_listener(leptos::ev::mousemove, move |ev| {
|
|
if let Some(win) = web_sys::window() {
|
|
if let Ok(Some(total_width)) = win.inner_width().map(|v| v.as_f64()) {
|
|
let total_width = total_width as i32;
|
|
let config = layout_config.get_untracked();
|
|
|
|
if resizing_left.get_untracked() {
|
|
let new_width = ev.client_x();
|
|
// Constraints: min, max, AND leave enough space for center
|
|
let max_allowed = (total_width - right_width.get_untracked() - config.center_pane_min).max(config.left_pane_min);
|
|
left_width.set(new_width.max(config.left_pane_min).min(config.left_pane_max).min(max_allowed));
|
|
} else if resizing_right.get_untracked() {
|
|
let new_width = total_width - ev.client_x();
|
|
// Constraints: min, max, AND leave enough space for center
|
|
let max_allowed = (total_width - left_width.get_untracked() - config.center_pane_min).max(config.right_pane_min);
|
|
right_width.set(new_width.max(config.right_pane_min).min(config.right_pane_max).min(max_allowed));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let on_mouseup = window_event_listener(leptos::ev::mouseup, move |_| {
|
|
resizing_left.set(false);
|
|
resizing_right.set(false);
|
|
});
|
|
|
|
on_cleanup(move || {
|
|
on_mousemove.remove();
|
|
on_mouseup.remove();
|
|
});
|
|
|
|
// ── 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 |_| {
|
|
let trigger = reload_trigger.map(|t| t.get()).unwrap_or(0);
|
|
if trigger == 0 { return; } // no repository open yet
|
|
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();
|
|
let trigger = reload_trigger.map(|t| t.get_untracked()).unwrap_or(0);
|
|
if trigger == 0 { return; } // no repository open yet
|
|
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"
|
|
style=move || format!("width: {}px", left_width.get())
|
|
>
|
|
<LibraryTree
|
|
root_libs=root_libs
|
|
children_cache=children_cache
|
|
expanded=expanded
|
|
cursor=tree_cursor
|
|
focused=focused
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
class="resizer"
|
|
class:resizing=move || resizing_left.get()
|
|
on:mousedown=move |ev| {
|
|
ev.prevent_default();
|
|
resizing_left.set(true);
|
|
}
|
|
/>
|
|
|
|
<div class="pane pane-center">
|
|
<PubList
|
|
items=list_items
|
|
cursor=list_cursor
|
|
focused=focused
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
class="resizer"
|
|
class:resizing=move || resizing_right.get()
|
|
on:mousedown=move |ev| {
|
|
ev.prevent_default();
|
|
resizing_right.set(true);
|
|
}
|
|
/>
|
|
|
|
<div
|
|
class="pane pane-right"
|
|
class:pane-focused=move || focused.get() == Pane::Detail
|
|
style=move || format!("width: {}px", right_width.get())
|
|
>
|
|
<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()));
|
|
});
|
|
}
|
|
}
|