feat(iced): implement FastStone-like image viewer
This commit is contained in:
@@ -177,6 +177,10 @@ pub struct HcieIcedApp {
|
|||||||
pub ai_script_model: String,
|
pub ai_script_model: String,
|
||||||
/// AI script generator — base URL for the selected provider.
|
/// AI script generator — base URL for the selected provider.
|
||||||
pub ai_script_url: String,
|
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.
|
/// A recently opened file entry.
|
||||||
@@ -507,6 +511,14 @@ pub enum Message {
|
|||||||
|
|
||||||
// ── Misc ────────────────────────────────────────────
|
// ── Misc ────────────────────────────────────────────
|
||||||
NoOp,
|
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.
|
/// 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_provider: "ollama".to_string(),
|
||||||
ai_script_model: "qwen2.5:7b".to_string(),
|
ai_script_model: "qwen2.5:7b".to_string(),
|
||||||
ai_script_url: "http://localhost:11434".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
|
// Apply saved settings to tool state
|
||||||
@@ -3257,6 +3271,86 @@ impl HcieIcedApp {
|
|||||||
self.window_id = Some(id);
|
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 => {}
|
Message::NoOp => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3271,9 +3365,23 @@ impl HcieIcedApp {
|
|||||||
/// dock grid, and status bar surround the content. Dialogs overlay everything.
|
/// dock grid, and status bar surround the content. Dialogs overlay everything.
|
||||||
pub fn view(&self) -> Element<'_, Message> {
|
pub fn view(&self) -> Element<'_, Message> {
|
||||||
let view_start = std::time::Instant::now();
|
let view_start = std::time::Instant::now();
|
||||||
let doc = &self.documents[self.active_doc];
|
|
||||||
let colors = self.theme_state.colors();
|
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)
|
// Title bar (now includes menu items and window controls)
|
||||||
let title_bar = panels::title_bar::view(
|
let title_bar = panels::title_bar::view(
|
||||||
&doc.name,
|
&doc.name,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ mod selection;
|
|||||||
mod settings;
|
mod settings;
|
||||||
mod sidebar;
|
mod sidebar;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
mod viewer;
|
||||||
|
|
||||||
use app::HcieIcedApp;
|
use app::HcieIcedApp;
|
||||||
|
|
||||||
|
|||||||
@@ -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<FolderRoot> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<PathBuf>,
|
||||||
|
/// 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<PathBuf, Vec<PathBuf>>,
|
||||||
|
/// Cached thumbnail RGBA pixel data (path → pixels, width, height).
|
||||||
|
pub thumbnail_cache: HashMap<PathBuf, (Vec<u8>, u32, u32)>,
|
||||||
|
/// Paths that failed to load as thumbnails.
|
||||||
|
pub failed_thumbnails: HashSet<PathBuf>,
|
||||||
|
/// Preview image RGBA pixel data and dimensions.
|
||||||
|
pub preview_pixels: Option<(Vec<u8>, u32, u32)>,
|
||||||
|
/// Preview load error message.
|
||||||
|
pub load_error: Option<String>,
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
@@ -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::<String>())
|
||||||
|
} 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<std::path::PathBuf> {
|
||||||
|
state
|
||||||
|
.images
|
||||||
|
.iter()
|
||||||
|
.filter(|p| !state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p))
|
||||||
|
.take(LAZY_LOAD_BUDGET)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user