use std::collections::HashSet;
use std::time::Duration;
use iced::widget::{self, button, column, container, pick_list, 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 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,
}
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,
}
}
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();
}
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 const TREE_FONT_SIZE: f32 = 14.0;
pub const TREE_ICON_SIZE: f32 = 15.0;
pub const ESTIMATED_TREE_ROW_HEIGHT: f32 = 20.0;
#[allow(clippy::cast_precision_loss)]
pub fn scroll_to_tree_focus<Message: 'static>(file_tree: &FileTree) -> Task<Message> {
if file_tree.visible_tree_nodes.is_empty() {
return Task::none();
}
let offset_y = file_tree.tree_focus_index as f32 * ESTIMATED_TREE_ROW_HEIGHT;
iced::widget::operation::scroll_to(
file_tree.tree_scroll_id.clone(),
iced::widget::operation::AbsoluteOffset {
x: 0.0,
y: offset_y,
},
)
}
pub fn build_tree_panel<'a, Message: 'a>(
file_tree: &'a FileTree,
tree_rows: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
let tree_body = widget::scrollable(column(tree_rows).spacing(0))
.id(file_tree.tree_scroll_id.clone())
.width(Length::Fill)
.height(Length::Fill)
.direction(widget::scrollable::Direction::Vertical(
theme::thin_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);
}
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);
}
}