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