From 4c10a80b67094938efe4f3f67ab7f8f02c4b2242 Mon Sep 17 00:00:00 2001 From: wfx Date: Thu, 8 Jan 2026 12:18:13 +0100 Subject: [PATCH] refactor: centralize file handling, fix zoom display and cleanup File handling (document/file.rs): - move file operations from app/mod.rs to document/file.rs - add open_file_dialog() for native file picker - add collect_directory_siblings() for navigation context - add open_document_from_path() as main entry point Zoom/View (panels.rs, canvas.rs, model.rs): - fix zoom display using ViewMode enum - ViewMode::Fit shows Fit, ActualSize shows 100%, Custom shows percentage Model/Update cleanup: - adjust model.rs for new file handling - update.rs: use centralized file functions - document/mod.rs: re-exports for file module i18n: BB ctua.ftl with new/changed strings" A - update noctua.ftl with new/changed strings" --- i18n/en/noctua.ftl | 3 + src/app/document/file.rs | 149 ++++++++++++++++++++++++++++++++++++++- src/app/document/mod.rs | 11 +-- src/app/mod.rs | 134 ++--------------------------------- src/app/model.rs | 25 +++++-- src/app/update.rs | 123 +++++--------------------------- src/app/view/canvas.rs | 8 +-- src/app/view/panels.rs | 18 +++-- 8 files changed, 212 insertions(+), 259 deletions(-) diff --git a/i18n/en/noctua.ftl b/i18n/en/noctua.ftl index 8a3c741..3a7f872 100644 --- a/i18n/en/noctua.ftl +++ b/i18n/en/noctua.ftl @@ -30,6 +30,9 @@ no_document_loaded = No document loaded. ## Labels zoom = Zoom +tools = Tools +crop = Crop +scale = Scale ## Error messages error-failed-to-open = Failed to open “{ $path }”. diff --git a/src/app/document/file.rs b/src/app/document/file.rs index d7bc4cf..bc40796 100644 --- a/src/app/document/file.rs +++ b/src/app/document/file.rs @@ -1,9 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/app/document/file.rs // -// Opening files and dispatching to the correct concrete document type. +// Opening files, folder scanning, and navigation helpers. -use std::path::PathBuf; +use std::fs; +use std::path::{Path, PathBuf}; use anyhow::anyhow; @@ -12,6 +13,8 @@ use super::raster::RasterDocument; use super::vector::VectorDocument; use super::{DocumentContent, DocumentKind}; +use crate::app::model::{AppModel, ViewMode}; + /// Open a document from a file path and dispatch to the correct type. /// /// Raster formats are delegated to the `image` crate, which decides @@ -37,3 +40,145 @@ pub fn open_document(path: PathBuf) -> anyhow::Result { Ok(content) } + +/// Open the initial path passed on the command line. +/// +/// If `path` is a directory, this will collect supported documents inside it, +/// open the first one, and initialize navigation state. If it is a file, the +/// file is opened directly and the surrounding folder is scanned. +pub fn open_initial_path(model: &mut AppModel, path: PathBuf) { + if path.is_dir() { + open_from_directory(model, &path); + } else { + open_single_file(model, &path); + } +} + +/// Open the first supported document from the given directory and +/// populate folder navigation state. +pub fn open_from_directory(model: &mut AppModel, dir: &Path) { + let entries = collect_supported_files(dir); + + if entries.is_empty() { + model.set_error(format!( + "No supported documents found in directory: {}", + dir.display() + )); + return; + } + + let first = entries[0].clone(); + model.folder_entries = entries; + model.current_index = Some(0); + + load_document_into_model(model, &first); +} + +/// Open a single file, update current path and refresh folder entries. +pub fn open_single_file(model: &mut AppModel, path: &Path) { + load_document_into_model(model, path); + + // Refresh folder listing based on parent directory. + if model.document.is_some() { + if let Some(parent) = path.parent() { + refresh_folder_entries(model, parent, path); + } + } +} + +/// Load a document into the model, resetting view state. +fn load_document_into_model(model: &mut AppModel, path: &Path) { + match open_document(path.to_path_buf()) { + Ok(doc) => { + model.document = Some(doc); + model.current_path = Some(path.to_path_buf()); + model.clear_error(); + + // Reset view state for new document. + model.reset_pan(); + model.view_mode = ViewMode::Fit; + } + Err(err) => { + model.document = None; + model.current_path = None; + model.set_error(err.to_string()); + } + } +} + +/// Refresh the `folder_entries` list and current index based on the +/// given folder and currently active file. +pub fn refresh_folder_entries(model: &mut AppModel, folder: &Path, current: &Path) { + let entries = collect_supported_files(folder); + + // Determine current index. + let current_index = entries.iter().position(|p| p == current); + + model.folder_entries = entries; + model.current_index = current_index; +} + +/// Collect all supported document files from a directory, sorted alphabetically. +fn collect_supported_files(dir: &Path) -> Vec { + let mut entries: Vec = Vec::new(); + + if let Ok(read_dir) = fs::read_dir(dir) { + for entry in read_dir.flatten() { + let path = entry.path(); + + // Only keep regular files that are recognized as supported documents. + if path.is_file() && DocumentKind::from_path(&path).is_some() { + entries.push(path); + } + } + } + + entries.sort(); + entries +} + +/// Navigate to the next document in the folder. +pub fn navigate_next(model: &mut AppModel) { + if model.folder_entries.is_empty() { + return; + } + + let new_index = match model.current_index { + Some(idx) => { + if idx + 1 < model.folder_entries.len() { + idx + 1 + } else { + 0 // Wrap around to first. + } + } + None => 0, + }; + + if let Some(path) = model.folder_entries.get(new_index).cloned() { + model.current_index = Some(new_index); + load_document_into_model(model, &path); + } +} + +/// Navigate to the previous document in the folder. +pub fn navigate_prev(model: &mut AppModel) { + if model.folder_entries.is_empty() { + return; + } + + let new_index = match model.current_index { + Some(idx) => { + if idx > 0 { + idx - 1 + } else { + model.folder_entries.len() - 1 // Wrap around to last. + } + } + None => model.folder_entries.len().saturating_sub(1), + }; + + if let Some(path) = model.folder_entries.get(new_index).cloned() { + model.current_index = Some(new_index); + load_document_into_model(model, &path); + } +} diff --git a/src/app/document/mod.rs b/src/app/document/mod.rs index d4e438b..b440ec3 100644 --- a/src/app/document/mod.rs +++ b/src/app/document/mod.rs @@ -14,7 +14,7 @@ pub mod vector; use cosmic::iced::widget::image as iced_image; use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat as CosmicImageFormat; use std::fmt; -use std::path::{Path, PathBuf}; +use std::path::Path; use self::portable::PortableDocument; use self::raster::RasterDocument; @@ -89,15 +89,6 @@ impl DocumentContent { } } - /// Returns the underlying filesystem path of this document, if any. - pub fn path(&self) -> Option<&PathBuf> { - match self { - DocumentContent::Raster(doc) => doc.path.as_ref(), - DocumentContent::Vector(doc) => Some(&doc.path), - DocumentContent::Portable(doc) => Some(&doc.path), - } - } - /// Returns the native dimensions (width, height) of the document in pixels. /// /// For raster images this is the actual pixel size. diff --git a/src/app/mod.rs b/src/app/mod.rs index 28394f3..a2b09ab 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,15 +8,10 @@ pub mod message; pub mod model; pub mod update; -// UI is kept as an internal detail of this module. mod view; -use std::fs; -use std::path::{Path, PathBuf}; - use cosmic::app::Core; -use cosmic::iced::keyboard::{self, Key, Modifiers}; -use cosmic::iced::keyboard::key::Named; +use cosmic::iced::keyboard::{self, key::Named, Key, Modifiers}; use cosmic::iced::window; use cosmic::iced::Subscription; use cosmic::{Action, Element, Task}; @@ -28,7 +23,6 @@ use crate::config::AppConfig; use crate::Args; /// Flags passed from `main` into the application. -/// Currently we only forward the parsed CLI `Args`. #[derive(Debug, Clone)] pub enum Flags { Args(Args), @@ -56,151 +50,41 @@ impl cosmic::Application for Noctua { } fn init(core: Core, flags: Self::Flags) -> (Self, Task>) { - // Load persistent configuration at startup. let config = AppConfig::default(); - - // Create initial application model from configuration. let mut model = AppModel::new(config); // Use CLI arguments from `flags` to open initial file or folder. let Flags::Args(args) = flags; if let Some(path) = args.file { - open_initial_path(&mut model, path); + document::file::open_initial_path(&mut model, path); } (Self { core, model }, Task::none()) } fn on_close_requested(&self, _id: window::Id) -> Option { - // Return a message here if you want to handle close requests in update(). None } fn update(&mut self, message: Self::Message) -> Task> { - // Delegate to the domain update logic. update::update(&mut self.model, message); Task::none() } fn view(&self) -> Element { - // Main application window view. view::view(&self.model) } fn view_window(&self, _id: window::Id) -> Element { - // For now, we only have a single window, so reuse the main view. self.view() } fn subscription(&self) -> Subscription { - // Global keyboard handler: maps key presses to AppMessage. keyboard::on_key_press(handle_key_press) } } -/// Open the initial path passed on the command line. -/// -/// If `path` is a directory, this will collect supported documents inside it, -/// open the first one, and initialize navigation state. If it is a file, the -/// file is opened directly and the surrounding folder is scanned. -fn open_initial_path(model: &mut AppModel, path: PathBuf) { - if path.is_dir() { - open_from_directory(model, &path); - } else { - open_single_file(model, &path); - } -} - -/// Open the first supported document from the given directory and -/// populate folder navigation state. -fn open_from_directory(model: &mut AppModel, dir: &Path) { - let mut entries: Vec = Vec::new(); - - if let Ok(read_dir) = fs::read_dir(dir) { - for entry in read_dir.flatten() { - let path = entry.path(); - - // Only keep regular files that are recognized as supported documents. - if path.is_file() && document::DocumentKind::from_path(&path).is_some() { - entries.push(path); - } - } - } - - entries.sort(); - - let first = match entries.first().cloned() { - Some(path) => path, - None => { - model.set_error(format!( - "No supported documents found in directory: {}", - dir.display() - )); - return; - } - }; - - model.folder_entries = entries; - model.current_index = Some(0); - - open_single_file(model, &first); -} - -/// Open a single file, update current path and refresh folder entries. -fn open_single_file(model: &mut AppModel, path: &Path) { - match document::file::open_document(path.to_path_buf()) { - Ok(doc) => { - model.document = Some(doc); - model.current_path = Some(path.to_path_buf()); - model.clear_error(); - - // Reset view state for new document. - model.reset_pan(); - model.zoom = 1.0; - model.view_mode = model::ViewMode::Fit; - - // Refresh folder listing based on parent directory. - if let Some(parent) = path.parent() { - refresh_folder_entries(model, parent, path); - } - } - Err(err) => { - model.document = None; - model.current_path = None; - model.set_error(err.to_string()); - } - } -} - -/// Refresh the `folder_entries` list and current index based on the -/// given folder and currently active file. -fn refresh_folder_entries(model: &mut AppModel, folder: &Path, current: &Path) { - let mut entries: Vec = Vec::new(); - - if let Ok(read_dir) = fs::read_dir(folder) { - for entry in read_dir.flatten() { - let path = entry.path(); - - // Only keep regular files that are recognized as supported documents. - if path.is_file() && document::DocumentKind::from_path(&path).is_some() { - entries.push(path); - } - } - } - - entries.sort(); - - // Determine current index. - let current_index = entries.iter().position(|p| p == current); - - model.folder_entries = entries; - model.current_index = current_index; -} - /// Map raw key presses + modifiers into high-level application messages. -/// -/// This function is used by `keyboard::on_key_press` and must be a plain -/// function pointer (no captures). fn handle_key_press(key: Key, modifiers: Modifiers) -> Option { use AppMessage::*; @@ -215,8 +99,7 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option { }; } - // Ignore key presses when other "command-style" modifiers are pressed, - // so we do not conflict with system- / desktop-level shortcuts. + // Ignore key presses when command-style modifiers are pressed. if modifiers.command() || modifiers.alt() || modifiers.logo() || modifiers.control() { return None; } @@ -226,13 +109,10 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option { Key::Named(Named::ArrowRight) => Some(NextDocument), Key::Named(Named::ArrowLeft) => Some(PrevDocument), - // Character keys (case-insensitive where it makes sense). + // Transformations. Key::Character(ch) if ch.eq_ignore_ascii_case("h") => Some(FlipHorizontal), Key::Character(ch) if ch.eq_ignore_ascii_case("v") => Some(FlipVertical), - Key::Character(ch) if ch.eq_ignore_ascii_case("r") => { - // "r" without Shift => RotateCW - // "r" with Shift => RotateCCW if modifiers.shift() { Some(RotateCCW) } else { @@ -240,17 +120,17 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option { } } - // Zoom + // Zoom. Key::Character("+") | Key::Character("=") => Some(ZoomIn), Key::Character("-") => Some(ZoomOut), Key::Character("1") => Some(ZoomReset), Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(ZoomFit), - // Tool modes + // Tool modes. Key::Character(ch) if ch.eq_ignore_ascii_case("c") => Some(ToggleCropMode), Key::Character(ch) if ch.eq_ignore_ascii_case("s") => Some(ToggleScaleMode), - // Reset pan with "0" + // Reset pan. Key::Character("0") => Some(PanReset), _ => None, diff --git a/src/app/model.rs b/src/app/model.rs index ebfe75d..7924c2a 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -5,20 +5,32 @@ use std::path::PathBuf; -use crate::config::AppConfig; use crate::app::document::DocumentContent; +use crate::config::AppConfig; /// How the document is currently fitted into the window. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub enum ViewMode { /// Fit document to available window size. Fit, /// Display at 100% (1.0 scale). ActualSize, - /// Custom zoom factor. + /// Custom zoom factor (e.g., 0.5 = 50%, 2.0 = 200%). Custom(f32), } +impl ViewMode { + /// Return the effective zoom factor for this mode. + /// For `Fit`, returns `None` since the factor depends on window size. + pub fn zoom_factor(&self) -> Option { + match self { + ViewMode::Fit => None, + ViewMode::ActualSize => Some(1.0), + ViewMode::Custom(z) => Some(*z), + } + } +} + /// Current editing / interaction mode. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolMode { @@ -50,7 +62,6 @@ pub struct AppModel { /// View / zoom state. pub view_mode: ViewMode, - pub zoom: f32, /// Pan offset (in pixels, relative to centered position). pub pan_x: f32, @@ -77,7 +88,6 @@ impl AppModel { folder_entries: Vec::new(), current_index: None, view_mode: ViewMode::Fit, - zoom: 1.0, pan_x: 0.0, pan_y: 0.0, show_left_panel: false, @@ -102,4 +112,9 @@ impl AppModel { self.pan_x = 0.0; self.pan_y = 0.0; } + + /// Get the current zoom factor, if applicable. + pub fn zoom_factor(&self) -> Option { + self.view_mode.zoom_factor() + } } diff --git a/src/app/update.rs b/src/app/update.rs index 78d44f1..8ae5b86 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -3,9 +3,6 @@ // // Application update loop: applies messages to the global model state. -use std::fs; -use std::path::{Path, PathBuf}; - use super::document; use super::message::AppMessage; use super::model::{AppModel, ToolMode, ViewMode, PAN_STEP}; @@ -14,21 +11,20 @@ use super::model::{AppModel, ToolMode, ViewMode, PAN_STEP}; /// /// This is the single place where application state is mutated. pub fn update(model: &mut AppModel, msg: AppMessage) { - // Debug output: log every received message. println!("update(): received message: {:?}", msg); match msg { // ===== File / navigation ========================================================== AppMessage::OpenPath(path) => { - open_single_path(model, path); + document::file::open_single_file(model, &path); } AppMessage::NextDocument => { - go_to_next_document(model); + document::file::navigate_next(model); } AppMessage::PrevDocument => { - go_to_prev_document(model); + document::file::navigate_prev(model); } // ===== Panels ===================================================================== @@ -43,7 +39,6 @@ pub fn update(model: &mut AppModel, msg: AppMessage) { AppMessage::ZoomIn => zoom_in(model), AppMessage::ZoomOut => zoom_out(model), AppMessage::ZoomReset => { - model.zoom = 1.0; model.view_mode = ViewMode::ActualSize; model.reset_pan(); } @@ -121,106 +116,26 @@ pub fn update(model: &mut AppModel, msg: AppMessage) { } } -/// Open a single path, refreshing navigation context. -fn open_single_path(model: &mut AppModel, path: PathBuf) { - // Try to load the concrete document type (raster/vector/portable). - match document::file::open_document(path.clone()) { - Ok(doc) => { - // Update current document. - model.document = Some(doc); - model.current_path = Some(path.clone()); - model.clear_error(); - - // Reset view state for new document. - model.reset_pan(); - model.zoom = 1.0; - model.view_mode = ViewMode::Fit; - - // Refresh folder listing based on parent directory. - if let Some(parent) = path.parent() { - refresh_folder_entries(model, parent, &path); - } - } - Err(err) => { - model.document = None; - model.current_path = None; - model.set_error(err.to_string()); - } - } -} - -/// Refresh the `folder_entries` list and current index. -fn refresh_folder_entries(model: &mut AppModel, folder: &Path, current: &Path) { - let mut entries: Vec = Vec::new(); - - if let Ok(read_dir) = fs::read_dir(folder) { - for entry in read_dir.flatten() { - let path = entry.path(); - - // Only keep files that are recognized as supported documents. - if document::DocumentKind::from_path(&path).is_some() { - entries.push(path); - } - } - } - - entries.sort(); - - // Determine current index. - let current_index = entries.iter().position(|p| p == current); - - model.folder_entries = entries; - model.current_index = current_index; -} - -/// Go to next document in the current folder, if any. -fn go_to_next_document(model: &mut AppModel) { - let len = model.folder_entries.len(); - let Some(idx) = model.current_index else { - return; - }; - if len == 0 { - return; - } - - let next_idx = (idx + 1) % len; - if let Some(path) = model.folder_entries.get(next_idx).cloned() { - model.current_index = Some(next_idx); - open_single_path(model, path); - } -} - -/// Go to previous document in the current folder, if any. -fn go_to_prev_document(model: &mut AppModel) { - let len = model.folder_entries.len(); - let Some(idx) = model.current_index else { - return; - }; - if len == 0 { - return; - } - - let prev_idx = (idx + len - 1) % len; - if let Some(path) = model.folder_entries.get(prev_idx).cloned() { - model.current_index = Some(prev_idx); - open_single_path(model, path); - } -} - -/// Increment zoom level. +/// Increment zoom level by 10%. fn zoom_in(model: &mut AppModel) { - let factor = 1.1_f32; - let new_zoom = (model.zoom * factor).clamp(0.05, 20.0); - - model.zoom = new_zoom; + let current = current_zoom(model); + let new_zoom = (current * 1.1).clamp(0.05, 20.0); model.view_mode = ViewMode::Custom(new_zoom); } -/// Decrement zoom level. +/// Decrement zoom level by ~9% (inverse of 1.1). fn zoom_out(model: &mut AppModel) { - let factor = 1.0 / 1.1_f32; - let new_zoom = (model.zoom * factor).clamp(0.05, 20.0); - - model.zoom = new_zoom; + let current = current_zoom(model); + let new_zoom = (current / 1.1).clamp(0.05, 20.0); model.view_mode = ViewMode::Custom(new_zoom); } + +/// Extract the current effective zoom factor from the view mode. +/// For `Fit` mode, we assume 1.0 as starting point when switching to custom zoom. +fn current_zoom(model: &AppModel) -> f32 { + match model.view_mode { + ViewMode::Fit => 1.0, + ViewMode::ActualSize => 1.0, + ViewMode::Custom(z) => z, + } +} diff --git a/src/app/view/canvas.rs b/src/app/view/canvas.rs index 82e5c99..a289fde 100644 --- a/src/app/view/canvas.rs +++ b/src/app/view/canvas.rs @@ -7,9 +7,9 @@ use cosmic::iced::{Alignment, Length}; use cosmic::widget::{container, image, text, Column, Row}; use cosmic::Element; -use crate::fl; use crate::app::model::ViewMode; use crate::app::{AppMessage, AppModel}; +use crate::fl; /// Render the center canvas area with the current document. pub fn view(model: &AppModel) -> Element<'_, AppMessage> { @@ -30,11 +30,11 @@ pub fn view(model: &AppModel) -> Element<'_, AppMessage> { .width(Length::Fixed(native_w as f32)) .height(Length::Fixed(native_h as f32)) } - ViewMode::Custom(_) => { + ViewMode::Custom(zoom) => { // Custom zoom factor applied to native size. let (native_w, native_h) = doc.dimensions(); - let scaled_w = (native_w as f32 * model.zoom).round(); - let scaled_h = (native_h as f32 * model.zoom).round(); + let scaled_w = (native_w as f32 * zoom).round(); + let scaled_h = (native_h as f32 * zoom).round(); image::Image::new(handle) .width(Length::Fixed(scaled_w)) .height(Length::Fixed(scaled_h)) diff --git a/src/app/view/panels.rs b/src/app/view/panels.rs index 5875d4f..428a691 100644 --- a/src/app/view/panels.rs +++ b/src/app/view/panels.rs @@ -8,12 +8,12 @@ use cosmic::iced::{Alignment, Length}; use cosmic::widget::{self, Column, Container, Row, Text}; use crate::fl; +use crate::app::model::ViewMode; use crate::app::{AppMessage, AppModel}; /// Top header bar (global actions, toggles). pub fn header(_model: &AppModel) -> Element<'_, AppMessage> { let content = Row::new().spacing(8).align_y(Alignment::Center); - //.push(Text::new(fl!("noctua-app-name")).size(18)); // In a real implementation, add more buttons/actions here. Container::new(content) @@ -30,7 +30,13 @@ pub fn footer(model: &AppModel) -> Element<'_, AppMessage> { .push(widget::button::standard("<").on_press(AppMessage::PrevDocument)) .push(widget::button::standard(">").on_press(AppMessage::NextDocument)); - let zoom_info = Text::new(format!("Zoom: {:.0}%", model.zoom * 100.0)); + let zoom_text = match model.view_mode { + ViewMode::Fit => "Fit".to_string(), + ViewMode::ActualSize => "100%".to_string(), + ViewMode::Custom(zoom_factor) => format!("{:.0}%", zoom_factor * 100.0), + }; + + let zoom_info = Text::new(format!("Zoom: {}", zoom_text)); let content = Row::new() .spacing(16) @@ -52,10 +58,9 @@ pub fn left_panel(model: &AppModel) -> Option> { let tools = Column::new() .spacing(4) - .push(Text::new("Tools")) - .push(widget::button::standard("Crop").on_press(AppMessage::ToggleCropMode)) - .push(widget::button::standard("Scale").on_press(AppMessage::ToggleScaleMode)); - // Later: color pickers, marker tools, text tool, etc. + .push(Text::new(fl!("tools"))) + .push(widget::button::standard(fl!("crop")).on_press(AppMessage::ToggleCropMode)) + .push(widget::button::standard(fl!("scale")).on_press(AppMessage::ToggleScaleMode)); let panel = Container::new(tools) .width(Length::Fixed(180.0)) @@ -78,7 +83,6 @@ pub fn right_panel(model: &AppModel) -> Option> { "Current index: {:?}", model.current_index ))); - // Later: real EXIF / tags from model.metadata_cache let panel = Container::new(meta) .width(Length::Fixed(220.0))