use super::theme;
use iced::advanced::layout::{self, Layout};
use iced::advanced::overlay;
use iced::advanced::renderer;
use iced::advanced::widget::Operation;
use iced::advanced::widget::tree::{self, Tree};
use iced::advanced::{Clipboard, Shell, Widget};
use iced::mouse;
use iced::widget::{
self, Column, Row, Space, button, column, container, mouse_area, row, scrollable, stack, text,
tooltip,
};
use iced::{
Alignment, Color, Element, Event, Length, Padding, Point, Rectangle, Size, Task, Vector,
};
use iced_fonts::lucide;
use iced_selection;
use std::borrow::Cow;
use std::collections::HashSet;
use std::path::Path;
use std::time::{Duration, Instant};
#[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(crate) fn editor_field_style(border_color: Color) -> impl Fn(&iced::Theme) -> container::Style {
move |_theme: &iced::Theme| theme::container_style(theme::BG_ELEVATED, 4.0, 1.0, border_color)
}
#[must_use]
pub fn single_line_editor<'a, M: 'a>(
buffer: &'a super::editor_widget::EditorBuffer,
placeholder: &'a str,
submit_on_enter: bool,
width: Length,
id: Option<iced::widget::Id>,
on_action: impl Fn(super::editor_widget::EditorAction) -> M + 'a,
) -> Element<'a, M> {
let mut editor = super::editor_widget::EditorWidget::new(buffer)
.single_line(true)
.show_gutter(false)
.code_mode(false)
.enter(if submit_on_enter {
super::editor_widget::EnterBehavior::Submit
} else {
super::editor_widget::EnterBehavior::Newline
})
.placeholder(placeholder)
.padding(theme::PAD_5)
.background(Some(theme::BG_ELEVATED));
if let Some(id) = id {
editor = editor.id(id);
}
let element = iced::Element::new(editor).map(on_action);
container(element)
.width(width)
.style(editor_field_style(theme::BORDER_STRONG))
.into()
}
#[must_use]
#[expect(clippy::too_many_arguments)]
pub fn password_field_editor<'a, M: Clone + 'a>(
buffer: &'a super::editor_widget::EditorBuffer,
placeholder: &'a str,
show: bool,
width: Length,
highlight: bool,
id: Option<iced::widget::Id>,
on_action: impl Fn(super::editor_widget::EditorAction) -> M + 'a,
on_toggle: M,
) -> Element<'a, M> {
let border_color = if highlight {
theme::ACCENT
} else {
theme::BORDER_STRONG
};
let mut editor = super::editor_widget::EditorWidget::new(buffer)
.single_line(true)
.masked(!show)
.show_gutter(false)
.code_mode(false)
.enter(super::editor_widget::EnterBehavior::Submit)
.placeholder(placeholder)
.padding(theme::PAD_5)
.background(Some(theme::BG_ELEVATED));
if let Some(id) = id {
editor = editor.id(id);
}
let element = iced::Element::new(editor).map(on_action);
let field = container(element)
.width(width)
.style(editor_field_style(border_color));
let eye_icon: Element<'_, M> = if show {
lucide::eye_off::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_14)
.color(theme::TEXT_MUTED)
.into()
} else {
lucide::eye::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_14)
.color(theme::TEXT_MUTED)
.into()
};
let toggle = button(eye_icon)
.padding(theme::PAD_2)
.style(theme::button_secondary)
.on_press(on_toggle);
row![field, Space::new().width(theme::SPACE_4), toggle]
.align_y(Alignment::Center)
.into()
}
#[must_use]
pub fn error_banner<'a, Message: 'a>(err: &'a str) -> Element<'a, Message> {
container(text(err).size(theme::TEXT_13).color(theme::STATUS_ERROR))
.padding(theme::PAD_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\u{2026}")
.size(theme::TEXT_14)
.color(theme::TEXT_MUTED)
.into()
}
#[must_use]
pub fn push_error_banner<'a, Message: 'a>(
col: Column<'a, Message>,
err: Option<&'a str>,
) -> Column<'a, Message> {
push_banner(col, err, false)
}
#[must_use]
pub fn push_error_banner_inset<'a, Message: 'a>(
col: Column<'a, Message>,
err: Option<&'a str>,
) -> Column<'a, Message> {
push_banner(col, err, true)
}
fn push_banner<'a, Message: 'a>(
mut col: Column<'a, Message>,
err: Option<&'a str>,
inset: bool,
) -> Column<'a, Message> {
if let Some(err) = err {
let banner = error_banner(err);
col = col.push(if inset {
scroll_h_inset(banner)
} else {
banner
});
col = col.push(Space::new().height(8));
}
col
}
#[must_use]
pub fn scroll_h_inset<'a, Message: 'a>(
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
container(content.into())
.padding([0.0, SCROLL_H_PAD])
.into()
}
#[must_use]
pub fn empty_state_placeholder<'a, Message: 'a>(
icon: iced::widget::Text<'a, iced::Theme, iced::Renderer>,
label: &'a str,
accent: iced::Color,
) -> Element<'a, Message> {
container(
column![
icon.size(48).color(accent),
text(label).size(theme::TEXT_14).color(accent),
]
.spacing(theme::SPACE_12)
.align_x(Alignment::Center),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
#[derive(Debug, Clone, Copy)]
pub struct PillMetrics {
pub text_size: f32,
pub padding: [f32; 2], }
pub const PILL_COMPACT: PillMetrics = PillMetrics {
text_size: theme::TEXT_10,
padding: [theme::PAD_1, theme::PAD_6],
};
pub const PILL_MODAL: PillMetrics = PillMetrics {
text_size: theme::TEXT_12,
padding: [theme::PAD_2, theme::PAD_8],
};
#[must_use]
pub fn badge_pill<'a, Message: 'a>(
label: String,
colors: (Color, Color),
metrics: PillMetrics,
) -> Element<'a, Message> {
container(text(label).size(metrics.text_size).color(colors.0))
.padding(metrics.padding)
.style(theme::pill_style(colors.1))
.into()
}
#[must_use]
pub fn maint_badge<'a, Message: 'a>(enabled: bool) -> Column<'a, Message> {
column![
theme::role_icon(&crate::Role::Maintainer)
.size(theme::TEXT_14)
.color(if enabled {
theme::ACCENT
} else {
theme::TEXT_MUTED
}),
text(if enabled { "ON" } else { "OFF" })
.size(theme::TEXT_10)
.color(if enabled {
theme::ACCENT
} else {
theme::TEXT_MUTED
}),
]
.spacing(0)
.align_x(Alignment::Center)
}
#[must_use]
pub fn section_heading<'a>(
title: impl Into<String>,
) -> iced::widget::Text<'a, iced::Theme, iced::Renderer> {
text(title.into())
.font(theme::JETBRAINS_MONO)
.size(theme::TEXT_16)
.color(theme::ACCENT)
}
#[must_use]
pub fn section_subheading<'a>(
title: impl Into<String>,
) -> iced::widget::Text<'a, iced::Theme, iced::Renderer> {
text(title.into())
.font(theme::JETBRAINS_MONO)
.size(theme::TEXT_13)
.color(theme::ACCENT)
}
pub const ACTION_ICON_SIZE: f32 = theme::TEXT_16;
#[must_use]
pub fn icon_tooltip_button<'a, Message>(
icon: impl Into<Element<'a, Message>>,
tooltip_text: impl Into<Cow<'a, str>>,
on_press: Option<Message>,
padding: impl Into<Padding>,
style: impl Fn(&iced::Theme, iced::widget::button::Status) -> iced::widget::button::Style + 'a,
position: tooltip::Position,
) -> Element<'a, Message>
where
Message: Clone + 'a,
{
tooltip(
button(icon)
.style(style)
.padding(padding)
.on_press_maybe(on_press),
text(tooltip_text.into()).size(theme::TEXT_11),
position,
)
.style(theme::tooltip_style)
.into()
}
#[must_use]
pub fn compact_x_button<'a, Message: Clone + 'a>(
padding: impl Into<Padding>,
on_press: Option<Message>,
tooltip_text: Option<&'a str>,
) -> Element<'a, Message> {
let glyph = lucide::x::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_12)
.color(if on_press.is_some() {
theme::TEXT_SECONDARY
} else {
theme::TEXT_MUTED
});
match tooltip_text {
Some(tooltip_text) => icon_tooltip_button(
glyph,
tooltip_text,
on_press,
padding,
theme::button_text,
tooltip::Position::Top,
),
None => button(glyph)
.padding(padding)
.style(theme::button_text)
.on_press_maybe(on_press)
.into(),
}
}
#[must_use]
pub fn ghost_icon_button<Message: 'static + Clone>() -> Element<'static, Message> {
container(
button(lucide::circle_check::<iced::Theme, iced::Renderer>().size(ACTION_ICON_SIZE))
.padding(button::DEFAULT_PADDING),
)
.width(0)
.clip(true)
.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),
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(theme::TEXT_12)
.color(if is_active {
theme::TEXT_SECONDARY
} else {
theme::TEXT_FAINT
}),
)
.on_press(on_press)
.style(theme::button_transparent)
.padding(0)
}
pub const SCROLL_H_PAD: f32 = 8.0;
pub const SCROLL_BOTTOM_PAD: f32 = 6.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> {
container(hscroll(
row(tab_buttons).spacing(0).width(Length::Fill),
scroll_id,
on_scroll,
))
.style(theme::surface_container_style)
.width(Length::Fill)
.into()
}
#[must_use]
pub fn vscroll<'a, Message: 'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
vscroll_sized(content, Length::Fill, Length::Fill)
}
#[must_use]
pub fn vscroll_sized<'a, Message: 'a>(
content: impl Into<Element<'a, Message>>,
width: Length,
height: Length,
) -> Element<'a, Message> {
vscroll_base(content, width, height).into()
}
#[must_use]
pub fn vscroll_tracked<'a, Message: 'a>(
content: impl Into<Element<'a, Message>>,
width: Length,
height: Length,
id: widget::Id,
on_scroll: impl Fn(scrollable::Viewport) -> Message + 'a,
) -> Element<'a, Message> {
vscroll_base(content, width, height)
.id(id)
.on_scroll(on_scroll)
.into()
}
fn vscroll_base<'a, Message: 'a>(
content: impl Into<Element<'a, Message>>,
width: Length,
height: Length,
) -> widget::Scrollable<'a, Message, iced::Theme, iced::Renderer> {
scrollable(
container(content.into())
.width(Length::Fill)
.padding([0.0, SCROLL_H_PAD]),
)
.width(width)
.height(height)
.direction(theme::vertical_scrollbar())
.style(theme::scrollbar_style)
}
fn hscroll<'a, Message: 'a>(
content: impl Into<Element<'a, Message>>,
id: Option<widget::Id>,
on_scroll: Option<impl Fn(scrollable::Viewport) -> Message + 'a>,
) -> Element<'a, Message> {
let mut sc = scrollable(
container(content.into())
.width(Length::Fill)
.padding(Padding {
bottom: SCROLL_BOTTOM_PAD,
..Padding::ZERO
}),
)
.width(Length::Fill)
.height(Length::Shrink)
.direction(theme::horizontal_scrollbar())
.style(theme::scrollbar_style);
if let Some(id) = id {
sc = sc.id(id);
}
if let Some(on_scroll) = on_scroll {
sc = sc.on_scroll(on_scroll);
}
sc.into()
}
pub const CHAT_VERTICAL_RHYTHM: f32 = 8.0;
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,
pub id: Option<iced::widget::Id>,
}
#[must_use]
pub fn chat_composer<'a, M: Clone + 'a>(
content: &'a super::editor_widget::EditorBuffer,
on_action: impl Fn(super::editor_widget::EditorAction) -> M + 'a,
send_msg: M,
placeholder: &'a str,
options: ChatComposerOptions<'a, M>,
) -> Element<'a, M> {
let send_msg_btn = send_msg.clone();
let mut editor = super::editor_widget::EditorWidget::new(content)
.show_gutter(false)
.code_mode(false)
.enter(super::editor_widget::EnterBehavior::Submit)
.placeholder(placeholder)
.min_height(options.min_height)
.max_height(options.max_height)
.padding(theme::PAD_5)
.background(Some(theme::BG_ELEVATED));
if let Some(id) = options.id {
editor = editor.id(id);
}
let input_editor: Element<'a, M> =
container(iced::Element::new(editor).map(move |action| match action {
super::editor_widget::EditorAction::Submit => send_msg.clone(),
other => on_action(other),
}))
.width(Length::Fill)
.height(Length::Shrink)
.into();
let send_disabled =
options.sending || (options.grey_on_empty && content.text().trim().is_empty());
let send_btn = icon_tooltip_button(
lucide::send::<iced::Theme, iced::Renderer>()
.size(theme::TEXT_14)
.color(if send_disabled {
theme::TEXT_MUTED
} else {
theme::ACCENT
}),
options.send_tooltip,
if send_disabled {
None
} else {
Some(send_msg_btn)
},
4,
theme::icon_button_style(send_disabled),
tooltip::Position::Top,
);
let mut toolbar = Row::new()
.spacing(theme::SPACE_6)
.align_y(Alignment::Center);
toolbar = toolbar.push(Space::new().width(Length::Fill));
for c in options.controls {
toolbar = toolbar.push(c);
}
toolbar = toolbar.push(send_btn);
container(column![input_editor, toolbar].spacing(theme::SPACE_6))
.padding(theme::PAD_10)
.style(theme::bubble_style(
theme::BG_ELEVATED,
Some(theme::TEXT_PRIMARY),
))
.width(Length::Fill)
.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)
}
#[must_use]
pub fn git_footer_stats<'a, Message: 'a>(
added: i64,
removed: i64,
huge_binary_file_count: usize,
size: f32,
) -> Option<Row<'a, Message>> {
if added == 0 && removed == 0 && huge_binary_file_count == 0 {
return None;
}
let mut parts: Vec<Element<'a, Message>> = Vec::new();
if added != 0 || removed != 0 {
parts.push(diff_stats_row::<Message>(added, removed, size).into());
}
if huge_binary_file_count > 0 {
parts.push(
lucide::files::<iced::Theme, iced::Renderer>()
.size(size)
.color(theme::TEXT_MUTED)
.into(),
);
parts.push(
text(format!("{huge_binary_file_count} files"))
.size(size)
.color(theme::TEXT_SECONDARY)
.into(),
);
}
Some(
Row::with_children(parts)
.spacing(theme::SPACE_3)
.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>,
pub ignored: bool,
}
#[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 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 * SCROLL_H_PAD).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_ICON_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 = vscroll_tracked(
column(tree_rows).spacing(0),
Length::Fill,
Length::Fill,
file_tree.tree_scroll_id.clone(),
on_scroll,
);
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()
}
pub fn tree_row<'a, Message: 'a>(
guide: String,
icon: Element<'a, Message>,
name: Element<'a, Message>,
counts: Option<Element<'a, Message>>,
) -> Row<'a, Message> {
let mut row = Row::new()
.push(text(guide).size(TREE_FONT_SIZE).color(theme::TEXT_MUTED))
.push(icon)
.push(Space::new().width(theme::SPACE_4))
.push(name)
.push(Space::new().width(Length::Fill));
if let Some(counts) = counts {
row = row.push(counts).push(Space::new().width(theme::SPACE_6));
}
row.align_y(Alignment::Center)
}
pub(super) fn modal_backdrop_layer<'a, Message: 'a + Clone>(
on_backdrop: Message,
opacity: f32,
) -> Element<'a, Message> {
mouse_area(
container(text(""))
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme: &iced::Theme| container::Style {
background: Some(iced::Background::Color(Color::from_rgba(
0.0, 0.0, 0.0, opacity,
))),
..container::Style::default()
}),
)
.on_press(on_backdrop)
.into()
}
pub(super) fn dialog_click_guard<'a, Message: 'a + Clone>(
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
mouse_area(content)
.interaction(iced::mouse::Interaction::Idle)
.into()
}
pub fn modal_backdrop<'a, Message: 'a + Clone>(
content: impl Into<Element<'a, Message>>,
on_backdrop: Message,
opacity: f32,
) -> Element<'a, Message> {
let backdrop = modal_backdrop_layer(on_backdrop, opacity);
let centered = container(dialog_click_guard(content))
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill);
stack([backdrop, centered.into()]).into()
}
pub fn empty_stack_placeholder<'a, Message: 'a>() -> Element<'a, Message> {
container(text(""))
.width(Length::Shrink)
.height(Length::Shrink)
.into()
}
#[must_use]
pub fn click_to_toggle<'a, Message: Clone + 'a>(
content: impl Into<Element<'a, Message>>,
on_click: Message,
on_cancel: Message,
) -> Element<'a, Message> {
ClickToggle {
content: content.into(),
on_click,
on_cancel,
}
.into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClickKind {
Click,
Drag,
MultiClick,
}
const CLICK_DRAG_MAX_DISTANCE: f32 = 6.0;
const DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(300);
fn classify_click(distance: f32, since_last_click: Option<Duration>) -> ClickKind {
if distance > CLICK_DRAG_MAX_DISTANCE {
return ClickKind::Drag;
}
if since_last_click.is_some_and(|elapsed| elapsed <= DOUBLE_CLICK_WINDOW) {
return ClickKind::MultiClick;
}
ClickKind::Click
}
#[derive(Default, Clone)]
struct ClickToggleState {
press: Option<Point>,
last_click: Option<Instant>,
click_count: u32,
drag_canceled: bool,
}
struct ClickToggle<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> {
content: Element<'a, Message, Theme, Renderer>,
on_click: Message,
on_cancel: Message,
}
impl<'a, Message, Theme, Renderer> From<ClickToggle<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a + Clone,
Theme: 'a,
Renderer: 'a + renderer::Renderer,
{
fn from(widget: ClickToggle<'a, Message, Theme, Renderer>) -> Self {
Self::new(widget)
}
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for ClickToggle<'_, Message, Theme, Renderer>
where
Renderer: renderer::Renderer,
Message: Clone,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<ClickToggleState>()
}
fn state(&self) -> tree::State {
tree::State::new(ClickToggleState::default())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.content)]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let mut child_messages = Vec::new();
let mut child_shell = Shell::new(&mut child_messages);
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
&mut child_shell,
viewport,
);
let child_published = !child_shell.is_empty();
shell.merge(child_shell, std::convert::identity);
self.handle_mouse(tree, event, layout, cursor, shell, child_published);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
let content_interaction = self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
);
match (content_interaction, cursor.is_over(layout.bounds())) {
(mouse::Interaction::None, true) => mouse::Interaction::Pointer,
_ => content_interaction,
}
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
renderer_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
renderer_style,
layout,
cursor,
viewport,
);
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<Message: Clone, Theme, Renderer> ClickToggle<'_, Message, Theme, Renderer> {
fn handle_mouse(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
shell: &mut Shell<'_, Message>,
child_published: bool,
) {
let state: &mut ClickToggleState = tree.state.downcast_mut();
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if cursor.is_over(layout.bounds())
&& let Some(position) = cursor.position()
{
state.press = Some(position);
state.drag_canceled = false;
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
if let (Some(press_position), Some(position)) = (state.press, cursor.position())
&& position.distance(press_position) > CLICK_DRAG_MAX_DISTANCE
{
self.publish_cancel_once(state, shell);
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let Some(press_position) = state.press.take() else {
return;
};
let Some(release_position) = cursor.position() else {
return;
};
let kind = classify_click(
release_position.distance(press_position),
state.last_click.map(|t| t.elapsed()),
);
if kind == ClickKind::Drag {
state.last_click = None;
state.click_count = 0;
self.publish_cancel_once(state, shell);
return;
}
if child_published {
if state.click_count > 0 {
shell.publish(self.on_cancel.clone());
}
state.last_click = None;
state.click_count = 0;
return;
}
state.click_count = if kind == ClickKind::Click {
1
} else {
state.click_count + 1
};
state.last_click = Some(Instant::now());
match state.click_count {
1 => shell.publish(self.on_click.clone()),
2 => shell.publish(self.on_cancel.clone()),
_ => {}
}
}
_ => {}
}
}
fn publish_cancel_once(&self, state: &mut ClickToggleState, shell: &mut Shell<'_, Message>) {
if !state.drag_canceled {
state.drag_canceled = true;
shell.publish(self.on_cancel.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_footer_stats_gating() {
assert!(git_footer_stats::<()>(0, 0, 0, 15.0).is_none());
assert!(git_footer_stats::<()>(0, 0, 3, 15.0).is_some());
assert!(git_footer_stats::<()>(1, 0, 0, 15.0).is_some());
assert!(git_footer_stats::<()>(0, 1, 2, 15.0).is_some());
}
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,
ignored: false,
},
TreeNode {
name: "b".into(),
full_path: "b".into(),
is_dir: false,
children: vec![],
error: None,
ignored: false,
},
TreeNode {
name: "c".into(),
full_path: "c".into(),
is_dir: false,
children: vec![],
error: None,
ignored: false,
},
];
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_cases() {
struct Case {
label: &'static str,
guide_chars: usize,
icon_size: f32,
name: &'static str,
name_suffix: Option<(&'static str, f32)>,
counts: Option<(&'static str, &'static str)>,
expected: f32,
}
#[rustfmt::skip] let cases = [
Case { label: "plain_file_row", guide_chars: 2, icon_size: TREE_ICON_SIZE, name: "src/main.rs", name_suffix: None, counts: None, expected: 128.2 },
Case { label: "dir_loading_suffix", guide_chars: 0, icon_size: TREE_ICON_SIZE, name: "src Loading…", name_suffix: None, counts: None, expected: 128.2 },
Case { label: "error_file_suffix", guide_chars: 0, icon_size: TREE_ICON_SIZE, name: "broken.txt", name_suffix: Some(("[⚠]", 11.0)), counts: None, expected: 126.8 },
Case { label: "diff_counts", guide_chars: 0, icon_size: TREE_ICON_SIZE, name: "lib.rs", name_suffix: None, counts: Some(("+123", "\u{2212}45")), expected: 123.4 },
Case { label: "diff_binary_count", guide_chars: 0, icon_size: TREE_ICON_SIZE, name: "data.bin", name_suffix: None, counts: Some(("binary", "")), expected: 128.2 },
];
for case in cases {
let w = tree_row_natural_width(
case.guide_chars,
case.icon_size,
case.name,
TREE_FONT_SIZE,
case.name_suffix,
case.counts,
);
assert!(
close(w, case.expected),
"case {}: expected {}, got {}",
case.label,
case.expected,
w
);
}
}
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 * SCROLL_H_PAD
));
}
#[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 * SCROLL_H_PAD).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,
ignored: false,
},
TreeNode {
name: "lib.rs".into(),
full_path: "src/lib.rs".into(),
is_dir: false,
children: vec![],
error: None,
ignored: false,
},
],
error: None,
ignored: false,
},
TreeNode {
name: "Cargo.toml".into(),
full_path: "Cargo.toml".into(),
is_dir: false,
children: vec![],
error: None,
ignored: false,
},
];
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,
ignored: false,
},
TreeNode {
name: "main.rs".into(),
full_path: "src/main.rs".into(),
is_dir: false,
children: vec![],
error: None,
ignored: false,
},
],
error: None,
ignored: false,
}];
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: Option<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 = viewport_h;
tree
}
#[test]
fn scroll_to_tree_focus_positions_viewport() {
struct Case {
label: &'static str,
n: usize,
scroll_y: f32,
viewport_h: Option<f32>,
focus: usize,
mode: ScrollMode,
expected: f32,
}
#[rustfmt::skip] let cases = [
Case { label: "fully_visible", n: 30, scroll_y: 40.0, viewport_h: Some(400.0), focus: 3, mode: ScrollMode::ScrollIntoView, expected: 40.0 },
Case { label: "below_viewport", n: 30, scroll_y: 0.0, viewport_h: Some(200.0), focus: 15, mode: ScrollMode::ScrollIntoView, expected: 19.5 },
Case { label: "above_viewport", n: 30, scroll_y: 100.0, viewport_h: Some(400.0), focus: 3, mode: ScrollMode::ScrollIntoView, expected: 58.5 },
Case { label: "top_edge_partial", n: 30, scroll_y: 50.0, viewport_h: Some(400.0), focus: 2, mode: ScrollMode::ScrollIntoView, expected: 50.0 },
Case { label: "no_viewport_snap", n: 30, scroll_y: 10.0, viewport_h: None, focus: 10, mode: ScrollMode::ScrollIntoView, expected: 195.0 },
Case { label: "snap_to_top", n: 30, scroll_y: 0.0, viewport_h: Some(400.0), focus: 8, mode: ScrollMode::SnapToTop, expected: 156.0 },
Case { label: "empty_tree", n: 0, scroll_y: 0.0, viewport_h: Some(400.0), focus: 0, mode: ScrollMode::ScrollIntoView, expected: 0.0 },
];
for case in cases {
let mut tree = tree_with_viewport(case.n, case.scroll_y, case.viewport_h);
tree.tree_focus_index = case.focus;
let _task = scroll_to_tree_focus::<()>(&mut tree, case.mode);
assert!(
(tree.scroll_y - case.expected).abs() < 0.01,
"case {}: scroll_y expected {}, got {}",
case.label,
case.expected,
tree.scroll_y
);
}
}
#[test]
fn classify_click_kinds() {
assert_eq!(classify_click(0.0, None), ClickKind::Click);
assert_eq!(classify_click(6.0, None), ClickKind::Click);
assert_eq!(classify_click(6.1, None), ClickKind::Drag);
assert_eq!(classify_click(100.0, Some(Duration::ZERO)), ClickKind::Drag);
assert_eq!(
classify_click(1.0, Some(Duration::from_millis(299))),
ClickKind::MultiClick
);
assert_eq!(
classify_click(1.0, Some(Duration::from_millis(301))),
ClickKind::Click
);
assert_eq!(
classify_click(1.0, Some(DOUBLE_CLICK_WINDOW)),
ClickKind::MultiClick
);
}
}