use std::collections::HashSet;
use std::path::Path;
use std::time::Duration;
use iced::widget::{self, Row, button, column, container, pick_list, scrollable, text, text_input};
use iced::{Alignment, Color, Element, Length, Padding, Task};
use iced_selection;
use super::theme;
#[derive(Debug, Clone)]
pub struct PickOption {
pub value: String,
pub label: String,
}
impl PartialEq for PickOption {
fn eq(&self, other: &Self) -> bool {
self.value == other.value
}
}
impl Eq for PickOption {}
impl std::fmt::Display for PickOption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.label)
}
}
pub fn pick_list_style(_theme: &iced::Theme, _status: pick_list::Status) -> pick_list::Style {
pick_list::Style {
text_color: theme::TEXT_PRIMARY,
placeholder_color: theme::TEXT_MUTED,
handle_color: theme::TEXT_MUTED,
background: iced::Background::Color(theme::BG_ELEVATED),
border: iced::Border {
radius: 4.0.into(),
width: 1.0,
color: theme::BORDER_STRONG,
},
}
}
pub fn text_input_style(_theme: &iced::Theme, _status: text_input::Status) -> text_input::Style {
text_input::Style {
background: iced::Background::Color(theme::BG_ELEVATED),
border: iced::Border {
radius: 4.0.into(),
width: 1.0,
color: theme::BORDER_STRONG,
},
icon: theme::TEXT_MUTED,
placeholder: theme::TEXT_MUTED,
value: theme::TEXT_PRIMARY,
selection: theme::ACCENT,
}
}
pub fn error_banner<'a, Message: 'a>(err: &'a str) -> Element<'a, Message> {
container(text(err).size(13).color(theme::STATUS_ERROR))
.padding(8)
.style(|_theme: &iced::Theme| container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
1.0, 0.267, 0.4, 0.08,
))),
border: iced::Border {
radius: 4.0.into(),
..iced::Border::default()
},
..container::Style::default()
})
.into()
}
pub fn empty_state_placeholder<'a, Message: 'a>(
icon: iced::widget::Text<'a, iced::Theme, iced::Renderer>,
label: &'a str,
) -> Element<'a, Message> {
container(
column![
icon.size(48).color(theme::TEXT_MUTED),
text(label).size(14).color(theme::TEXT_MUTED),
]
.spacing(12)
.align_x(Alignment::Center),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
pub fn selectable_text<'a>(
content: impl iced_selection::text::IntoFragment<'a>,
color: Color,
) -> iced_selection::text::Text<'a, iced::Theme, iced::Renderer> {
iced_selection::text::Text::new(content).style(move |_theme| iced_selection::text::Style {
color: Some(color),
..Default::default()
})
}
pub fn diff_stats_row<'a, Message: 'a>(added: i64, removed: i64, size: f32) -> Row<'a, Message> {
let mut parts: Vec<Element<'a, Message>> = Vec::new();
if added > 0 {
parts.push(
text(format!("+{added}"))
.size(size)
.color(theme::STATUS_SUCCESS)
.into(),
);
}
if added > 0 && removed > 0 {
parts.push(text("/").size(size).color(theme::TEXT_MUTED).into());
}
if removed > 0 {
parts.push(
text(format!("\u{2212}{removed}"))
.size(size)
.color(theme::STATUS_ERROR)
.into(),
);
}
Row::with_children(parts)
.spacing(0)
.align_y(Alignment::Center)
}
pub async fn debounce_sleep(ms: u64, generation: u64) -> u64 {
tokio::time::sleep(Duration::from_millis(ms)).await;
generation
}
#[must_use]
pub const fn debounce_should_process(
generation: u64,
current_generation: u64,
pending: bool,
) -> bool {
generation == current_generation && pending
}
#[derive(Debug, Clone)]
pub struct TreeNode {
pub name: String,
pub full_path: String,
pub is_dir: bool,
pub children: Vec<TreeNode>,
pub error: Option<String>,
}
pub struct FileTree {
pub nodes: Vec<TreeNode>,
pub expanded_dirs: HashSet<String>,
pub tree_focused: bool,
pub tree_focus_index: usize,
pub visible_tree_nodes: Vec<(String, bool)>,
pub tree_scroll_id: iced::widget::Id,
pub scroll_y: f32,
pub viewport_h: Option<f32>,
}
impl FileTree {
#[must_use]
pub fn new(scroll_id: iced::widget::Id) -> Self {
Self {
nodes: Vec::new(),
expanded_dirs: HashSet::new(),
tree_focused: false,
tree_focus_index: 0,
visible_tree_nodes: Vec::new(),
tree_scroll_id: scroll_id,
scroll_y: 0.0,
viewport_h: None,
}
}
pub fn rebuild_visible(&mut self) {
self.visible_tree_nodes.clear();
Self::flatten_tree_nodes(
&self.nodes,
&self.expanded_dirs,
&mut self.visible_tree_nodes,
);
if self.visible_tree_nodes.is_empty() {
self.tree_focus_index = 0;
} else {
self.tree_focus_index = self.tree_focus_index.min(self.visible_tree_nodes.len() - 1);
}
}
fn flatten_tree_nodes(
nodes: &[TreeNode],
expanded: &HashSet<String>,
out: &mut Vec<(String, bool)>,
) {
for node in nodes {
out.push((node.full_path.clone(), node.is_dir));
if node.is_dir && expanded.contains(&node.full_path) && !node.children.is_empty() {
Self::flatten_tree_nodes(&node.children, expanded, out);
}
}
}
pub fn sort_nodes(nodes: &mut [TreeNode]) {
nodes.sort_by(|a, b| {
if a.is_dir != b.is_dir {
return b.is_dir.cmp(&a.is_dir);
}
a.name.to_lowercase().cmp(&b.name.to_lowercase())
});
for node in nodes {
Self::sort_nodes(&mut node.children);
}
}
pub fn clear(&mut self) {
self.nodes.clear();
self.expanded_dirs.clear();
self.tree_focused = false;
self.tree_focus_index = 0;
self.visible_tree_nodes.clear();
self.scroll_y = 0.0;
self.viewport_h = None;
}
pub fn focus_path(&mut self, path: &str) -> Option<usize> {
let pos = self
.visible_tree_nodes
.iter()
.position(|(p, _)| p == path)?;
self.tree_focus_index = pos;
Some(pos)
}
pub fn expand_dir_and_focus_first_child<Message: 'static>(
&mut self,
path: &str,
) -> Task<Message> {
debug_assert!(
self.expanded_dirs.contains(path),
"expand_dir_and_focus_first_child: path must be in expanded_dirs before calling"
);
self.rebuild_visible();
if let Some(dir_idx) = self.focus_path(path) {
if dir_idx + 1 < self.visible_tree_nodes.len() {
self.tree_focus_index = dir_idx + 1;
return scroll_to_tree_focus(self, ScrollMode::SnapToTop);
}
}
Task::none()
}
pub fn collapse_dir_and_keep_focus<Message: 'static>(&mut self, path: &str) -> Task<Message> {
debug_assert!(
!self.expanded_dirs.contains(path),
"collapse_dir_and_keep_focus: path must have been removed from expanded_dirs \
before calling"
);
self.rebuild_visible();
if self.focus_path(path).is_some() {
return scroll_to_tree_focus(self, ScrollMode::SnapToTop);
}
Task::none()
}
#[must_use]
pub fn focused_tree_node(&self) -> Option<(usize, String, bool)> {
if !self.tree_focused || self.visible_tree_nodes.is_empty() {
return None;
}
let idx = self.tree_focus_index.min(self.visible_tree_nodes.len() - 1);
let path = self.visible_tree_nodes[idx].0.clone();
let is_dir = self.visible_tree_nodes[idx].1;
Some((idx, path, is_dir))
}
#[must_use]
pub fn focused_is_expanded_dir(&self) -> bool {
self.focused_tree_node()
.is_some_and(|(_, ref path, is_dir)| is_dir && self.expanded_dirs.contains(path))
}
#[must_use]
pub fn focused_parent_path(&self) -> Option<String> {
let (_idx, path, _is_dir) = self.focused_tree_node()?;
let parent = Path::new(&path).parent()?;
let parent_str = parent.to_string_lossy().to_string();
if parent_str.is_empty() {
None
} else {
Some(parent_str)
}
}
}
pub const TREE_FONT_SIZE: f32 = 14.0;
pub const TREE_ICON_SIZE: f32 = 15.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollMode {
SnapToTop,
ScrollIntoView,
}
pub const ESTIMATED_TREE_ROW_HEIGHT: f32 = TREE_FONT_SIZE * 1.3;
#[allow(clippy::cast_precision_loss)]
pub fn scroll_to_tree_focus<Message: 'static>(
file_tree: &mut FileTree,
mode: ScrollMode,
) -> Task<Message> {
if file_tree.visible_tree_nodes.is_empty() {
return Task::none();
}
let focus_y = file_tree.tree_focus_index as f32 * ESTIMATED_TREE_ROW_HEIGHT;
match mode {
ScrollMode::SnapToTop => absolute_scroll_to(file_tree, focus_y),
ScrollMode::ScrollIntoView => match file_tree.viewport_h {
None => {
absolute_scroll_to(file_tree, focus_y)
}
Some(viewport_h) => {
let row_bottom = focus_y + ESTIMATED_TREE_ROW_HEIGHT;
let viewport_bottom = file_tree.scroll_y + viewport_h;
if row_bottom <= file_tree.scroll_y {
absolute_scroll_to(file_tree, focus_y)
} else if focus_y >= viewport_bottom {
file_tree.scroll_y = (file_tree.scroll_y + ESTIMATED_TREE_ROW_HEIGHT).max(0.0);
iced::widget::operation::scroll_by(
file_tree.tree_scroll_id.clone(),
iced::widget::operation::AbsoluteOffset {
x: 0.0,
y: ESTIMATED_TREE_ROW_HEIGHT,
},
)
} else {
Task::none()
}
}
},
}
}
fn absolute_scroll_to<Message: 'static>(file_tree: &mut FileTree, y: f32) -> Task<Message> {
file_tree.scroll_y = y.max(0.0);
iced::widget::operation::scroll_to(
file_tree.tree_scroll_id.clone(),
iced::widget::operation::AbsoluteOffset { x: 0.0, y },
)
}
pub fn build_tree_panel<'a, Message: 'a>(
file_tree: &'a FileTree,
tree_rows: Vec<Element<'a, Message>>,
on_scroll: impl Fn(scrollable::Viewport) -> Message + 'a,
) -> Element<'a, Message> {
let tree_body = widget::scrollable(column(tree_rows).spacing(0))
.id(file_tree.tree_scroll_id.clone())
.on_scroll(on_scroll)
.width(Length::Fill)
.height(Length::Fill)
.direction(theme::vertical_scrollbar())
.style(theme::scrollbar_style);
let tree_inner: Element<'_, Message> = container(tree_body)
.width(Length::Fixed(260.0))
.height(Length::Fill)
.style(|_t: &iced::Theme| container::Style {
background: Some(iced::Background::Color(theme::BG_SURFACE)),
border: iced::Border {
radius: 0.0.into(),
width: 0.0,
color: iced::Color::TRANSPARENT,
},
..Default::default()
})
.into();
if file_tree.tree_focused {
container(tree_inner)
.style(|_t: &iced::Theme| container::Style {
border: iced::Border {
color: theme::ACCENT_LIGHT,
width: 2.0,
radius: 0.0.into(),
},
..Default::default()
})
.into()
} else {
tree_inner
}
}
#[must_use]
pub fn tree_guide_prefix(ancestor_mask: u64, depth: usize, is_last: bool) -> String {
debug_assert!(
depth < 64,
"tree_guide_prefix: depth {depth} exceeds u64 bit limit (max 63)"
);
let mut s = String::new();
for d in 0..depth.saturating_sub(1) {
if ancestor_mask & (1u64 << d) != 0 {
s.push('│');
} else {
s.push(' ');
}
s.push(' ');
}
if depth > 0 {
if is_last {
s.push('└');
} else {
s.push('├');
}
s.push(' ');
}
s
}
pub fn render_tree_children<'a, Message>(
children: &'a [TreeNode],
depth: usize,
ancestor_mask: u64,
is_last: bool,
render_node: impl Fn(&'a TreeNode, usize, u64, bool) -> Element<'a, Message>,
) -> Vec<Element<'a, Message>> {
let child_count = children.len();
let cont_bit = if !is_last { 1u64 << depth } else { 0u64 };
let child_mask = ancestor_mask | cont_bit;
children
.iter()
.enumerate()
.map(|(i, child)| {
let child_is_last = i == child_count - 1;
render_node(child, depth + 1, child_mask, child_is_last)
})
.collect()
}
#[must_use]
pub fn tree_node_focused(tree: &FileTree, node_path: &str) -> bool {
tree.tree_focused
&& tree.tree_focus_index < tree.visible_tree_nodes.len()
&& tree.visible_tree_nodes[tree.tree_focus_index].0 == node_path
}
fn tree_node_button_style(
is_highlighted: bool,
) -> impl Fn(&iced::Theme, button::Status) -> button::Style {
move |_t: &iced::Theme, status| {
let bg = if is_highlighted {
theme::HOVER_STRONG
} else if status == button::Status::Hovered {
theme::HOVER
} else {
iced::Color::TRANSPARENT
};
button::Style {
background: Some(iced::Background::Color(bg)),
..Default::default()
}
}
}
pub fn tree_node_button<'a, Message: Clone + 'a>(
content: impl Into<Element<'a, Message>>,
is_highlighted: bool,
on_press: Option<Message>,
) -> Element<'a, Message> {
let mut btn = widget::button(content)
.style(tree_node_button_style(is_highlighted))
.width(Length::Fill)
.padding(Padding::ZERO);
if let Some(msg) = on_press {
btn = btn.on_press(msg);
}
btn.into()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tree(nodes: Vec<(&str, bool)>) -> FileTree {
let mut tree = FileTree::new(iced::widget::Id::new("test"));
tree.visible_tree_nodes = nodes
.into_iter()
.map(|(p, is_dir)| (p.to_string(), is_dir))
.collect();
tree
}
#[test]
fn focus_path_found() {
let mut tree = make_tree(vec![
("src", true),
("src/main.rs", false),
("Cargo.toml", false),
]);
assert_eq!(tree.focus_path("src/main.rs"), Some(1));
assert_eq!(tree.tree_focus_index, 1);
}
#[test]
fn focus_path_not_found() {
let mut tree = make_tree(vec![("src", true), ("Cargo.toml", false)]);
tree.tree_focus_index = 42;
assert_eq!(tree.focus_path("nonexistent"), None);
assert_eq!(tree.tree_focus_index, 42);
}
#[test]
fn focus_path_empty_tree() {
let mut tree = make_tree(vec![]);
assert_eq!(tree.focus_path("anything"), None);
assert_eq!(tree.tree_focus_index, 0);
}
#[test]
fn focus_path_first_node() {
let mut tree = make_tree(vec![("src", true), ("src/main.rs", false)]);
assert_eq!(tree.focus_path("src"), Some(0));
assert_eq!(tree.tree_focus_index, 0);
}
#[test]
fn focus_path_updates_index_no_residual() {
let mut tree = make_tree(vec![("a", false), ("b", false), ("c", false)]);
tree.focus_path("c");
assert_eq!(tree.tree_focus_index, 2);
tree.focus_path("a");
assert_eq!(tree.tree_focus_index, 0);
}
#[test]
fn focused_tree_node_not_focused() {
let tree = make_tree(vec![("src", true), ("src/main.rs", false)]);
assert!(tree.focused_tree_node().is_none());
}
#[test]
fn focused_tree_node_empty_visible_nodes() {
let mut tree = make_tree(vec![]);
tree.tree_focused = true;
assert!(tree.focused_tree_node().is_none());
}
#[test]
fn focused_tree_node_clamps_index() {
let mut tree = make_tree(vec![("a", false), ("b", false)]);
tree.tree_focused = true;
tree.tree_focus_index = 10;
let (idx, path, is_dir) = tree.focused_tree_node().unwrap();
assert_eq!(idx, 1);
assert_eq!(path, "b");
assert!(!is_dir);
}
#[test]
fn focused_tree_node_returns_correct_node() {
let mut tree = make_tree(vec![
("src", true),
("src/main.rs", false),
("Cargo.toml", false),
]);
tree.tree_focused = true;
tree.tree_focus_index = 1;
let (idx, path, is_dir) = tree.focused_tree_node().unwrap();
assert_eq!(idx, 1);
assert_eq!(path, "src/main.rs");
assert!(!is_dir);
}
#[test]
fn focused_tree_node_returns_directory() {
let mut tree = make_tree(vec![("src", true), ("src/main.rs", false)]);
tree.tree_focused = true;
tree.tree_focus_index = 0;
let (idx, path, is_dir) = tree.focused_tree_node().unwrap();
assert_eq!(idx, 0);
assert_eq!(path, "src");
assert!(is_dir);
}
#[test]
fn focused_is_expanded_dir_not_focused() {
let tree = make_tree(vec![("src", true)]);
assert!(!tree.focused_is_expanded_dir());
}
#[test]
fn focused_is_expanded_dir_empty_tree() {
let mut tree = make_tree(vec![]);
tree.tree_focused = true;
assert!(!tree.focused_is_expanded_dir());
}
#[test]
fn focused_is_expanded_dir_file() {
let mut tree = make_tree(vec![("main.rs", false)]);
tree.tree_focused = true;
assert!(!tree.focused_is_expanded_dir());
}
#[test]
fn focused_is_expanded_dir_collapsed_directory() {
let mut tree = make_tree(vec![("src", true)]);
tree.tree_focused = true;
assert!(!tree.focused_is_expanded_dir());
}
#[test]
fn focused_is_expanded_dir_expanded_directory() {
let mut tree = make_tree(vec![("src", true)]);
tree.tree_focused = true;
tree.expanded_dirs.insert("src".into());
assert!(tree.focused_is_expanded_dir());
}
#[test]
fn focused_parent_path_not_focused() {
let tree = make_tree(vec![("src/main.rs", false)]);
assert!(tree.focused_parent_path().is_none());
}
#[test]
fn focused_parent_path_empty_tree() {
let mut tree = make_tree(vec![]);
tree.tree_focused = true;
assert!(tree.focused_parent_path().is_none());
}
#[test]
fn focused_parent_path_root_item() {
let mut tree = make_tree(vec![("src", true)]);
tree.tree_focused = true;
assert!(tree.focused_parent_path().is_none());
}
#[test]
fn focused_parent_path_nested() {
let mut tree = make_tree(vec![("src/main.rs", false)]);
tree.tree_focused = true;
assert_eq!(tree.focused_parent_path(), Some("src".into()));
}
#[test]
fn focused_parent_path_deep_nested() {
let mut tree = make_tree(vec![("a/b/c/file.rs", false)]);
tree.tree_focused = true;
assert_eq!(tree.focused_parent_path(), Some("a/b/c".into()));
}
fn g(guide: &str) -> String {
guide.to_string()
}
#[test]
fn guide_prefix_depth_zero() {
assert_eq!(tree_guide_prefix(0, 0, false), g(""));
assert_eq!(tree_guide_prefix(0, 0, true), g(""));
assert_eq!(tree_guide_prefix(0b_1111, 0, false), g(""));
}
#[test]
fn guide_prefix_depth_one_not_last() {
assert_eq!(tree_guide_prefix(0, 1, false), g("├ "));
}
#[test]
fn guide_prefix_depth_one_last() {
assert_eq!(tree_guide_prefix(0, 1, true), g("└ "));
}
#[test]
fn guide_prefix_depth_one_ancestor_continues_not_last() {
assert_eq!(tree_guide_prefix(0b_01, 1, false), g("├ "));
}
#[test]
fn guide_prefix_depth_one_ancestor_continues_last() {
assert_eq!(tree_guide_prefix(0b_01, 1, true), g("└ "));
}
#[test]
fn guide_prefix_depth_two_mixed_ancestors() {
assert_eq!(tree_guide_prefix(0b_01, 2, false), g("│ ├ "));
}
#[test]
fn guide_prefix_depth_two_all_ancestors_continue_not_last() {
assert_eq!(tree_guide_prefix(0b_11, 2, false), g("│ ├ "));
}
#[test]
fn guide_prefix_depth_two_all_ancestors_continue_last() {
assert_eq!(tree_guide_prefix(0b_11, 2, true), g("│ └ "));
}
#[test]
fn guide_prefix_depth_two_no_ancestor_continuation() {
assert_eq!(tree_guide_prefix(0, 2, false), g(" ├ "));
}
#[test]
fn guide_prefix_depth_two_no_ancestor_continuation_last() {
assert_eq!(tree_guide_prefix(0, 2, true), g(" └ "));
}
#[test]
fn guide_prefix_deep_tree() {
assert_eq!(tree_guide_prefix(0b_1011, 5, false), g("│ │ │ ├ "));
}
#[test]
fn guide_prefix_deep_tree_last() {
assert_eq!(tree_guide_prefix(0b_1011, 5, true), g("│ │ │ └ "));
}
#[test]
fn guide_prefix_mask_ignores_bits_above_depth() {
assert_eq!(tree_guide_prefix(0b1_0000_0000, 1, false), g("├ "));
assert_eq!(tree_guide_prefix(0b1_0000_0000, 1, true), g("└ "));
}
#[test]
#[should_panic(expected = "exceeds u64 bit limit")]
fn guide_prefix_depth_overflow_debug() {
let _ = tree_guide_prefix(0, 64, false);
}
fn tree_with_src_dir() -> FileTree {
let mut tree = FileTree::new(iced::widget::Id::new("test"));
tree.nodes = vec![TreeNode {
name: "src".into(),
full_path: "src".into(),
is_dir: true,
children: vec![
TreeNode {
name: "lib.rs".into(),
full_path: "src/lib.rs".into(),
is_dir: false,
children: vec![],
error: None,
},
TreeNode {
name: "main.rs".into(),
full_path: "src/main.rs".into(),
is_dir: false,
children: vec![],
error: None,
},
],
error: None,
}];
tree
}
#[test]
fn expand_dir_advances_to_first_child() {
let mut tree = tree_with_src_dir();
tree.expanded_dirs.insert("src".into());
assert!(tree.visible_tree_nodes.is_empty());
let _task = tree.expand_dir_and_focus_first_child::<()>("src");
assert_eq!(tree.visible_tree_nodes.len(), 3);
assert_eq!(tree.visible_tree_nodes[0].0, "src");
assert_eq!(tree.visible_tree_nodes[1].0, "src/lib.rs");
assert_eq!(tree.visible_tree_nodes[2].0, "src/main.rs");
assert_eq!(tree.tree_focus_index, 1);
}
#[test]
fn expand_dir_no_children_stays_on_dir() {
let mut tree = tree_with_src_dir();
tree.expanded_dirs.insert("src".into());
tree.nodes[0].children.clear();
let _task = tree.expand_dir_and_focus_first_child::<()>("src");
assert_eq!(tree.visible_tree_nodes.len(), 1);
assert_eq!(tree.visible_tree_nodes[0].0, "src");
assert_eq!(tree.tree_focus_index, 0);
}
#[test]
fn expand_dir_not_in_expanded_dirs_panics_in_debug() {
let mut tree = tree_with_src_dir();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _task = tree.expand_dir_and_focus_first_child::<()>("src");
}));
#[cfg(debug_assertions)]
assert!(
result.is_err(),
"debug_assert should fire when path not in expanded_dirs"
);
#[cfg(not(debug_assertions))]
assert!(result.is_ok(), "no panic expected in release builds");
}
#[test]
fn collapse_dir_keeps_focus_on_directory() {
let mut tree = tree_with_src_dir();
tree.expanded_dirs.insert("src".into());
tree.rebuild_visible();
assert_eq!(tree.visible_tree_nodes.len(), 3);
tree.expanded_dirs.remove("src");
let _task = tree.collapse_dir_and_keep_focus::<()>("src");
assert_eq!(tree.visible_tree_nodes.len(), 1);
assert_eq!(tree.visible_tree_nodes[0].0, "src");
assert_eq!(tree.tree_focus_index, 0);
}
#[test]
fn collapse_dir_not_in_visible_tree_still_finds_it() {
let mut tree = tree_with_src_dir();
let _task = tree.collapse_dir_and_keep_focus::<()>("src");
assert_eq!(tree.visible_tree_nodes.len(), 1);
assert_eq!(tree.visible_tree_nodes[0].0, "src");
assert_eq!(tree.tree_focus_index, 0);
}
#[test]
fn collapse_dir_still_in_expanded_dirs_panics_in_debug() {
let mut tree = tree_with_src_dir();
tree.expanded_dirs.insert("src".into());
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _task = tree.collapse_dir_and_keep_focus::<()>("src");
}));
#[cfg(debug_assertions)]
assert!(
result.is_err(),
"debug_assert should fire when path still in expanded_dirs"
);
#[cfg(not(debug_assertions))]
assert!(result.is_ok(), "no panic expected in release builds");
}
fn tree_with_viewport(n: usize, scroll_y: f32, viewport_h: f32) -> FileTree {
let mut tree = FileTree::new(iced::widget::Id::new("scroll_test"));
tree.visible_tree_nodes = (0..n).map(|i| (format!("file_{i}.rs"), false)).collect();
tree.scroll_y = scroll_y;
tree.viewport_h = Some(viewport_h);
tree
}
#[test]
fn scroll_into_view_row_fully_visible_no_scroll() {
let mut tree = tree_with_viewport(30, 40.0, 400.0);
tree.tree_focus_index = 3;
let _task = scroll_to_tree_focus::<()>(&mut tree, ScrollMode::ScrollIntoView);
assert!(
(tree.scroll_y - 40.0).abs() < 0.01,
"scroll_y should remain 40"
);
}
#[test]
fn scroll_into_view_row_below_viewport_advances_one_row() {
let mut tree = tree_with_viewport(30, 0.0, 200.0);
tree.tree_focus_index = 15;
let _task = scroll_to_tree_focus::<()>(&mut tree, ScrollMode::ScrollIntoView);
assert!(
(tree.scroll_y - 18.2_f32).abs() < 0.01,
"scroll_y should advance by ~18.2, got {}",
tree.scroll_y
);
}
#[test]
fn scroll_into_view_row_above_viewport_brings_to_top() {
let mut tree = tree_with_viewport(30, 100.0, 400.0);
tree.tree_focus_index = 3;
let _task = scroll_to_tree_focus::<()>(&mut tree, ScrollMode::ScrollIntoView);
assert!(
(tree.scroll_y - 54.6).abs() < 0.01,
"scroll_y should be ~54.6, got {}",
tree.scroll_y
);
}
#[test]
fn scroll_into_view_partially_visible_at_top_edge_no_scroll() {
let mut tree = tree_with_viewport(30, 50.0, 400.0);
tree.tree_focus_index = 2;
let _task = scroll_to_tree_focus::<()>(&mut tree, ScrollMode::ScrollIntoView);
assert!(
(tree.scroll_y - 50.0).abs() < 0.01,
"scroll_y should remain 50, got {}",
tree.scroll_y
);
}
#[test]
fn scroll_into_view_unknown_viewport_falls_back_to_snap() {
let mut tree = tree_with_viewport(30, 10.0, 0.0);
tree.viewport_h = None;
tree.tree_focus_index = 10;
let _task = scroll_to_tree_focus::<()>(&mut tree, ScrollMode::ScrollIntoView);
assert!(
(tree.scroll_y - 182.0).abs() < 0.01,
"scroll_y should snap to ~182, got {}",
tree.scroll_y
);
}
#[test]
fn scroll_snap_to_top_sets_scroll_y() {
let mut tree = tree_with_viewport(30, 0.0, 400.0);
tree.tree_focus_index = 8;
let _task = scroll_to_tree_focus::<()>(&mut tree, ScrollMode::SnapToTop);
assert!(
(tree.scroll_y - 145.6).abs() < 0.01,
"scroll_y should be ~145.6, got {}",
tree.scroll_y
);
}
#[test]
fn scroll_into_view_empty_tree_noop() {
let mut tree = FileTree::new(iced::widget::Id::new("scroll_test"));
tree.viewport_h = Some(400.0);
let _task = scroll_to_tree_focus::<()>(&mut tree, ScrollMode::ScrollIntoView);
assert!((tree.scroll_y - 0.0).abs() < 0.01);
}
}