From 593522e83f5f53ad0d038f2f149c5e93eb60d0e6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 23 Jul 2026 04:07:35 +0300 Subject: [PATCH] feat: Add directory tree navigation with expand/collapse support and theme toggle functionality --- hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 31 +- .../src/viewer/directory_tree.rs | 136 ++++++--- .../crates/hcie-iced-gui/src/viewer/mod.rs | 274 +++++++++++++++++- 3 files changed, 392 insertions(+), 49 deletions(-) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 937f60f..8348896 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -1080,6 +1080,10 @@ pub enum Message { ViewerPrev, ViewerNext, 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 ────────────────────────────────────── /// Open the SVG node editor for a vector SvgShape. @@ -8918,15 +8922,14 @@ impl HcieIcedApp { Message::ViewerToggle => { self.viewer_active = !self.viewer_active; 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 .settings .viewer_last_dir .as_ref() .map(std::path::PathBuf::from) .filter(|p| p.exists()) - .or_else(|| dirs::home_dir()) - .unwrap_or_else(|| std::path::PathBuf::from(".")); + .unwrap_or_else(crate::viewer::default_start_dir); self.viewer_state = crate::viewer::ViewerState::new(start_dir); } 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 ────────────────────────────────── Message::SvgEditorOpen { @@ -9404,7 +9426,8 @@ impl HcieIcedApp { // When the viewer is active, show the full-screen image viewer 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) .width(Length::Fill) .height(Length::Fill) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/directory_tree.rs b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/directory_tree.rs index 0c86fb8..db4b3c7 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/directory_tree.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/directory_tree.rs @@ -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 //! 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::theme::ThemeColors; 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 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> { let roots = get_roots(); let mut items = column![].spacing(2); @@ -97,7 +99,11 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag .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( path: &Path, name: &str, @@ -107,33 +113,100 @@ fn draw_tree_node( depth: usize, ) -> Element<'static, Message> { let is_selected = state.current_dir == path; + let is_expanded = state.expanded_nodes.contains(path); let indent = depth as f32 * 16.0; let subdirs = get_subdirs_for_path(path); let has_children = !subdirs.is_empty(); - let label = format!("{} {}", icon, name); let label_color = if is_selected { colors.accent } else { colors.text_primary }; - let label_text = text(label.clone()) - .size(12) - .style(move |_theme| iced::widget::text::Style { - color: Some(label_color), - }); + 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 row_content: Element<'static, Message> = if has_children { - let mut children_col = column![].spacing(1); - for child in &subdirs { - 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); - children_col = children_col.push(child_node); + 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) + .style(move |_theme| iced::widget::text::Style { + color: Some(label_color), + }); + + 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); + for child in &subdirs { + 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); + 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) .on_press(Message::ViewerNavigate(path.to_path_buf())) .width(Length::Fill) @@ -152,29 +225,11 @@ fn draw_tree_node( }, ); - column![btn, children_col].spacing(1).into() - } else { - 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() - } - }, - ) - .into() - }; - - container(row_content) + container( + iced::widget::row![Space::with_width(iced::Length::Fixed(20.0)), btn] + .width(Length::Fill) + .align_y(iced::Alignment::Center), + ) .width(Length::Fill) .padding(iced::Padding { top: 0.0, @@ -183,6 +238,7 @@ fn draw_tree_node( left: indent, }) .into() + } } /// Get subdirectories of a path (non-cached version for the view). diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs index e361bff..2e850a7 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs @@ -25,13 +25,47 @@ const SUPPORTED_EXTENSIONS: &[&str] = &[ "hdr", "dds", "tga", "exr", ]; +/// Return the standard root directories that should be pre-expanded in the tree. +fn standard_root_dirs() -> Vec { + 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 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)] pub enum ViewerMode { #[default] Browser, Viewer, + Navigation, } /// Persistent viewer state — directory listing, thumbnail cache, zoom/pan. @@ -46,6 +80,8 @@ pub struct ViewerState { pub view_mode: ViewerMode, /// Whether the viewer is in fullscreen mode. pub fullscreen: bool, + /// Paths that are expanded in the directory tree (collapse/expand state). + pub expanded_nodes: HashSet, /// Cached subdirectory lists to avoid repeated disk reads. pub subdirs_cache: HashMap>, /// Cached thumbnail RGBA pixel data (path → pixels, width, height). @@ -66,12 +102,17 @@ pub struct ViewerState { impl Default for ViewerState { fn default() -> Self { + let mut expanded_nodes = HashSet::new(); + for root in standard_root_dirs() { + expanded_nodes.insert(root); + } Self { current_dir: PathBuf::new(), images: Vec::new(), active_image_idx: 0, view_mode: ViewerMode::Browser, fullscreen: false, + expanded_nodes, subdirs_cache: HashMap::new(), thumbnail_cache: HashMap::new(), failed_thumbnails: HashSet::new(), @@ -99,6 +140,15 @@ impl ViewerState { 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. pub fn refresh_images(&mut self) { 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. -pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { - let header = view_header(state, colors); +pub fn view(state: &ViewerState, colors: ThemeColors, is_light: bool) -> Element<'static, Message> { + let header = view_header(state, colors, is_light); let body = match state.view_mode { ViewerMode::Browser => view_browser(state, colors), ViewerMode::Viewer => view_viewer(state, colors), + ViewerMode::Navigation => view_navigation(state, colors), }; column![header, horizontal_rule(1), body] .width(Length::Fill) @@ -279,8 +330,8 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag .into() } -/// Header toolbar with mode toggle, fullscreen toggle, and exit button. -fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { +/// Header toolbar with mode toggle, fullscreen toggle, theme toggle, and exit button. +fn view_header(state: &ViewerState, colors: ThemeColors, is_light: bool) -> Element<'static, Message> { let exit_btn = button(text(" Exit Viewer ")) .on_press(Message::ViewerToggle) .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 ")) .on_press(Message::ViewerRefresh) .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![ exit_btn, text(" "), @@ -376,8 +463,11 @@ fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes text(" "), browser_btn, viewer_btn, + nav_btn, text(" "), refresh_btn, + Space::with_width(Length::Fill), + theme_btn, ] .align_y(iced::Alignment::Center) .width(Length::Fill) @@ -594,6 +684,180 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static .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. fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { if state.images.is_empty() {