feat: Add directory tree navigation with expand/collapse support and theme toggle functionality

This commit is contained in:
Your Name
2026-07-23 04:07:35 +03:00
parent 21175b98c1
commit 593522e83f
3 changed files with 392 additions and 49 deletions
+27 -4
View File
@@ -1080,6 +1080,10 @@ pub enum Message {
ViewerPrev, ViewerPrev,
ViewerNext, ViewerNext,
ViewerEnter, ViewerEnter,
/// Toggle the expanded/collapsed state of a directory tree node.
ViewerToggleNode(std::path::PathBuf),
/// Toggle between light and dark theme presets within the viewer.
ViewerThemeToggle,
// ── SVG Editor ────────────────────────────────────── // ── SVG Editor ──────────────────────────────────────
/// Open the SVG node editor for a vector SvgShape. /// Open the SVG node editor for a vector SvgShape.
@@ -8918,15 +8922,14 @@ impl HcieIcedApp {
Message::ViewerToggle => { Message::ViewerToggle => {
self.viewer_active = !self.viewer_active; self.viewer_active = !self.viewer_active;
if self.viewer_active && self.viewer_state.current_dir.as_os_str().is_empty() { if self.viewer_active && self.viewer_state.current_dir.as_os_str().is_empty() {
// Initialize to last-used directory or home on first open // Initialize to last-used directory, system Pictures, or home on first open
let start_dir = self let start_dir = self
.settings .settings
.viewer_last_dir .viewer_last_dir
.as_ref() .as_ref()
.map(std::path::PathBuf::from) .map(std::path::PathBuf::from)
.filter(|p| p.exists()) .filter(|p| p.exists())
.or_else(|| dirs::home_dir()) .unwrap_or_else(crate::viewer::default_start_dir);
.unwrap_or_else(|| std::path::PathBuf::from("."));
self.viewer_state = crate::viewer::ViewerState::new(start_dir); self.viewer_state = crate::viewer::ViewerState::new(start_dir);
} }
if self.viewer_active { if self.viewer_active {
@@ -9052,6 +9055,25 @@ impl HcieIcedApp {
}); });
} }
} }
Message::ViewerToggleNode(path) => {
self.viewer_state.toggle_node(&path);
}
Message::ViewerThemeToggle => {
let current = self.theme_state.preset();
let new_preset = match current {
crate::theme::ThemePreset::Photopea
| crate::theme::ThemePreset::Photoshop
| crate::theme::ThemePreset::ProDark
| crate::theme::ThemePreset::Amoled => crate::theme::ThemePreset::ProLight,
crate::theme::ThemePreset::PhotoshopLight
| crate::theme::ThemePreset::ProLight => {
crate::theme::ThemePreset::Photopea
}
};
self.theme_state.set_preset(new_preset);
self.settings.theme_preset = new_preset;
let _ = self.settings.save();
}
// ── SVG Editor ────────────────────────────────── // ── SVG Editor ──────────────────────────────────
Message::SvgEditorOpen { Message::SvgEditorOpen {
@@ -9404,7 +9426,8 @@ impl HcieIcedApp {
// When the viewer is active, show the full-screen image viewer // When the viewer is active, show the full-screen image viewer
if self.viewer_active { if self.viewer_active {
let viewer_panel = crate::viewer::view(&self.viewer_state, colors); let is_light = colors.is_light;
let viewer_panel = crate::viewer::view(&self.viewer_state, colors, is_light);
return container(viewer_panel) return container(viewer_panel)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
@@ -1,13 +1,15 @@
//! Directory tree navigation panel. //! Directory tree navigation panel with collapse/expand support.
//! //!
//! Displays a collapsible tree of filesystem directories with standard //! Displays a collapsible tree of filesystem directories with standard
//! locations (Home, Desktop, Documents, Downloads, Pictures, Videos, Root) //! locations (Home, Desktop, Documents, Downloads, Pictures, Videos, Root)
//! as top-level roots. Each node shows its subdirectories when expanded. //! as top-level roots. Each node shows its subdirectories when expanded,
//! controlled by the `expanded_nodes` set in `ViewerState`.
//! Includes a functional scrollbar wrapping the entire tree.
use crate::app::Message; use crate::app::Message;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use crate::viewer::ViewerState; use crate::viewer::ViewerState;
use iced::widget::{button, column, container, scrollable, text}; use iced::widget::{button, column, container, scrollable, text, Space};
use iced::{Element, Length}; use iced::{Element, Length};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -82,7 +84,7 @@ fn color_alpha(color: iced::Color, alpha: f32) -> iced::Color {
} }
} }
/// Build the directory tree view. /// Build the directory tree view with a functional scrollbar.
pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
let roots = get_roots(); let roots = get_roots();
let mut items = column![].spacing(2); let mut items = column![].spacing(2);
@@ -97,7 +99,11 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag
.into() .into()
} }
/// Recursively draw a tree node with its children. /// Recursively draw a tree node with expand/collapse support.
///
/// Nodes with children show a toggle arrow. Clicking the arrow toggles
/// expansion state. Clicking the folder name navigates to that directory.
/// Children are only rendered when the node is in the `expanded_nodes` set.
fn draw_tree_node( fn draw_tree_node(
path: &Path, path: &Path,
name: &str, name: &str,
@@ -107,32 +113,99 @@ fn draw_tree_node(
depth: usize, depth: usize,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let is_selected = state.current_dir == path; let is_selected = state.current_dir == path;
let is_expanded = state.expanded_nodes.contains(path);
let indent = depth as f32 * 16.0; let indent = depth as f32 * 16.0;
let subdirs = get_subdirs_for_path(path); let subdirs = get_subdirs_for_path(path);
let has_children = !subdirs.is_empty(); let has_children = !subdirs.is_empty();
let label = format!("{} {}", icon, name);
let label_color = if is_selected { let label_color = if is_selected {
colors.accent colors.accent
} else { } else {
colors.text_primary colors.text_primary
}; };
let label_text = text(label.clone()) if has_children {
let arrow = if is_expanded { "\u{25BC}" } else { "\u{25B6}" };
let arrow_text = text(format!("{} ", arrow))
.size(10)
.style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
});
let arrow_btn = button(arrow_text)
.on_press(Message::ViewerToggleNode(path.to_path_buf()))
.width(iced::Length::Fixed(20.0))
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::TRANSPARENT)),
text_color: colors.text_secondary,
border: iced::Border::default(),
..Default::default()
}
},
);
let label = format!("{} {}", icon, name);
let label_text = text(label)
.size(12) .size(12)
.style(move |_theme| iced::widget::text::Style { .style(move |_theme| iced::widget::text::Style {
color: Some(label_color), color: Some(label_color),
}); });
let row_content: Element<'static, Message> = if has_children { let nav_btn = button(label_text)
.on_press(Message::ViewerNavigate(path.to_path_buf()))
.width(Length::Fill)
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_selected {
color_alpha(colors.accent, 0.15)
} else {
iced::Color::TRANSPARENT
})),
text_color: label_color,
border: iced::Border::default(),
..Default::default()
}
},
);
let header_row = iced::widget::row![arrow_btn, nav_btn]
.width(Length::Fill)
.align_y(iced::Alignment::Center);
let mut content = column![header_row].spacing(1).width(Length::Fill);
if is_expanded {
let mut children_col = column![].spacing(1); let mut children_col = column![].spacing(1);
for child in &subdirs { for child in &subdirs {
if let Some(child_name) = child.file_name().and_then(|n| n.to_str()) { if let Some(child_name) = child.file_name().and_then(|n| n.to_str()) {
let child_node = draw_tree_node(child, child_name, ">", state, colors, depth + 1); let child_node =
draw_tree_node(child, child_name, ">", state, colors, depth + 1);
children_col = children_col.push(child_node); children_col = children_col.push(child_node);
} }
} }
content = content.push(children_col);
}
container(content)
.width(Length::Fill)
.padding(iced::Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: indent,
})
.into()
} else {
let label = format!("{} {}", icon, name);
let label_text = text(label)
.size(12)
.style(move |_theme| iced::widget::text::Style {
color: Some(label_color),
});
let btn = button(label_text) let btn = button(label_text)
.on_press(Message::ViewerNavigate(path.to_path_buf())) .on_press(Message::ViewerNavigate(path.to_path_buf()))
@@ -152,29 +225,11 @@ fn draw_tree_node(
}, },
); );
column![btn, children_col].spacing(1).into() container(
} else { iced::widget::row![Space::with_width(iced::Length::Fixed(20.0)), btn]
button(label_text)
.on_press(Message::ViewerNavigate(path.to_path_buf()))
.width(Length::Fill) .width(Length::Fill)
.style( .align_y(iced::Alignment::Center),
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_selected {
color_alpha(colors.accent, 0.15)
} else {
iced::Color::TRANSPARENT
})),
text_color: label_color,
border: iced::Border::default(),
..Default::default()
}
},
) )
.into()
};
container(row_content)
.width(Length::Fill) .width(Length::Fill)
.padding(iced::Padding { .padding(iced::Padding {
top: 0.0, top: 0.0,
@@ -184,6 +239,7 @@ fn draw_tree_node(
}) })
.into() .into()
} }
}
/// Get subdirectories of a path (non-cached version for the view). /// Get subdirectories of a path (non-cached version for the view).
fn get_subdirs_for_path(path: &Path) -> Vec<PathBuf> { fn get_subdirs_for_path(path: &Path) -> Vec<PathBuf> {
@@ -25,13 +25,47 @@ const SUPPORTED_EXTENSIONS: &[&str] = &[
"hdr", "dds", "tga", "exr", "hdr", "dds", "tga", "exr",
]; ];
/// Return the standard root directories that should be pre-expanded in the tree.
fn standard_root_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(home) = dirs::home_dir() {
dirs.push(home);
}
if let Some(desktop) = dirs::desktop_dir() {
dirs.push(desktop);
}
if let Some(docs) = dirs::document_dir() {
dirs.push(docs);
}
if let Some(downloads) = dirs::download_dir() {
dirs.push(downloads);
}
if let Some(pics) = dirs::picture_dir() {
dirs.push(pics);
}
if let Some(vids) = dirs::video_dir() {
dirs.push(vids);
}
dirs
}
/// Return the default directory for first launch — the system Pictures folder,
/// falling back to the home directory, then current directory.
pub fn default_start_dir() -> PathBuf {
dirs::picture_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
}
/// Viewer display mode — Browser shows the dual-pane directory view, /// Viewer display mode — Browser shows the dual-pane directory view,
/// Viewer shows a single image with navigation arrows. /// Viewer shows a single image with navigation arrows,
/// Navigation shows a directory tree with a large preview pane.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ViewerMode { pub enum ViewerMode {
#[default] #[default]
Browser, Browser,
Viewer, Viewer,
Navigation,
} }
/// Persistent viewer state — directory listing, thumbnail cache, zoom/pan. /// Persistent viewer state — directory listing, thumbnail cache, zoom/pan.
@@ -46,6 +80,8 @@ pub struct ViewerState {
pub view_mode: ViewerMode, pub view_mode: ViewerMode,
/// Whether the viewer is in fullscreen mode. /// Whether the viewer is in fullscreen mode.
pub fullscreen: bool, pub fullscreen: bool,
/// Paths that are expanded in the directory tree (collapse/expand state).
pub expanded_nodes: HashSet<PathBuf>,
/// Cached subdirectory lists to avoid repeated disk reads. /// Cached subdirectory lists to avoid repeated disk reads.
pub subdirs_cache: HashMap<PathBuf, Vec<PathBuf>>, pub subdirs_cache: HashMap<PathBuf, Vec<PathBuf>>,
/// Cached thumbnail RGBA pixel data (path → pixels, width, height). /// Cached thumbnail RGBA pixel data (path → pixels, width, height).
@@ -66,12 +102,17 @@ pub struct ViewerState {
impl Default for ViewerState { impl Default for ViewerState {
fn default() -> Self { fn default() -> Self {
let mut expanded_nodes = HashSet::new();
for root in standard_root_dirs() {
expanded_nodes.insert(root);
}
Self { Self {
current_dir: PathBuf::new(), current_dir: PathBuf::new(),
images: Vec::new(), images: Vec::new(),
active_image_idx: 0, active_image_idx: 0,
view_mode: ViewerMode::Browser, view_mode: ViewerMode::Browser,
fullscreen: false, fullscreen: false,
expanded_nodes,
subdirs_cache: HashMap::new(), subdirs_cache: HashMap::new(),
thumbnail_cache: HashMap::new(), thumbnail_cache: HashMap::new(),
failed_thumbnails: HashSet::new(), failed_thumbnails: HashSet::new(),
@@ -99,6 +140,15 @@ impl ViewerState {
state state
} }
/// Toggle a directory tree node's expanded/collapsed state.
pub fn toggle_node(&mut self, path: &Path) {
if self.expanded_nodes.contains(path) {
self.expanded_nodes.remove(path);
} else {
self.expanded_nodes.insert(path.to_path_buf());
}
}
/// Refresh the images list in the current directory. /// Refresh the images list in the current directory.
pub fn refresh_images(&mut self) { pub fn refresh_images(&mut self) {
log::debug!("Refreshing image list for: {}", self.current_dir.display()); log::debug!("Refreshing image list for: {}", self.current_dir.display());
@@ -267,11 +317,12 @@ fn _fit_size(texture_w: f32, texture_h: f32, avail_w: f32, avail_h: f32) -> (f32
} }
/// Build the viewer panel UI element. /// Build the viewer panel UI element.
pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { pub fn view(state: &ViewerState, colors: ThemeColors, is_light: bool) -> Element<'static, Message> {
let header = view_header(state, colors); let header = view_header(state, colors, is_light);
let body = match state.view_mode { let body = match state.view_mode {
ViewerMode::Browser => view_browser(state, colors), ViewerMode::Browser => view_browser(state, colors),
ViewerMode::Viewer => view_viewer(state, colors), ViewerMode::Viewer => view_viewer(state, colors),
ViewerMode::Navigation => view_navigation(state, colors),
}; };
column![header, horizontal_rule(1), body] column![header, horizontal_rule(1), body]
.width(Length::Fill) .width(Length::Fill)
@@ -279,8 +330,8 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag
.into() .into()
} }
/// Header toolbar with mode toggle, fullscreen toggle, and exit button. /// Header toolbar with mode toggle, fullscreen toggle, theme toggle, and exit button.
fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { fn view_header(state: &ViewerState, colors: ThemeColors, is_light: bool) -> Element<'static, Message> {
let exit_btn = button(text(" Exit Viewer ")) let exit_btn = button(text(" Exit Viewer "))
.on_press(Message::ViewerToggle) .on_press(Message::ViewerToggle)
.style( .style(
@@ -356,6 +407,28 @@ fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
}, },
); );
let is_nav = state.view_mode == ViewerMode::Navigation;
let nav_btn = button(text(" Navigate "))
.on_press(Message::ViewerSetMode(ViewerMode::Navigation))
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_nav {
colors.accent
} else {
colors.bg_panel
})),
text_color: if is_nav {
iced::Color::WHITE
} else {
colors.text_primary
},
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
},
);
let refresh_btn = button(text(" Refresh ")) let refresh_btn = button(text(" Refresh "))
.on_press(Message::ViewerRefresh) .on_press(Message::ViewerRefresh)
.style( .style(
@@ -369,6 +442,20 @@ fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
}, },
); );
let theme_label = if is_light { " Light " } else { " Dark " };
let theme_btn = button(text(theme_label))
.on_press(Message::ViewerThemeToggle)
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
text_color: colors.text_primary,
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
},
);
row![ row![
exit_btn, exit_btn,
text(" "), text(" "),
@@ -376,8 +463,11 @@ fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
text(" "), text(" "),
browser_btn, browser_btn,
viewer_btn, viewer_btn,
nav_btn,
text(" "), text(" "),
refresh_btn, refresh_btn,
Space::with_width(Length::Fill),
theme_btn,
] ]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.width(Length::Fill) .width(Length::Fill)
@@ -594,6 +684,180 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
.into() .into()
} }
/// Navigation mode — directory tree on the left with a large preview pane on the right.
fn view_navigation(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
let tree_panel = {
let tree_content = directory_tree::view(state, colors);
container(
column![
text("Folders").style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_primary),
}),
horizontal_rule(1),
tree_content,
]
.spacing(4)
.width(Length::Fill)
.height(Length::Fill),
)
.width(300)
.height(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default()
.color(colors.border_low)
.width(1)
.rounded(8),
..Default::default()
})
};
let preview_panel = view_large_preview(state, colors);
row![tree_panel, preview_panel]
.width(Length::Fill)
.height(Length::Fill)
.spacing(4)
.into()
}
/// Large preview panel for Navigation mode — shows the selected image with prev/next controls.
fn view_large_preview(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
if state.images.is_empty() {
return container(
column![
text("No images found in this folder.").style(move |_theme| {
iced::widget::text::Style {
color: Some(colors.text_secondary),
}
}),
]
.align_x(iced::Alignment::Center),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into();
}
let total = state.images.len();
let current_idx = state.active_image_idx;
let filename = state
.images
.get(current_idx)
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let edit_path = state.images[current_idx].clone();
let info_bar = row![
text(filename.clone()).style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_primary),
}),
Space::with_width(Length::Fill),
text(format!("{} / {}", current_idx + 1, total)).style(move |_theme| {
iced::widget::text::Style {
color: Some(colors.text_secondary),
}
}),
text(" "),
button(text("Edit"))
.on_press(Message::ViewerOpenFile(edit_path))
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.accent)),
text_color: iced::Color::WHITE,
border: iced::Border::default().color(colors.accent).width(1),
..Default::default()
}
}
),
]
.align_y(iced::Alignment::Center)
.width(Length::Fill);
let image_area: Element<'static, Message> = if let Some((pixels, w, h)) = &state.preview_pixels
{
let handle: iced::widget::image::Handle =
iced::widget::image::Handle::from_rgba(*w, *h, pixels.clone());
let img = iced::widget::Image::new(handle)
.width(Length::Fill)
.height(Length::Fill);
let prev_btn = button(text(" < ")).on_press(Message::ViewerPrev).style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.5,
))),
text_color: iced::Color::WHITE,
border: iced::Border::default().rounded(8),
..Default::default()
}
},
);
let next_btn = button(text(" > ")).on_press(Message::ViewerNext).style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.5,
))),
text_color: iced::Color::WHITE,
border: iced::Border::default().rounded(8),
..Default::default()
}
},
);
row![prev_btn, img, next_btn]
.width(Length::Fill)
.height(Length::Fill)
.into()
} else if let Some(err) = &state.load_error {
column![
text(format!("Failed to load: {}", err)).style(move |_theme| {
iced::widget::text::Style {
color: Some(colors.danger),
}
})
]
.align_x(iced::Alignment::Center)
.width(Length::Fill)
.height(Length::Fill)
.into()
} else {
column![
text("Loading...").style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
})
]
.align_x(iced::Alignment::Center)
.width(Length::Fill)
.height(Length::Fill)
.into()
};
container(
column![info_bar, horizontal_rule(1), image_area]
.width(Length::Fill)
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default()
.color(colors.border_low)
.width(1)
.rounded(8),
..Default::default()
})
.into()
}
/// Viewer mode — single image with navigation arrows and zoom. /// Viewer mode — single image with navigation arrows and zoom.
fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
if state.images.is_empty() { if state.images.is_empty() {