use std::collections::HashSet;
use std::path::Path;
use std::time::Duration;
use iced::keyboard;
use iced::widget::{
self, Column, Row, Space, button, column, container, pick_list, row, scrollable, stack, text,
text_editor, text_input, tooltip,
};
use iced::{Alignment, Color, Element, Length, Padding, Task};
use iced_selection;
use super::theme;
use iced_fonts::lucide;
#[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)
}
}
#[must_use]
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,
},
}
}
#[must_use]
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,
}
}
#[must_use]
pub fn text_input_highlight_style(
_theme: &iced::Theme,
_status: text_input::Status,
) -> text_input::Style {
text_input::Style {
background: iced::Background::Color(theme::ACCENT.scale_alpha(0.07)),
border: iced::Border {
radius: 4.0.into(),
width: 1.5,
color: theme::ACCENT,
},
icon: theme::TEXT_MUTED,
placeholder: theme::TEXT_MUTED,
value: theme::TEXT_PRIMARY,
selection: theme::ACCENT,
}
}
#[must_use]
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::pill_style(theme::STATUS_ERROR.scale_alpha(0.08)))
.into()
}
#[must_use]
pub fn loading_text<'a, Message: 'a>() -> Element<'a, Message> {
text("Loading...").size(14).color(theme::TEXT_MUTED).into()
}
#[must_use]
pub fn push_error_banner<'a, Message: 'a>(
mut col: Column<'a, Message>,
err: Option<&'a str>,
) -> Column<'a, Message> {
if let Some(err) = err {
col = col.push(error_banner(err));
col = col.push(Space::new().height(8));
}
col
}
#[must_use]
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()
}
#[must_use]
pub fn badge_pill<'a, Message: 'a>(
label: String,
colors: (Color, Color),
text_size: u32,
padding: [u16; 2],
) -> Element<'a, Message> {
container(text(label).size(text_size).color(colors.1))
.padding(padding)
.style(theme::pill_style(colors.0))
.into()
}
#[must_use]
pub fn role_badge<'a, Message: 'a>(
role: String,
colors: (Color, Color),
text_size: u32,
padding: [u16; 2],
selectable: bool,
) -> Element<'a, Message> {
let label: Element<'a, Message> = if selectable {
selectable_text(role, colors.0).size(text_size).into()
} else {
text(role).size(text_size).color(colors.0).into()
};
container(label)
.padding(padding)
.style(theme::pill_style(colors.1))
.into()
}
#[must_use]
pub fn maint_badge<'a, Message: 'a>(enabled: bool) -> Column<'a, Message> {
column![
text("Maint").size(8).color(theme::TEXT_MUTED),
text(if enabled { "ON" } else { "OFF" })
.size(9)
.color(if enabled {
theme::ACCENT
} else {
theme::TEXT_MUTED
}),
]
.spacing(0)
.align_x(Alignment::Center)
}
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),
selection: theme::ACCENT_DIM,
})
}
#[must_use]
pub fn tab_close_button<'a, Message: Clone + 'a>(
is_active: bool,
on_press: Message,
) -> widget::Button<'a, Message> {
widget::button(
lucide::x::<iced::Theme, iced::Renderer>()
.size(12)
.color(if is_active {
theme::TEXT_SECONDARY
} else {
theme::TEXT_FAINT
}),
)
.on_press(on_press)
.style(theme::button_transparent)
.padding(0)
}
#[must_use]
pub fn tab_scrollable<'a, Message: 'a>(
tab_buttons: Vec<Element<'a, Message>>,
scroll_id: Option<widget::Id>,
on_scroll: Option<impl Fn(scrollable::Viewport) -> Message + 'a>,
) -> Element<'a, Message> {
let mut sc = scrollable(row(tab_buttons).spacing(0).width(Length::Fill))
.direction(theme::horizontal_scrollbar())
.style(theme::scrollbar_style)
.width(Length::Fill)
.height(Length::Shrink);
if let Some(id) = scroll_id {
sc = sc.id(id);
}
if let Some(on_scroll) = on_scroll {
sc = sc.on_scroll(on_scroll);
}
container(sc)
.style(theme::surface_container_style)
.width(Length::Fill)
.into()
}
pub struct ChatComposerOptions<'a, M> {
pub sending: bool,
pub min_height: f32,
pub max_height: f32,
pub controls: Vec<Element<'a, M>>,
pub grey_on_empty: bool,
pub send_tooltip: &'a str,
}
#[must_use]
pub fn chat_composer<'a, M: Clone + 'a>(
content: &'a text_editor::Content,
on_action: impl Fn(text_editor::Action) -> M + 'a,
send_msg: M,
placeholder: &'a str,
options: ChatComposerOptions<'a, M>,
) -> Element<'a, M> {
let send_msg_btn = send_msg.clone();
let mut input_editor = text_editor(content)
.on_action(on_action)
.placeholder(placeholder)
.min_height(options.min_height)
.max_height(options.max_height)
.style(|_theme: &iced::Theme, status| {
let is_focused = matches!(status, text_editor::Status::Focused { .. });
text_editor::Style {
background: iced::Background::Color(theme::BG_ELEVATED),
border: iced::Border {
radius: 8.0.into(),
width: if is_focused { 1.0 } else { 0.0 },
color: if is_focused {
theme::ACCENT
} else {
iced::Color::TRANSPARENT
},
},
placeholder: theme::TEXT_MUTED,
value: theme::TEXT_PRIMARY,
selection: theme::ACCENT_DIM,
}
})
.key_binding(move |key_press| {
let km = super::detect_keyboard_mods(key_press.modifiers);
if km.is_shortcut_platform_mod()
&& matches!(
&key_press.key,
keyboard::Key::Character(c) if c == "z"
)
{
return None;
}
if key_press.key == keyboard::Key::Named(keyboard::key::Named::Enter)
&& !key_press.modifiers.shift()
{
Some(text_editor::Binding::Custom(send_msg.clone()))
} else {
text_editor::Binding::from_key_press(key_press)
}
});
if !options.controls.is_empty() {
input_editor = input_editor.padding(iced::Padding::new(5.0).right(38.0));
}
let send_disabled = options.sending
|| (options.grey_on_empty && (content.is_empty() || content.text().trim().is_empty()));
let send_btn = tooltip(
button(
lucide::send::<iced::Theme, iced::Renderer>()
.size(14)
.color(if send_disabled {
theme::TEXT_MUTED
} else {
theme::ACCENT
}),
)
.style(theme::icon_button_style(send_disabled))
.on_press_maybe(if send_disabled {
None
} else {
Some(send_msg_btn)
})
.padding(4),
text(options.send_tooltip).size(11),
tooltip::Position::Top,
)
.style(theme::tooltip_style);
let mut col = Column::new().spacing(6).align_x(Alignment::End);
for c in options.controls {
col = col.push(c);
}
let overlay: Element<'_, M> = container(col.push(send_btn))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::End)
.align_y(Alignment::End)
.padding(iced::Padding::default().right(8.0).bottom(8.0))
.into();
container(stack([input_editor.into(), overlay]))
.padding(8)
.style(theme::base_container_style)
.into()
}
#[must_use]
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
}
#[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>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TreeNavDirection {
Up,
Down,
}
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);
}
}
#[must_use]
pub fn nav_up(&mut self) -> bool {
if self.tree_focused && self.tree_focus_index > 0 {
self.tree_focus_index -= 1;
true
} else {
false
}
}
#[must_use]
pub fn nav_down(&mut self) -> bool {
if self.tree_focused && self.tree_focus_index + 1 < self.visible_tree_nodes.len() {
self.tree_focus_index += 1;
true
} else {
false
}
}
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 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()
}
pub fn focus_parent<Message: 'static>(&mut self) -> Task<Message> {
match self.focused_parent_path() {
Some(ref p) if self.focus_path(p).is_some() => {
scroll_to_tree_focus(self, ScrollMode::SnapToTop)
}
_ => Task::none(),
}
}
pub fn focus_next_row<Message: 'static>(&mut self, idx: usize) -> Task<Message> {
if idx + 1 < self.visible_tree_nodes.len() {
self.tree_focus_index = idx + 1;
scroll_to_tree_focus(self, ScrollMode::SnapToTop)
} else {
Task::none()
}
}
pub fn nav_and_scroll<Message: 'static>(
&mut self,
direction: TreeNavDirection,
) -> Task<Message> {
let moved = match direction {
TreeNavDirection::Up => self.nav_up(),
TreeNavDirection::Down => self.nav_down(),
};
if moved {
scroll_to_tree_focus(self, ScrollMode::ScrollIntoView)
} else {
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;
pub const TREE_MIN_WIDTH: f32 = 260.0;
pub const TREE_MAX_WIDTH: f32 = 400.0;
pub const TREE_SCROLLBAR_ALLOWANCE: f32 = 10.0;
pub const TREE_ROW_H_PADDING: f32 = 8.0;
pub const JETBRAINS_MONO_ADVANCE: f32 = 0.6;
pub const LUCIDE_ADVANCE: f32 = 1.0;
#[must_use]
#[expect(clippy::cast_precision_loss)] pub fn mono_text_width(chars: usize, size: f32) -> f32 {
chars as f32 * size * JETBRAINS_MONO_ADVANCE
}
#[must_use]
pub fn tree_row_natural_width(
guide_chars: usize,
icon_size: f32,
name: &str,
name_size: f32,
name_suffix: Option<(&str, f32)>,
counts: Option<(&str, &str)>,
) -> f32 {
let mut w = mono_text_width(guide_chars, TREE_FONT_SIZE)
+ icon_size * LUCIDE_ADVANCE
+ 4.0
+ mono_text_width(name.chars().count(), name_size);
if let Some((suffix, size)) = name_suffix {
w += 4.0 + mono_text_width(suffix.chars().count(), size);
}
if let Some((add, rem)) = counts {
let counts_chars = if add.is_empty() {
rem.chars().count()
} else if rem.is_empty() {
add.chars().count()
} else {
add.chars().count() + 1 + rem.chars().count()
};
w += mono_text_width(counts_chars, 10.0) + 6.0;
}
w
}
pub fn collect_tree_row_widths(
nodes: &[TreeNode],
expanded: &HashSet<String>,
row_width: impl Fn(&TreeNode, usize) -> f32,
) -> Vec<f32> {
fn walk(
nodes: &[TreeNode],
expanded: &HashSet<String>,
row_width: &impl Fn(&TreeNode, usize) -> f32,
depth: usize,
out: &mut Vec<f32>,
) {
for node in nodes {
out.push(row_width(node, depth));
if node.is_dir && expanded.contains(&node.full_path) {
walk(&node.children, expanded, row_width, depth + 1, out);
}
}
}
let mut out = Vec::new();
walk(nodes, expanded, &row_width, 0, &mut out);
out
}
#[must_use]
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] pub fn tree_panel_width(file_tree: &FileTree, row_widths: &[f32]) -> f32 {
let widest = match file_tree.viewport_h {
Some(viewport_h) if viewport_h > 0.0 => {
let row_h = ESTIMATED_TREE_ROW_HEIGHT;
let first = (file_tree.scroll_y / row_h).floor().max(0.0) as usize;
let last = ((file_tree.scroll_y + viewport_h) / row_h).ceil() as usize + 1;
row_widths
.iter()
.enumerate()
.skip(first.saturating_sub(1))
.take(last - first + 2)
.map(|(_, w)| *w)
.fold(0.0f32, f32::max)
}
_ => row_widths.iter().copied().fold(0.0f32, f32::max),
};
(widest + 2.0 * TREE_ROW_H_PADDING + TREE_SCROLLBAR_ALLOWANCE)
.clamp(TREE_MIN_WIDTH, TREE_MAX_WIDTH)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollMode {
SnapToTop,
ScrollIntoView,
}
pub const ESTIMATED_TREE_ROW_HEIGHT: f32 = TREE_FONT_SIZE * 1.3;
#[expect(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>>,
row_widths: &[f32],
on_scroll: impl Fn(scrollable::Viewport) -> Message + 'a,
) -> Element<'a, Message> {
let panel_width = tree_panel_width(file_tree, row_widths);
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(panel_width))
.height(Length::Fill)
.style(theme::surface_container_style)
.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()
}
pub fn render_tree_node<'a, Message>(
is_dir: bool,
render_dir: impl FnOnce() -> Element<'a, Message>,
render_file: impl FnOnce() -> Element<'a, Message>,
) -> Element<'a, Message> {
if is_dir { render_dir() } else { render_file() }
}
#[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]
#[expect(clippy::type_complexity)]
fn focus_path_cases() {
#[rustfmt::skip]
let cases: &[(&str, &[(&str, bool)], &str, Option<usize>, usize)] = &[
("found", &[("src", true), ("src/main.rs", false), ("Cargo.toml", false)], "src/main.rs", Some(1), 1),
("empty_tree", &[], "anything", None, 0),
("first_node", &[("src", true), ("src/main.rs", false)], "src", Some(0), 0),
];
for &(name, nodes, path, expected, expected_index) in cases {
let mut tree = make_tree(nodes.to_vec());
assert_eq!(tree.focus_path(path), expected, "case: {name}");
assert_eq!(
tree.tree_focus_index, expected_index,
"case: {name} (index)"
);
}
}
#[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_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]
#[expect(clippy::type_complexity)]
fn focused_tree_node_cases() {
#[rustfmt::skip]
let cases: &[(&str, &[(&str, bool)], bool, usize, Option<(usize, &str, bool)>)] = &[
("not_focused", &[("src", true), ("src/main.rs", false)], false, 0, None),
("empty_visible_nodes", &[], true, 0, None),
("clamps_index", &[("a", false), ("b", false)], true, 10, Some((1, "b", false))),
("returns_correct_node", &[("src", true), ("src/main.rs", false), ("Cargo.toml", false)], true, 1, Some((1, "src/main.rs", false))),
("returns_directory", &[("src", true), ("src/main.rs", false)], true, 0, Some((0, "src", true))),
];
for &(name, nodes, focused, focus_index, expected) in cases {
let mut tree = make_tree(nodes.to_vec());
tree.tree_focused = focused;
tree.tree_focus_index = focus_index;
assert_eq!(
tree.focused_tree_node(),
expected.map(|(i, p, d)| (i, p.to_string(), d)),
"case: {name}"
);
}
}
#[test]
fn nav_cases() {
let cases: &[(&str, &str, bool, usize, bool, usize)] = &[
("up_at_top_clamped", "up", true, 0, false, 0),
("down_at_bottom_clamped", "down", true, 1, false, 1),
("up_moves_focus", "up", true, 1, true, 0),
("down_moves_focus", "down", true, 0, true, 1),
("ignored_when_not_focused_up", "up", false, 0, false, 0),
("ignored_when_not_focused_down", "down", false, 0, false, 0),
];
for &(name, dir, focused, start, expected_moved, expected_index) in cases {
let mut tree = make_tree(vec![("a", false), ("b", false)]);
tree.tree_focused = focused;
tree.tree_focus_index = start;
let moved = if dir == "up" {
tree.nav_up()
} else {
tree.nav_down()
};
assert_eq!(moved, expected_moved, "case: {name}");
assert_eq!(
tree.tree_focus_index, expected_index,
"case: {name} (index)"
);
}
}
#[test]
fn rebuild_visible_clamps_high_focus_index() {
let mut tree = FileTree::new(iced::widget::Id::new("test"));
tree.nodes = vec![
TreeNode {
name: "a".into(),
full_path: "a".into(),
is_dir: false,
children: vec![],
error: None,
},
TreeNode {
name: "b".into(),
full_path: "b".into(),
is_dir: false,
children: vec![],
error: None,
},
TreeNode {
name: "c".into(),
full_path: "c".into(),
is_dir: false,
children: vec![],
error: None,
},
];
tree.rebuild_visible();
assert_eq!(tree.visible_tree_nodes.len(), 3);
tree.tree_focus_index = 999;
tree.rebuild_visible();
assert_eq!(tree.tree_focus_index, 2);
}
#[test]
fn rebuild_visible_empty_tree_resets_focus_index() {
let mut tree = make_tree(vec![("a", false)]);
tree.tree_focus_index = 0;
tree.nodes.clear();
tree.expanded_dirs.clear();
tree.rebuild_visible();
assert!(tree.visible_tree_nodes.is_empty());
assert_eq!(tree.tree_focus_index, 0);
}
#[test]
#[expect(clippy::type_complexity)]
fn focused_is_expanded_dir_cases() {
#[rustfmt::skip]
let cases: &[(&str, &[(&str, bool)], bool, Option<&str>, bool)] = &[
("not_focused", &[("src", true)], false, None, false),
("empty_tree", &[], true, None, false),
("file", &[("main.rs", false)], true, None, false),
("collapsed_directory", &[("src", true)], true, None, false),
("expanded_directory", &[("src", true)], true, Some("src"), true),
];
for &(name, nodes, focused, expanded, expected) in cases {
let mut tree = make_tree(nodes.to_vec());
tree.tree_focused = focused;
if let Some(dir) = expanded {
tree.expanded_dirs.insert(dir.into());
}
assert_eq!(tree.focused_is_expanded_dir(), expected, "case: {name}");
}
}
#[test]
fn focused_parent_path_cases() {
#[rustfmt::skip]
#[expect(clippy::type_complexity)] let cases: &[(&str, &[(&str, bool)], bool, Option<&str>)] = &[
("not_focused", &[("src/main.rs", false)], false, None),
("empty_tree", &[], true, None),
("root_item", &[("src", true)], true, None),
("nested", &[("src/main.rs", false)], true, Some("src")),
("deep_nested", &[("a/b/c/file.rs", false)], true, Some("a/b/c")),
];
for &(name, nodes, focused, expected) in cases {
let mut tree = make_tree(nodes.to_vec());
tree.tree_focused = focused;
assert_eq!(
tree.focused_parent_path(),
expected.map(str::to_string),
"case: {name}"
);
}
}
struct GuidePrefixCase {
name: &'static str,
mask: u64,
depth: usize,
is_last: bool,
expected: &'static str,
}
#[expect(clippy::too_many_lines)]
#[test]
fn tree_guide_prefix_cases() {
let cases = [
GuidePrefixCase {
name: "root, mask=0, not last",
mask: 0,
depth: 0,
is_last: false,
expected: "",
},
GuidePrefixCase {
name: "root, mask=0, last",
mask: 0,
depth: 0,
is_last: true,
expected: "",
},
GuidePrefixCase {
name: "root, mask=all, not last",
mask: 0b_1111,
depth: 0,
is_last: false,
expected: "",
},
GuidePrefixCase {
name: "depth 1, mask=0, not last",
mask: 0,
depth: 1,
is_last: false,
expected: "├ ",
},
GuidePrefixCase {
name: "depth 1, mask=0, last",
mask: 0,
depth: 1,
is_last: true,
expected: "└ ",
},
GuidePrefixCase {
name: "depth 1, mask=0b01, not last",
mask: 0b_01,
depth: 1,
is_last: false,
expected: "├ ",
},
GuidePrefixCase {
name: "depth 1, mask=0b01, last",
mask: 0b_01,
depth: 1,
is_last: true,
expected: "└ ",
},
GuidePrefixCase {
name: "depth 2, mask=0b01, not last",
mask: 0b_01,
depth: 2,
is_last: false,
expected: "│ ├ ",
},
GuidePrefixCase {
name: "depth 2, mask=0b11, not last",
mask: 0b_11,
depth: 2,
is_last: false,
expected: "│ ├ ",
},
GuidePrefixCase {
name: "depth 2, mask=0b11, last",
mask: 0b_11,
depth: 2,
is_last: true,
expected: "│ └ ",
},
GuidePrefixCase {
name: "depth 2, mask=0, not last",
mask: 0,
depth: 2,
is_last: false,
expected: " ├ ",
},
GuidePrefixCase {
name: "depth 2, mask=0, last",
mask: 0,
depth: 2,
is_last: true,
expected: " └ ",
},
GuidePrefixCase {
name: "depth 5, mask=0b1011, not last",
mask: 0b_1011,
depth: 5,
is_last: false,
expected: "│ │ │ ├ ",
},
GuidePrefixCase {
name: "depth 5, mask=0b1011, last",
mask: 0b_1011,
depth: 5,
is_last: true,
expected: "│ │ │ └ ",
},
GuidePrefixCase {
name: "high bits, mask=0x100, not last",
mask: 0b1_0000_0000,
depth: 1,
is_last: false,
expected: "├ ",
},
GuidePrefixCase {
name: "high bits, mask=0x100, last",
mask: 0b1_0000_0000,
depth: 1,
is_last: true,
expected: "└ ",
},
];
for case in &cases {
assert_eq!(
tree_guide_prefix(case.mask, case.depth, case.is_last),
case.expected,
"case '{}' failed",
case.name
);
}
}
#[test]
#[should_panic(expected = "exceeds u64 bit limit")]
fn guide_prefix_depth_overflow_debug() {
let _ = tree_guide_prefix(0, 64, false);
}
#[test]
fn mono_text_width_uses_06em_advance() {
assert!(close(mono_text_width(0, 14.0), 0.0));
assert!(close(mono_text_width(1, 14.0), 8.4));
assert!(close(mono_text_width(10, 14.0), 84.0));
assert!(close(mono_text_width(6, 10.0), 36.0));
}
#[test]
fn tree_row_natural_width_plain_file_row() {
let w =
tree_row_natural_width(2, TREE_FONT_SIZE, "src/main.rs", TREE_FONT_SIZE, None, None);
assert!(close(w, 127.2));
}
#[test]
fn tree_row_natural_width_dir_loading_suffix() {
let w = tree_row_natural_width(
0,
TREE_ICON_SIZE,
"src Loading…",
TREE_FONT_SIZE,
None,
None,
);
assert!(close(w, 128.2));
}
#[test]
fn tree_row_natural_width_error_file_suffix() {
let w = tree_row_natural_width(
0,
TREE_FONT_SIZE,
"broken.txt",
TREE_FONT_SIZE,
Some(("[⚠]", 11.0)),
None,
);
assert!(close(w, 125.8));
}
#[test]
fn tree_row_natural_width_diff_counts() {
let w = tree_row_natural_width(
0,
TREE_FONT_SIZE,
"lib.rs",
TREE_FONT_SIZE,
None,
Some(("+123", "-45")),
);
assert!(close(w, 122.4));
}
#[test]
fn tree_row_natural_width_diff_binary_count() {
let w = tree_row_natural_width(
0,
TREE_FONT_SIZE,
"data.bin",
TREE_FONT_SIZE,
None,
Some(("binary", "")),
);
assert!(close(w, 127.2));
}
fn close(a: f32, b: f32) -> bool {
(a - b).abs() < 0.001
}
fn tree_with_panel_viewport(scroll_y: f32, viewport_h: Option<f32>) -> FileTree {
let mut tree = FileTree::new(iced::widget::Id::new("width_test"));
tree.scroll_y = scroll_y;
tree.viewport_h = viewport_h;
tree
}
#[test]
fn tree_panel_width_clamps_to_minimum() {
let tree = tree_with_panel_viewport(0.0, Some(400.0));
let widths = vec![68.4, 100.0, 50.0];
assert!(close(tree_panel_width(&tree, &widths), TREE_MIN_WIDTH));
}
#[test]
fn tree_panel_width_clamps_to_maximum() {
let tree = tree_with_panel_viewport(0.0, Some(400.0));
let long_name = "x".repeat(50);
let widths = vec![tree_row_natural_width(
0,
TREE_FONT_SIZE,
&long_name,
TREE_FONT_SIZE,
None,
None,
)];
assert!(close(tree_panel_width(&tree, &widths), TREE_MAX_WIDTH));
}
#[test]
fn tree_panel_width_scales_with_widest_row() {
let tree = tree_with_panel_viewport(0.0, Some(400.0));
let wide = tree_row_natural_width(
2,
TREE_FONT_SIZE,
"some_really_long_file_name.rs",
TREE_FONT_SIZE,
None,
None,
);
let widths = vec![68.4, wide, 50.0];
assert!(close(
tree_panel_width(&tree, &widths),
wide + 2.0 * TREE_ROW_H_PADDING + TREE_SCROLLBAR_ALLOWANCE
));
}
#[test]
fn tree_panel_width_measures_all_rows_without_viewport() {
let tree = tree_with_panel_viewport(0.0, None);
let long_name = "y".repeat(40);
let wide =
tree_row_natural_width(0, TREE_FONT_SIZE, &long_name, TREE_FONT_SIZE, None, None);
let widths = vec![50.0, wide, 60.0];
assert!(close(
tree_panel_width(&tree, &widths),
(wide + 2.0 * TREE_ROW_H_PADDING + TREE_SCROLLBAR_ALLOWANCE)
.clamp(TREE_MIN_WIDTH, TREE_MAX_WIDTH)
));
}
#[test]
fn tree_panel_width_filters_out_of_viewport_rows() {
let tree = tree_with_panel_viewport(0.0, Some(200.0));
let mut widths = vec![68.4; 50];
widths[40] = 500.0; assert!(close(tree_panel_width(&tree, &widths), TREE_MIN_WIDTH));
widths[2] = 500.0; assert!(close(tree_panel_width(&tree, &widths), TREE_MAX_WIDTH));
}
#[test]
fn tree_panel_width_scrolled_viewport_measures_mid_rows() {
let tree = tree_with_panel_viewport(400.0, Some(200.0));
let mut widths = vec![68.4; 60];
widths[10] = 500.0; assert!(close(tree_panel_width(&tree, &widths), TREE_MIN_WIDTH));
widths[30] = 500.0; assert!(close(tree_panel_width(&tree, &widths), TREE_MAX_WIDTH));
}
#[test]
fn tree_panel_width_empty_tree_stays_at_minimum() {
let tree = tree_with_panel_viewport(0.0, None);
assert!(close(tree_panel_width(&tree, &[]), TREE_MIN_WIDTH));
}
#[test]
#[expect(clippy::cast_precision_loss)] fn collect_tree_row_widths_mirrors_render_order() {
let mut tree = FileTree::new(iced::widget::Id::new("w"));
tree.nodes = vec![
TreeNode {
name: "src".into(),
full_path: "src".into(),
is_dir: true,
children: vec![
TreeNode {
name: "main.rs".into(),
full_path: "src/main.rs".into(),
is_dir: false,
children: vec![],
error: None,
},
TreeNode {
name: "lib.rs".into(),
full_path: "src/lib.rs".into(),
is_dir: false,
children: vec![],
error: None,
},
],
error: None,
},
TreeNode {
name: "Cargo.toml".into(),
full_path: "Cargo.toml".into(),
is_dir: false,
children: vec![],
error: None,
},
];
tree.expanded_dirs.insert("src".to_string());
let widths = collect_tree_row_widths(&tree.nodes, &tree.expanded_dirs, |node, depth| {
depth as f32 * 10.0 + node.name.chars().count() as f32
});
assert_eq!(widths, vec![3.0, 17.0, 16.0, 10.0]);
tree.expanded_dirs.clear();
let widths = collect_tree_row_widths(&tree.nodes, &tree.expanded_dirs, |node, depth| {
depth as f32 * 10.0 + node.name.chars().count() as f32
});
assert_eq!(widths, vec![3.0, 10.0]);
}
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);
}
}