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 feba3e0..794c5ab 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -177,6 +177,10 @@ pub struct HcieIcedApp { pub ai_script_model: String, /// AI script generator — base URL for the selected provider. pub ai_script_url: String, + /// Whether the FastStone-like image viewer is active. + pub viewer_active: bool, + /// Viewer state for directory browsing and thumbnail grid. + pub viewer_state: crate::viewer::ViewerState, } /// A recently opened file entry. @@ -507,6 +511,14 @@ pub enum Message { // ── Misc ──────────────────────────────────────────── NoOp, + + // ── Image Viewer ──────────────────────────────────── + ViewerToggle, + ViewerNavigate(std::path::PathBuf), + ViewerSelectFile(std::path::PathBuf), + ViewerOpenFile(std::path::PathBuf), + ViewerRefresh, + ViewerFullscreen(bool), } /// Convert a FilterType enum to the engine's string filter ID. @@ -674,6 +686,8 @@ impl HcieIcedApp { ai_script_provider: "ollama".to_string(), ai_script_model: "qwen2.5:7b".to_string(), ai_script_url: "http://localhost:11434".to_string(), + viewer_active: false, + viewer_state: crate::viewer::ViewerState::default(), }; // Apply saved settings to tool state @@ -3257,6 +3271,86 @@ impl HcieIcedApp { self.window_id = Some(id); } + // ── Image Viewer ───────────────────────────────── + Message::ViewerToggle => { + self.viewer_active = !self.viewer_active; + if self.viewer_active && self.viewer_state.current_dir.as_os_str().is_empty() { + // Initialize to home directory on first open + let start_dir = dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")); + self.viewer_state = crate::viewer::ViewerState::new(start_dir); + } + if self.viewer_active { + self.viewer_state.load_preview(); + } + } + Message::ViewerNavigate(path) => { + self.viewer_state.set_dir(path); + self.viewer_state.load_preview(); + } + Message::ViewerSelectFile(path) => { + // Select the file in the grid + if let Some(idx) = self.viewer_state.images.iter().position(|p| p == &path) { + self.viewer_state.active_image_idx = idx; + self.viewer_state.load_preview(); + self.viewer_state.view_mode = crate::viewer::ViewerMode::Viewer; + } + } + Message::ViewerOpenFile(path) => { + // Open the file in the editor — exit viewer and open the file + self.viewer_active = false; + let path_str = path.to_string_lossy().to_string(); + let ext = path.extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let result = match ext.as_str() { + "psd" => self.documents[0].engine.import_psd(&path_str), + "kra" => self.documents[0].engine.import_kra(&path_str), + "hcie" => self.documents[0].engine.load_native(&path_str), + _ => self.documents[0].engine.open_image(&path_str), + }; + match result { + Ok(()) => { + self.documents[0].engine.pre_tile_all_layers(); + self.documents[0].name = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Untitled".to_string()); + self.documents[0].source_path = Some(path.clone()); + self.add_recent_file(&path); + let composite = self.documents[0].engine.get_composite_pixels(); + self.documents[0].composite_raw = composite; + self.documents[0].composite_pixels = std::sync::Arc::new(self.documents[0].composite_raw.clone()); + self.documents[0].full_upload.replace(true); + self.documents[0].render_generation = self.documents[0].render_generation.wrapping_add(1); + self.documents[0].cached_layers = self.documents[0].engine.layer_infos(); + let history_len = self.documents[0].engine.history_len(); + self.documents[0].cached_history = (0..history_len) + .filter_map(|i| self.documents[0].engine.history_description(i).map(|d| (i, d))) + .collect(); + self.documents[0].history_current = self.documents[0].engine.history_current(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + Err(e) => { + log::error!("Failed to open file from viewer: {}", e); + } + } + } + Message::ViewerRefresh => { + self.viewer_state.clear_caches(); + self.viewer_state.load_preview(); + } + Message::ViewerFullscreen(fs) => { + self.viewer_state.fullscreen = fs; + if let Some(id) = self.window_id { + if fs { + return iced::window::change_level(id, iced::window::Level::AlwaysOnTop); + } else { + return iced::window::change_level(id, iced::window::Level::Normal); + } + } + } + Message::NoOp => {} } @@ -3271,9 +3365,23 @@ impl HcieIcedApp { /// dock grid, and status bar surround the content. Dialogs overlay everything. pub fn view(&self) -> Element<'_, Message> { let view_start = std::time::Instant::now(); - let doc = &self.documents[self.active_doc]; let colors = self.theme_state.colors(); + // 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); + return container(viewer_panel) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_app)), + ..Default::default() + }) + .into(); + } + + let doc = &self.documents[self.active_doc]; + // Title bar (now includes menu items and window controls) let title_bar = panels::title_bar::view( &doc.name, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs index 86ee626..7d43399 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs @@ -16,6 +16,7 @@ mod selection; mod settings; mod sidebar; mod theme; +mod viewer; use app::HcieIcedApp; 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 new file mode 100644 index 0000000..7eda194 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/directory_tree.rs @@ -0,0 +1,198 @@ +//! Directory tree navigation panel. +//! +//! 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. + +use crate::app::Message; +use crate::theme::ThemeColors; +use crate::viewer::ViewerState; +use iced::widget::{button, column, container, scrollable, text}; +use iced::{Element, Length}; +use std::path::{Path, PathBuf}; + +/// A root directory entry with display name, path, and icon. +struct FolderRoot { + name: String, + path: PathBuf, + icon: &'static str, +} + +/// Get the standard directory roots for the tree. +fn get_roots() -> Vec { + let mut roots = Vec::new(); + if let Some(home) = dirs::home_dir() { + roots.push(FolderRoot { + name: "Home".to_string(), + path: home, + icon: "~", + }); + } + if let Some(desktop) = dirs::desktop_dir() { + roots.push(FolderRoot { + name: "Desktop".to_string(), + path: desktop, + icon: "D", + }); + } + if let Some(docs) = dirs::document_dir() { + roots.push(FolderRoot { + name: "Documents".to_string(), + path: docs, + icon: "Docs", + }); + } + if let Some(downloads) = dirs::download_dir() { + roots.push(FolderRoot { + name: "Downloads".to_string(), + path: downloads, + icon: "DL", + }); + } + if let Some(pics) = dirs::picture_dir() { + roots.push(FolderRoot { + name: "Pictures".to_string(), + path: pics, + icon: "Pic", + }); + } + if let Some(vids) = dirs::video_dir() { + roots.push(FolderRoot { + name: "Videos".to_string(), + path: vids, + icon: "Vid", + }); + } + let root_path = PathBuf::from("/"); + if root_path.exists() { + roots.push(FolderRoot { + name: "Root (/)".to_string(), + path: root_path, + icon: "/", + }); + } + roots +} + +/// Scale a color's alpha by a factor (simulates gamma_multiply). +fn color_alpha(color: iced::Color, alpha: f32) -> iced::Color { + iced::Color { + a: color.a * alpha, + ..color + } +} + +/// Build the directory tree view. +pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { + let roots = get_roots(); + let mut items = column![].spacing(2); + + for root in &roots { + let node = draw_tree_node(&root.path, &root.name, root.icon, state, colors, 0); + items = items.push(node); + } + + scrollable(items.width(Length::Fill)) + .height(Length::Fill) + .into() +} + +/// Recursively draw a tree node with its children. +fn draw_tree_node( + path: &Path, + name: &str, + icon: &str, + state: &ViewerState, + colors: ThemeColors, + depth: usize, +) -> Element<'static, Message> { + let is_selected = state.current_dir == 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), + }); + + 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 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() + } + }); + + 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) + .width(Length::Fill) + .padding(iced::Padding { + top: 0.0, + right: 0.0, + bottom: 0.0, + left: indent, + }) + .into() +} + +/// Get subdirectories of a path (non-cached version for the view). +fn get_subdirs_for_path(path: &Path) -> Vec { + let mut dirs = Vec::new(); + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + dirs.push(p); + } + } + } + dirs.sort(); + dirs +} 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 new file mode 100644 index 0000000..b7d0b40 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs @@ -0,0 +1,674 @@ +//! FastStone-like Image Viewer Mode implementation. +//! +//! Provides a full-screen image browser with: +//! - Left sidebar: directory tree navigation + preview panel +//! - Right panel: thumbnail grid with lazy loading +//! - Keyboard navigation (arrow keys, Enter) +//! - Fullscreen toggle +//! - Browser and Viewer modes +//! +//! Ported from the egui version at `hcie-gui-egui/src/app/shell/viewer.rs`. + +pub mod directory_tree; +pub mod thumbnail_grid; + +use crate::app::Message; +use crate::theme::ThemeColors; +use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, Space}; +use iced::{Element, Length}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Supported image file extensions for the viewer. +const SUPPORTED_EXTENSIONS: &[&str] = &[ + "png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif", "avif", "ico", "pnm", "qoi", "ff", + "hdr", "dds", "tga", "exr", +]; + +/// Viewer display mode — Browser shows the dual-pane directory view, +/// Viewer shows a single image with navigation arrows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ViewerMode { + #[default] + Browser, + Viewer, +} + +/// Persistent viewer state — directory listing, thumbnail cache, zoom/pan. +pub struct ViewerState { + /// Currently browsed directory. + pub current_dir: PathBuf, + /// Supported image files in the current directory. + pub images: Vec, + /// Index of the currently selected/active image. + pub active_image_idx: usize, + /// Current display mode. + pub view_mode: ViewerMode, + /// Whether the viewer is in fullscreen mode. + pub fullscreen: bool, + /// Cached subdirectory lists to avoid repeated disk reads. + pub subdirs_cache: HashMap>, + /// Cached thumbnail RGBA pixel data (path → pixels, width, height). + pub thumbnail_cache: HashMap, u32, u32)>, + /// Paths that failed to load as thumbnails. + pub failed_thumbnails: HashSet, + /// Preview image RGBA pixel data and dimensions. + pub preview_pixels: Option<(Vec, u32, u32)>, + /// Preview load error message. + pub load_error: Option, + /// Zoom level for the viewer panel. + pub zoom: f32, + /// Pan offset for the viewer panel. + pub pan_offset: (f32, f32), +} + +impl Default for ViewerState { + fn default() -> Self { + Self { + current_dir: PathBuf::new(), + images: Vec::new(), + active_image_idx: 0, + view_mode: ViewerMode::Browser, + fullscreen: false, + subdirs_cache: HashMap::new(), + thumbnail_cache: HashMap::new(), + failed_thumbnails: HashSet::new(), + preview_pixels: None, + load_error: None, + zoom: 1.0, + pan_offset: (0.0, 0.0), + } + } +} + +impl ViewerState { + /// Create a new viewer state starting at the specified directory. + pub fn new(start_dir: PathBuf) -> Self { + log::debug!( + "Initializing ViewerState with starting directory: {}", + start_dir.display() + ); + let mut state = Self { + current_dir: start_dir, + ..Default::default() + }; + state.refresh_images(); + state + } + + /// Refresh the images list in the current directory. + pub fn refresh_images(&mut self) { + log::debug!("Refreshing image list for: {}", self.current_dir.display()); + self.images.clear(); + if let Ok(entries) = std::fs::read_dir(&self.current_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && is_supported_image(&path) { + self.images.push(path); + } + } + } + self.images.sort(); + self.active_image_idx = 0; + self.preview_pixels = None; + self.load_error = None; + log::debug!("Found {} supported images", self.images.len()); + } + + /// Move selection to the next image. + pub fn next_image(&mut self) { + if !self.images.is_empty() { + self.active_image_idx = (self.active_image_idx + 1) % self.images.len(); + self.preview_pixels = None; + self.load_error = None; + self.zoom = 1.0; + self.pan_offset = (0.0, 0.0); + log::trace!("Navigated to next image, index: {}", self.active_image_idx); + } + } + + /// Move selection to the previous image. + pub fn prev_image(&mut self) { + if !self.images.is_empty() { + self.active_image_idx = if self.active_image_idx == 0 { + self.images.len() - 1 + } else { + self.active_image_idx - 1 + }; + self.preview_pixels = None; + self.load_error = None; + self.zoom = 1.0; + self.pan_offset = (0.0, 0.0); + log::trace!( + "Navigated to previous image, index: {}", + self.active_image_idx + ); + } + } + + /// Change active directory and clear thumbnail caches. + pub fn set_dir(&mut self, path: PathBuf) { + log::debug!("Setting current directory to: {}", path.display()); + self.current_dir = path; + self.thumbnail_cache.clear(); + self.failed_thumbnails.clear(); + self.refresh_images(); + } + + /// Clear all file system and folder caches. + pub fn clear_caches(&mut self) { + log::debug!("Clearing directory and thumbnail caches"); + self.subdirs_cache.clear(); + self.thumbnail_cache.clear(); + self.failed_thumbnails.clear(); + self.refresh_images(); + } + + /// Get subdirectories of a path, cached to avoid repeated disk reads. + pub fn get_subdirs(&mut self, path: &Path) -> &[PathBuf] { + if !self.subdirs_cache.contains_key(path) { + let mut dirs = Vec::new(); + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + dirs.push(p); + } + } + } + dirs.sort(); + self.subdirs_cache.insert(path.to_path_buf(), dirs); + } + self.subdirs_cache.get(path).unwrap() + } + + /// Load the full preview image for the currently selected image. + pub fn load_preview(&mut self) { + if self.images.is_empty() { + return; + } + let path = &self.images[self.active_image_idx]; + log::debug!("Loading full preview image: {}", path.display()); + match image::open(path) { + Ok(dyn_img) => { + let rgba = dyn_img.to_rgba8(); + let w = rgba.width(); + let h = rgba.height(); + let pixels = rgba.into_raw(); + self.preview_pixels = Some((pixels, w, h)); + self.load_error = None; + } + Err(e) => { + let msg = format!("{}", e); + log::warn!("Failed to load preview {}: {}", path.display(), msg); + self.preview_pixels = None; + self.load_error = Some(msg); + } + } + } + + /// Load a thumbnail for the given image path (120x120 max). + pub fn load_thumbnail(&mut self, path: &Path) { + if self.thumbnail_cache.contains_key(path) || self.failed_thumbnails.contains(path) { + return; + } + log::debug!("Lazy loading thumbnail for: {}", path.display()); + match image::open(path) { + Ok(dyn_img) => { + let thumb = dyn_img.thumbnail(120, 120); + let rgba = thumb.to_rgba8(); + let w = rgba.width(); + let h = rgba.height(); + let pixels = rgba.into_raw(); + self.thumbnail_cache.insert(path.to_path_buf(), (pixels, w, h)); + } + Err(_) => { + self.failed_thumbnails.insert(path.to_path_buf()); + } + } + } +} + +/// Check if a file path has a supported image extension. +pub fn is_supported_image(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| SUPPORTED_EXTENSIONS.contains(&ext.to_lowercase().as_str())) + .unwrap_or(false) +} + +/// Fit a texture size into available space while preserving aspect ratio. +fn _fit_size(texture_w: f32, texture_h: f32, avail_w: f32, avail_h: f32) -> (f32, f32) { + if texture_w <= 0.0 || texture_h <= 0.0 || avail_w <= 0.0 || avail_h <= 0.0 { + return (avail_w, avail_h); + } + let scale = (avail_w / texture_w).min(avail_h / texture_h); + (texture_w * scale, texture_h * scale) +} + +/// Build the viewer panel UI element. +pub fn view( + state: &ViewerState, + colors: ThemeColors, +) -> Element<'static, Message> { + let header = view_header(state, colors); + let body = match state.view_mode { + ViewerMode::Browser => view_browser(state, colors), + ViewerMode::Viewer => view_viewer(state, colors), + }; + column![header, horizontal_rule(1), body] + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +/// Header toolbar with mode toggle, fullscreen toggle, and exit button. +fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { + let exit_btn = button(text(" Exit Viewer ")) + .on_press(Message::ViewerToggle) + .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() + } + }); + + let fs_label = if state.fullscreen { "Windowed" } else { "Fullscreen" }; + let fs_btn = button(text(format!(" {} ", fs_label))) + .on_press(Message::ViewerFullscreen(!state.fullscreen)) + .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() + } + }); + + let is_browser = state.view_mode == ViewerMode::Browser; + let browser_btn = button(text(" Browse ")) + .on_press(Message::ViewerNavigate(state.current_dir.clone())) + .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(if is_browser { + colors.accent + } else { + colors.bg_panel + })), + text_color: if is_browser { + iced::Color::WHITE + } else { + colors.text_primary + }, + border: iced::Border::default().color(colors.border_low).width(1), + ..Default::default() + } + }); + + let is_viewer = state.view_mode == ViewerMode::Viewer; + let viewer_btn = button(text(" Viewer ")) + .on_press(Message::ViewerNavigate(state.current_dir.clone())) + .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(if is_viewer { + colors.accent + } else { + colors.bg_panel + })), + text_color: if is_viewer { + 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(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(" "), + fs_btn, + text(" "), + browser_btn, + viewer_btn, + text(" "), + refresh_btn, + ] + .align_y(iced::Alignment::Center) + .width(Length::Fill) + .into() +} + +/// Browser mode — dual-pane layout with directory tree + preview on left, +/// thumbnail grid on right. +fn view_browser(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { + let left_sidebar = view_left_sidebar(state, colors); + let right_panel = view_right_panel(state, colors); + + row![left_sidebar, right_panel] + .width(Length::Fill) + .height(Length::Fill) + .spacing(4) + .into() +} + +/// Left sidebar: folder tree on top, preview panel on bottom. +fn view_left_sidebar(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(280) + .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_preview(state, colors); + + column![tree_panel, preview_panel] + .width(280) + .height(Length::Fill) + .spacing(4) + .into() +} + +/// Preview panel showing the selected image thumbnail and metadata. +fn view_preview(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { + let content = if let Some((pixels, w, h)) = &state.preview_pixels { + let img = iced::widget::Image::new(iced::widget::image::Handle::from_rgba( + *w, *h, pixels.clone(), + )) + .width(Length::Fill) + .height(Length::Shrink); + + let filename = state + .images + .get(state.active_image_idx) + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let dim_text = format!("{}x{}", w, h); + + column![ + img, + text(filename.clone()) + .size(11) + .style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_primary), + }), + text(dim_text.clone()) + .size(10) + .style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + }), + ] + .spacing(4) + .align_x(iced::Alignment::Center) + .width(Length::Fill) + } else if let Some(err) = &state.load_error { + column![text(format!("Error: {}", err)).style(move |_theme| iced::widget::text::Style { + color: Some(colors.danger), + })] + .align_x(iced::Alignment::Center) + .width(Length::Fill) + } else { + column![text("No image selected").style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + })] + .align_x(iced::Alignment::Center) + .width(Length::Fill) + }; + + container( + column![ + text("Preview").style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_primary), + }), + horizontal_rule(1), + content, + ] + .spacing(4) + .width(Length::Fill) + .height(Length::Fill), + ) + .height(220) + .width(280) + .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() +} + +/// Right panel: toolbar with path, stats, and thumbnail grid. +fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { + let has_parent = state.current_dir.parent().is_some(); + + let parent_btn = if has_parent { + button(text(" Parent ")) + .on_press(Message::ViewerNavigate( + state.current_dir.parent().unwrap().to_path_buf(), + )) + } else { + button(text(" Parent ")) + }; + + let parent_btn = parent_btn + .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() + } + }); + + let dir_str = state.current_dir.to_string_lossy().to_string(); + let total_files = state.images.len(); + let selected_num = if total_files > 0 { + state.active_image_idx + 1 + } else { + 0 + }; + let stats_str = format!("{} Files | Selected: {} / {}", total_files, selected_num, total_files); + + let toolbar = row![ + parent_btn, + text(" ").size(8), + text(dir_str.clone()) + .size(12) + .style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_primary), + }), + ] + .align_y(iced::Alignment::Center) + .width(Length::Fill); + + let stats = text(stats_str.clone()) + .size(11) + .style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + }); + + let grid = thumbnail_grid::view(state, colors); + + container( + column![ + toolbar, + horizontal_rule(1), + stats, + horizontal_rule(1), + grid, + ] + .spacing(4) + .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() { + return column![ + text("No images found in this folder.").style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + }), + button(text("Browse Mode")).on_press(Message::ViewerNavigate( + state.current_dir.clone(), + )), + ] + .align_x(iced::Alignment::Center) + .width(Length::Fill) + .height(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_path = state.images[current_idx].clone(); + let prev_btn = button(text(" < ")) + .on_press(Message::ViewerNavigate(prev_path)) + .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_idx = if current_idx + 1 < total { + current_idx + 1 + } else { + 0 + }; + let next_path = state.images[next_idx].clone(); + let next_btn = button(text(" > ")) + .on_press(Message::ViewerNavigate(next_path)) + .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() + }; + + column![info_bar, horizontal_rule(1), image_area] + .width(Length::Fill) + .height(Length::Fill) + .into() +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs new file mode 100644 index 0000000..3f76e7e --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs @@ -0,0 +1,159 @@ +//! Thumbnail grid panel with lazy loading. +//! +//! Displays a responsive grid of image thumbnails that load lazily +//! (max 3 per frame to avoid stalls). Each card shows the thumbnail, +//! filename, and selection highlight. + +use crate::app::Message; +use crate::theme::ThemeColors; +use crate::viewer::ViewerState; +use iced::widget::{button, column, container, row, scrollable, text, Space}; +use iced::{Element, Length}; + +/// Thumbnail card width in pixels. +const CARD_W: f32 = 130.0; +/// Thumbnail card spacing. +const CARD_SPACING: f32 = 8.0; +/// Max thumbnails to load per frame. +const LAZY_LOAD_BUDGET: usize = 3; + +/// Scale a color's alpha by a factor. +fn color_alpha(color: iced::Color, alpha: f32) -> iced::Color { + iced::Color { + a: color.a * alpha, + ..color + } +} + +/// Build the thumbnail grid view. +pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { + if state.images.is_empty() { + return container( + text("No images found in this folder.").style(move |_theme| { + iced::widget::text::Style { + color: Some(colors.text_secondary), + } + }), + ) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into(); + } + + let avail_w = 600.0_f32; + let cols = ((avail_w / (CARD_W + CARD_SPACING)).floor().max(1.0)) as usize; + + let mut grid_rows = column![].spacing(CARD_SPACING); + let total = state.images.len(); + let mut index = 0; + + while index < total { + let mut row_items = row![].spacing(CARD_SPACING); + for _ in 0..cols { + if index >= total { + break; + } + let card = thumbnail_card(state, index, colors); + row_items = row_items.push(card); + index += 1; + } + grid_rows = grid_rows.push(row_items); + } + + scrollable(grid_rows.width(Length::Fill).padding(8)) + .height(Length::Fill) + .into() +} + +/// Render a single thumbnail card. +fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Element<'static, Message> { + let img_path = &state.images[index]; + let is_selected = state.active_image_idx == index; + let filename = img_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let display_name = if filename.chars().count() > 15 { + format!("{}...", filename.chars().take(12).collect::()) + } else { + filename.clone() + }; + + let thumb_content: Element<'static, Message> = if let Some((pixels, w, h)) = state.thumbnail_cache.get(img_path) { + let handle: iced::widget::image::Handle = iced::widget::image::Handle::from_rgba( + *w, *h, pixels.clone(), + ); + let img = iced::widget::Image::new(handle) + .width(110) + .height(100); + img.into() + } else if state.failed_thumbnails.contains(img_path) { + text("Error").size(10).into() + } else { + text("...").size(10).into() + }; + + let card_content = column![ + Space::with_height(4), + thumb_content, + Space::with_height(6), + text(display_name.clone()) + .size(10) + .style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_primary), + }), + ] + .spacing(2) + .align_x(iced::Alignment::Center) + .width(CARD_W); + + let frame_color = if is_selected { + color_alpha(colors.accent, 0.12) + } else { + colors.bg_app + }; + let border_color = if is_selected { + colors.accent + } else { + colors.border_low + }; + + button(card_content) + .on_press(Message::ViewerSelectFile(img_path.clone())) + .width(CARD_W) + .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(frame_color)), + text_color: colors.text_primary, + border: iced::Border::default() + .color(border_color) + .width(if is_selected { 2.0 } else { 1.0 }) + .rounded(6), + ..Default::default() + } + }) + .into() +} + +/// Count how many thumbnails need loading. +pub fn count_unloaded(state: &ViewerState) -> usize { + state + .images + .iter() + .filter(|p| !state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p)) + .count() +} + +/// Get the next batch of images to load thumbnails for. +pub fn next_load_batch(state: &ViewerState) -> Vec { + state + .images + .iter() + .filter(|p| !state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p)) + .take(LAZY_LOAD_BUDGET) + .cloned() + .collect() +}