From 767315a65e0039ac5cac56d48ff1ad40e75c354d Mon Sep 17 00:00:00 2001 From: mow Date: Mon, 9 Mar 2026 21:06:51 +0100 Subject: [PATCH 01/10] refactor(examples/cli): update to new SessionCommand API --- core/examples/cli.rs | 283 +++++++++++++++++++++++++++---------------- 1 file changed, 178 insertions(+), 105 deletions(-) diff --git a/core/examples/cli.rs b/core/examples/cli.rs index cf4304e..7d4edb3 100644 --- a/core/examples/cli.rs +++ b/core/examples/cli.rs @@ -6,10 +6,10 @@ use clap::{ArgGroup, Parser}; use noctua_core::document::manager::{DocumentManager, DocumentState}; use noctua_core::document::session::command::SessionCommand; -use noctua_core::document::session::data::CollectionKind; +use noctua_core::document::session::data::{CollectionKind, SessionData}; use std::path::PathBuf; -// ── ANSI helpers ── +// ANSI helpers const RESET: &str = "\x1b[0m"; const BOLD: &str = "\x1b[1m"; @@ -40,36 +40,32 @@ fn info(text: &str) { println!(" {YELLOW}i{RESET} {text}"); } -fn print_session(manager: &DocumentManager, session_name: &str) { - if let Some(session) = manager.session(session_name) { - let kind_label = match session.kind { - CollectionKind::DirectoryBrowser => "DirectoryBrowser", - CollectionKind::DocumentCollection => "DocumentCollection", +fn print_session(session: &SessionData, active_doc_index: Option) { + let kind_label = match session.kind { + CollectionKind::DirectoryBrowser => "DirectoryBrowser", + CollectionKind::DocumentCollection => "DocumentCollection", + }; + println!( + " {MAGENTA}Session: {BOLD}{}{RESET} {DIM}({kind_label}, {} items, active: {:?}){RESET}", + session.name, + session.items.len(), + active_doc_index, + ); + for (i, item) in session.items.iter().enumerate() { + let marker = if active_doc_index == Some(i) { + format!("{GREEN}=>{RESET}") + } else { + format!("{DIM} {RESET}") }; + let name = item + .path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?"); println!( - " {MAGENTA}Collection: {BOLD}{}{RESET} {DIM}({kind_label}, {} items, index: {:?}){RESET}", - session.name, - session.items.len(), - session.current_index + " {marker} {DIM}[{i}]{RESET} {name} {DIM}(page {}){RESET}", + item.page_index ); - for (i, item) in session.items.iter().enumerate() { - let marker = if session.current_index == Some(i) { - format!("{GREEN}=>{RESET}") - } else { - format!("{DIM} {RESET}") - }; - let name = item - .path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("?"); - println!( - " {marker} {DIM}[{i}]{RESET} {name} {DIM}(page {}){RESET}", - item.page_index - ); - } - } else { - err(&format!("Session '{session_name}' not found")); } } @@ -82,9 +78,11 @@ fn exec(manager: &mut DocumentManager, label: &str, command: SessionCommand) { } } -fn print_nav_index(manager: &DocumentManager, label: &str) { - if let Some(session) = manager.active_session() { - ok(&format!("{label:<28} → index {:?}", session.current_index)); +fn print_active_session(mgr: &DocumentManager) { + if let Some(session) = mgr.active_session() { + print_session(session, mgr.active_document_index()); + } else { + info("No active session"); } } @@ -98,7 +96,6 @@ fn print_nav_index(manager: &DocumentManager, label: &str) { .args(["open_document", "open_directory", "open_session"]) ) )] - struct Opt { /// Open a document (raster, vector, portable) #[arg(long, value_name = "PATH")] @@ -119,12 +116,16 @@ fn main() { let cli = Opt::parse(); let mut mgr = DocumentManager::new(); - // ── Open source ── + // Open source heading("Opening source"); if let Some(path) = cli.open_session { - exec(&mut mgr, "Open session file", SessionCommand::Open { path }); + exec( + &mut mgr, + "Open session file", + SessionCommand::OpenSession { path }, + ); } else if let Some(path) = cli.open_document { let dir = path .parent() @@ -139,28 +140,27 @@ fn main() { exec( &mut mgr, - &format!("New browser session '{session_name}'"), - SessionCommand::New { + &format!("New session '{session_name}'"), + SessionCommand::NewSession { name: session_name.clone(), - kind: CollectionKind::DirectoryBrowser, }, ); exec( &mut mgr, &format!("Select '{session_name}'"), - SessionCommand::Select { + SessionCommand::SelectSession { name: session_name.clone(), }, ); exec( &mut mgr, &format!("Add directory {}", dir.display()), - SessionCommand::AddDirectory { dir }, + SessionCommand::AddDirectoryToSession { dir }, ); exec( &mut mgr, &format!("Add document {}", path.display()), - SessionCommand::AddDocument { path }, + SessionCommand::AddDocumentToSession { path }, ); } else if let Some(path) = cli.open_directory { let session_name = path @@ -172,26 +172,25 @@ fn main() { exec( &mut mgr, &format!("New session '{session_name}'"), - SessionCommand::New { + SessionCommand::NewSession { name: session_name.clone(), - kind: CollectionKind::DirectoryBrowser, }, ); exec( &mut mgr, &format!("Select '{session_name}'"), - SessionCommand::Select { + SessionCommand::SelectSession { name: session_name.clone(), }, ); exec( &mut mgr, &format!("Add directory {}", path.display()), - SessionCommand::AddDirectory { dir: path }, + SessionCommand::AddDirectoryToSession { dir: path }, ); } - // ── State summary ── + // State summary heading("State after open"); @@ -202,7 +201,7 @@ fn main() { DocumentState::Loaded(_) => ok("Document loaded"), } - let active_name = match mgr.active_session_name.clone() { + let active_name = match mgr.active_session().map(|s| s.name.clone()) { Some(n) => n, None => { info("No active session – nothing more to test."); @@ -210,115 +209,189 @@ fn main() { } }; - print_session(&mgr, &active_name); + print_active_session(&mgr); - // ── Navigation ── + // Navigation heading("Navigation"); exec(&mut mgr, "NextDocument", SessionCommand::NextDocument); - print_nav_index(&mgr, "After NextDocument"); + ok(&format!( + "After NextDocument → index {:?}", + mgr.active_document_index() + )); exec( &mut mgr, "PreviousDocument", SessionCommand::PreviousDocument, ); - print_nav_index(&mgr, "After PreviousDocument"); + ok(&format!( + "After PreviousDocument → index {:?}", + mgr.active_document_index() + )); exec( &mut mgr, "SelectDocument { index: 0 }", SessionCommand::SelectDocument { index: 0 }, ); - print_nav_index(&mgr, "After SelectDocument(0)"); + ok(&format!( + "After SelectDocument(0) → index {:?}", + mgr.active_document_index() + )); - // ── Collection operations ── + // Move operations - heading("Collection operations"); + heading("Move operations"); + + step("Before MoveDocumentDown:"); + print_active_session(&mgr); + + exec( + &mut mgr, + "MoveDocumentDown", + SessionCommand::MoveDocumentDown, + ); + ok(&format!( + "After MoveDocumentDown → index {:?}", + mgr.active_document_index() + )); + + exec(&mut mgr, "MoveDocumentUp", SessionCommand::MoveDocumentUp); + ok(&format!( + "After MoveDocumentUp → index {:?}", + mgr.active_document_index() + )); + + // Copy / Paste + + heading("Copy / Paste"); exec( &mut mgr, "New session 'Favorites'", - SessionCommand::New { + SessionCommand::NewSession { name: "Favorites".to_string(), - kind: CollectionKind::DocumentCollection, }, ); exec( &mut mgr, "New session 'Workspace'", - SessionCommand::New { + SessionCommand::NewSession { name: "Workspace".to_string(), - kind: CollectionKind::DocumentCollection, }, ); - // Copy first item from browser → Favorites - heading("Copy item to Favorites"); - step("Before:"); - print_session(&mgr, "Favorites"); + step("Favorites before paste:"); + if let Some(session) = mgr.session_by_name("Favorites") { + print_session(session, None); + } + // SelectSession source → SelectDocument → CopyDocument → SelectSession target → PasteDocument exec( &mut mgr, - &format!("Copy [0] from '{active_name}' → 'Favorites'"), - SessionCommand::CopyDocument { - source: active_name.clone(), - target: "Favorites".to_string(), - source_index: 0, - target_index: 0, - }, - ); - step("After:"); - print_session(&mgr, "Favorites"); - - // Remove from DirectoryBrowser (should fail) - heading("Remove from DirectoryBrowser (expect error)"); - exec( - &mut mgr, - &format!("Select '{active_name}'"), - SessionCommand::Select { + &format!("SelectSession '{active_name}'"), + SessionCommand::SelectSession { name: active_name.clone(), }, ); exec( &mut mgr, - "RemoveDocument { index: 0 }", - SessionCommand::RemoveDocument { index: 0 }, + "SelectDocument { index: 0 }", + SessionCommand::SelectDocument { index: 0 }, ); - - // Move: Favorites → Workspace (copy + delete) - heading("Move: Favorites → Workspace"); - step("Before:"); - print_session(&mgr, "Favorites"); - print_session(&mgr, "Workspace"); - + exec(&mut mgr, "CopyDocument", SessionCommand::CopyDocument); exec( &mut mgr, - "Copy [0] from 'Favorites' → 'Workspace'", - SessionCommand::CopyDocument { - source: "Favorites".to_string(), - target: "Workspace".to_string(), - source_index: 0, - target_index: 0, - }, - ); - exec( - &mut mgr, - "Select 'Favorites'", - SessionCommand::Select { + "SelectSession 'Favorites'", + SessionCommand::SelectSession { name: "Favorites".to_string(), }, ); + exec(&mut mgr, "PasteDocument", SessionCommand::PasteDocument); + + step("Favorites after paste:"); + print_active_session(&mgr); + + // Move pasted item to front via MoveDocumentTo + exec( &mut mgr, - "RemoveDocument { index: 0 }", - SessionCommand::RemoveDocument { index: 0 }, + "MoveDocumentTo { index: 0 }", + SessionCommand::MoveDocumentTo { index: 0 }, + ); + step("Favorites after MoveDocumentTo(0):"); + print_active_session(&mgr); + + // Remove active document + + exec(&mut mgr, "RemoveDocument", SessionCommand::RemoveDocument); + step("Favorites after RemoveDocument:"); + print_active_session(&mgr); + + // Session operations + + heading("Session operations"); + + exec( + &mut mgr, + &format!("SelectSession '{active_name}'"), + SessionCommand::SelectSession { + name: active_name.clone(), + }, + ); + exec( + &mut mgr, + "DuplicateSession 'Snapshot'", + SessionCommand::DuplicateSession { + new_name: "Snapshot".to_string(), + }, ); - step("After:"); - print_session(&mgr, "Favorites"); - print_session(&mgr, "Workspace"); + step("Snapshot session:"); + if let Some(session) = mgr.session_by_name("Snapshot") { + print_session(session, None); + } + + exec( + &mut mgr, + "SelectSession 'Workspace'", + SessionCommand::SelectSession { + name: "Workspace".to_string(), + }, + ); + exec( + &mut mgr, + "RenameSession 'Archive'", + SessionCommand::RenameSession { + name: "Archive".to_string(), + }, + ); + + step("All sessions after rename:"); + for session in mgr.sessions() { + let is_active = mgr + .active_session() + .map_or(false, |a| a.name == session.name); + let marker = if is_active { + format!("{GREEN}*{RESET}") + } else { + format!("{DIM} {RESET}") + }; + println!(" {marker} {}", session.name); + } + + exec( + &mut mgr, + "CloseSession (closes 'Archive')", + SessionCommand::CloseSession, + ); + + step("All sessions after close:"); + for session in mgr.sessions() { + println!(" {DIM}-{RESET} {}", session.name); + } heading("Done"); } From 9ec407d9aab0519072ce6044e6daf9650418ebaa Mon Sep 17 00:00:00 2001 From: mow Date: Mon, 9 Mar 2026 21:08:59 +0100 Subject: [PATCH 02/10] refactor(session): introduce cursor model and full command set --- core/src/document/loader.rs | 6 - core/src/document/manager.rs | 419 ++++++++++++++++++--------- core/src/document/session/command.rs | 83 ++++-- core/src/document/session/data.rs | 35 +-- core/src/document/session/store.rs | 1 - 5 files changed, 340 insertions(+), 204 deletions(-) diff --git a/core/src/document/loader.rs b/core/src/document/loader.rs index c94dc31..f361d0b 100644 --- a/core/src/document/loader.rs +++ b/core/src/document/loader.rs @@ -98,10 +98,4 @@ pub(crate) fn scan_directory_into(session: &mut SessionData, dir: &Path) { session.items = paths.into_iter().map(|p| SessionItem::new(p, 0)).collect(); } - - session.current_index = if session.items.is_empty() { - None - } else { - Some(0) - }; } diff --git a/core/src/document/manager.rs b/core/src/document/manager.rs index 29c7b0a..c7cb67d 100644 --- a/core/src/document/manager.rs +++ b/core/src/document/manager.rs @@ -1,18 +1,18 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/document/manager.rs // -// Document Manager – single entry point via SessionCommand. +// Document manager: session state, cursors, clipboard, and command dispatch. use crate::document::loader::{load_document, scan_directory_into}; use crate::document::session::command::SessionCommand; -use crate::document::session::data::{CollectionKind, SessionData}; +use crate::document::session::data::{CollectionKind, SessionData, SessionItem}; use crate::document::session::store; use crate::document::DocumentContent; use crate::error::Error; use log::warn; -use std::path::Path; +use std::path::{Path, PathBuf}; -#[allow(dead_code)] +/// The load state of the currently viewed document. #[derive(Debug)] pub enum DocumentState { Empty, @@ -21,9 +21,20 @@ pub enum DocumentState { Error(Error), } +/// Central state for all sessions, cursors, and the document clipboard. +/// +/// Two cursors are maintained: +/// - `active_session_index` – the currently active session +/// - `active_document_index` – the currently active document within that session +/// +/// All `SessionCommand` variants that do not carry an explicit target operate +/// on the active cursor. `SelectSession` and `SelectDocument` set them. pub struct DocumentManager { sessions: Vec, - pub active_session_name: Option, + active_session_index: Option, + active_document_index: Option, + clipboard: Option, + session_file_path: Option, pub state: DocumentState, } @@ -31,7 +42,10 @@ impl Default for DocumentManager { fn default() -> Self { Self { sessions: Vec::new(), - active_session_name: None, + active_session_index: None, + active_document_index: None, + clipboard: None, + session_file_path: None, state: DocumentState::Empty, } } @@ -43,79 +57,92 @@ impl DocumentManager { Self::default() } - /// The single public entry point for all session/collection state changes. + /// The single public entry point for all session and document state changes. pub fn handle(&mut self, command: SessionCommand) { match command { - // -- Lifecycle -- - SessionCommand::New { name, kind } => { - self.create_session(&name, kind); - } - SessionCommand::Open { path } => { - self.open_session_file(&path); - } - SessionCommand::Select { name } => { - if self.session(&name).is_some() { - self.active_session_name = Some(name); - } - } + // Session lifecycle + SessionCommand::NewSession { name } => self.new_session(&name), + SessionCommand::OpenSession { path } => self.open_session_file(&path), + SessionCommand::CloseSession | SessionCommand::RemoveSession => self.close_session(), + SessionCommand::SaveSession => self.save_session(), + SessionCommand::SelectSession { name } => self.select_session(&name), + SessionCommand::DuplicateSession { new_name } => self.duplicate_session(&new_name), + SessionCommand::RenameSession { name } => self.rename_session(&name), - // -- Collection management -- - SessionCommand::AddDirectory { dir } => { + // Collection management + SessionCommand::AddDirectoryToSession { dir } => { if let Some(session) = self.active_session_mut() { scan_directory_into(session, &dir); } + self.reset_document_cursor(); } - SessionCommand::AddDocument { path } => { - self.add_document(&path); - } - SessionCommand::CopyDocument { - source, - target, - source_index, - target_index, - } => { - self.copy_document(&source, &target, source_index, target_index); - } - SessionCommand::RemoveDocument { index } => { - self.remove_document(index); - } + SessionCommand::AddDocumentToSession { path } => self.add_document(&path), - // -- Navigation -- + // Document management + SessionCommand::SelectDocument { index } => self.select_document(index), + SessionCommand::CopyDocument => self.copy_document(), + SessionCommand::PasteDocument => self.paste_document(), + SessionCommand::RemoveDocument => self.remove_document(), SessionCommand::NextDocument => self.navigate_next(), SessionCommand::PreviousDocument => self.navigate_previous(), - SessionCommand::SelectDocument { index } => self.select_document(index), + SessionCommand::MoveDocumentUp => self.move_document_up(), + SessionCommand::MoveDocumentDown => self.move_document_down(), + SessionCommand::MoveDocumentTo { index } => self.move_document_to(index), } } - /// Read-only access to a session by name. - #[must_use] - pub fn session(&self, name: &str) -> Option<&SessionData> { - self.sessions.iter().find(|s| s.name == name) - } - /// Read-only access to the active session. #[must_use] pub fn active_session(&self) -> Option<&SessionData> { - let name = self.active_session_name.as_deref()?; + self.active_session_index.map(|i| &self.sessions[i]) + } + + /// The index of the currently active document within the active session. + #[must_use] + pub fn active_document_index(&self) -> Option { + self.active_document_index + } + + /// Read-only access to all sessions. + #[must_use] + pub fn sessions(&self) -> &[SessionData] { + &self.sessions + } + + /// Read-only access to a session by name. + #[must_use] + pub fn session_by_name(&self, name: &str) -> Option<&SessionData> { self.sessions.iter().find(|s| s.name == name) } // -- Private helpers -- - fn session_mut(&mut self, name: &str) -> Option<&mut SessionData> { - self.sessions.iter_mut().find(|s| s.name == name) - } - fn active_session_mut(&mut self) -> Option<&mut SessionData> { - let name = self.active_session_name.clone()?; - self.sessions.iter_mut().find(|s| s.name == name) + self.active_session_index.map(|i| &mut self.sessions[i]) } - fn create_session(&mut self, name: &str, kind: CollectionKind) { - if self.session(name).is_none() { - self.sessions - .push(SessionData::new(name, kind, None, Vec::new())); + fn index_by_name(&self, name: &str) -> Option { + self.sessions.iter().position(|s| s.name == name) + } + + /// Sets `active_document_index` to `Some(0)` if the active session has items, else `None`. + fn reset_document_cursor(&mut self) { + self.active_document_index = match self.active_session() { + Some(s) if !s.items.is_empty() => Some(0), + _ => None, + }; + } + + fn new_session(&mut self, name: &str) { + if self.index_by_name(name).is_some() { + return; } + self.sessions.push(SessionData::new( + name, + CollectionKind::DocumentCollection, + None, + Vec::new(), + )); } fn open_session_file(&mut self, path: &Path) { @@ -141,23 +168,19 @@ impl DocumentManager { false } }); - session.current_index = match session.current_index { - Some(i) if i < session.items.len() => Some(i), - _ if !session.items.is_empty() => Some(0), - _ => None, - }; } } } - let browser_name = loaded + let browser_index = loaded .iter() - .find(|s| s.kind == CollectionKind::DirectoryBrowser) - .map(|s| s.name.clone()); + .position(|s| s.kind == CollectionKind::DirectoryBrowser); self.sessions = loaded; - self.active_session_name = browser_name; + self.active_session_index = browser_index; + self.session_file_path = Some(path.to_path_buf()); self.state = DocumentState::Empty; + self.reset_document_cursor(); } Err(e) => { self.state = DocumentState::Error(e); @@ -165,15 +188,89 @@ impl DocumentManager { } } + fn close_session(&mut self) { + let Some(idx) = self.active_session_index else { + return; + }; + + // The DirectoryBrowser session is managed by the UI and cannot be closed. + if self.sessions[idx].kind == CollectionKind::DirectoryBrowser { + return; + } + + self.sessions.remove(idx); + self.active_document_index = None; + + self.active_session_index = if self.sessions.is_empty() { + None + } else { + Some(idx.saturating_sub(1)) + }; + } + + fn save_session(&mut self) { + let Some(path) = self.session_file_path.clone() else { + return; + }; + if let Err(e) = store::save_sessions(&path, &self.sessions) { + self.state = DocumentState::Error(e); + } + } + + fn select_session(&mut self, name: &str) { + if let Some(idx) = self.index_by_name(name) { + self.active_session_index = Some(idx); + self.reset_document_cursor(); + } + } + + fn duplicate_session(&mut self, new_name: &str) { + if self.index_by_name(new_name).is_some() { + return; + } + let Some(idx) = self.active_session_index else { + return; + }; + let items = self.sessions[idx].items.clone(); + self.sessions.push(SessionData::new( + new_name, + CollectionKind::DocumentCollection, + None, + items, + )); + } + + fn rename_session(&mut self, name: &str) { + let Some(idx) = self.active_session_index else { + return; + }; + + // The DirectoryBrowser session cannot be renamed. + if self.sessions[idx].kind == CollectionKind::DirectoryBrowser { + return; + } + + if self.index_by_name(name).is_some() { + return; + } + + self.sessions[idx].name = name.to_string(); + } + fn add_document(&mut self, path: &Path) { self.state = DocumentState::Loading; match load_document(path) { Ok(content) => { if let Some(session) = self.active_session_mut() { - if let Some(idx) = session.items.iter().position(|item| item.path == path) { - session.current_index = Some(idx); + if session.kind == CollectionKind::DocumentCollection + && !session.items.iter().any(|i| i.path == path) + { + session.items.push(SessionItem::new(path.to_path_buf(), 0)); } } + self.active_document_index = self + .active_session() + .and_then(|s| s.items.iter().position(|i| i.path == path)); self.state = DocumentState::Loaded(content); } Err(e) => { @@ -183,111 +280,145 @@ impl DocumentManager { } fn select_document(&mut self, index: usize) { - if let Some(session) = self.active_session_mut() { + if let Some(session) = self.active_session() { if index < session.items.len() { - session.current_index = Some(index); + self.active_document_index = Some(index); } } } + fn copy_document(&mut self) { + let Some(idx) = self.active_document_index else { + return; + }; + self.clipboard = self + .active_session() + .and_then(|s| s.items.get(idx)) + .cloned(); + } + + fn paste_document(&mut self) { + let Some(item) = self.clipboard.clone() else { + return; + }; + + if !matches!( + self.active_session().map(|s| &s.kind), + Some(CollectionKind::DocumentCollection) + ) { + return; + } + + let new_index = { + let Some(session) = self.active_session_mut() else { + return; + }; + + if session + .items + .iter() + .any(|i| i.path == item.path && i.page_index == item.page_index) + { + return; + } + + session.items.push(item); + session.items.len() - 1 + }; + + self.active_document_index = Some(new_index); + } + + fn remove_document(&mut self) { + if !matches!( + self.active_session().map(|s| &s.kind), + Some(CollectionKind::DocumentCollection) + ) { + return; + } + + let Some(idx) = self.active_document_index else { + return; + }; + + let new_index = { + let Some(session) = self.active_session_mut() else { + return; + }; + + if idx >= session.items.len() { + return; + } + + session.items.remove(idx); + let new_len = session.items.len(); + if new_len == 0 { + None + } else { + Some(idx.min(new_len - 1)) + } + }; + + self.active_document_index = new_index; + } + fn navigate_next(&mut self) { - if let Some(session) = self.active_session_mut() { - if let Some(idx) = session.current_index { - if idx + 1 < session.items.len() { - session.current_index = Some(idx + 1); - } + let len = self.active_session().map_or(0, |s| s.items.len()); + if let Some(idx) = self.active_document_index { + if idx + 1 < len { + self.active_document_index = Some(idx + 1); } } } fn navigate_previous(&mut self) { - if let Some(session) = self.active_session_mut() { - if let Some(idx) = session.current_index { - if idx > 0 { - session.current_index = Some(idx - 1); - } + if let Some(idx) = self.active_document_index { + if idx > 0 { + self.active_document_index = Some(idx - 1); } } } - fn copy_document( - &mut self, - source_name: &str, - target_name: &str, - source_index: usize, - target_index: usize, - ) { - let item = if let Some(s) = self.session(source_name) { - match s.items.get(source_index) { - Some(item) => item.clone(), - None => return, - } - } else { - self.state = DocumentState::Error(Error::Session(format!( - "Source session '{source_name}' not found" - ))); + fn move_document_up(&mut self) { + let Some(idx) = self.active_document_index else { return; }; - - let Some(target) = self.session(target_name) else { - self.state = DocumentState::Error(Error::Session(format!( - "Target session '{target_name}' not found" - ))); - return; - }; - - if target.kind != CollectionKind::DocumentCollection { - self.state = DocumentState::Error(Error::Session( - "Target must be a DocumentCollection".to_string(), - )); + if idx == 0 { return; } - - if target - .items - .iter() - .any(|i| i.path == item.path && i.page_index == item.page_index) - { - self.state = DocumentState::Error(Error::Session("Item already exists".to_string())); - return; - } - - if let Some(target) = self.session_mut(target_name) { - let idx = target_index.min(target.items.len()); - target.items.insert(idx, item); - } - } - - fn remove_document(&mut self, index: usize) { - // Check kind before taking a mutable borrow on the session, so that - // self.state can be set without conflicting borrows. - match self.active_session().map(|s| s.kind.clone()) { - None => return, - Some(CollectionKind::DirectoryBrowser) => { - self.state = DocumentState::Error(Error::Session( - "Can only remove from a DocumentCollection".to_string(), - )); - return; - } - Some(CollectionKind::DocumentCollection) => {} - } - let Some(session) = self.active_session_mut() else { return; }; + session.items.swap(idx, idx - 1); + self.active_document_index = Some(idx - 1); + } - if index >= session.items.len() { + fn move_document_down(&mut self) { + let Some(idx) = self.active_document_index else { + return; + }; + let Some(session) = self.active_session_mut() else { + return; + }; + if idx + 1 >= session.items.len() { return; } + session.items.swap(idx, idx + 1); + self.active_document_index = Some(idx + 1); + } - session.items.remove(index); - let new_len = session.items.len(); - if new_len == 0 { - session.current_index = None; - } else if let Some(current) = session.current_index { - if current >= new_len { - session.current_index = Some(new_len - 1); - } + fn move_document_to(&mut self, target: usize) { + let Some(idx) = self.active_document_index else { + return; + }; + let Some(session) = self.active_session_mut() else { + return; + }; + if idx == target || target >= session.items.len() { + return; } + let item = session.items.remove(idx); + session.items.insert(target, item); + self.active_document_index = Some(target); } } diff --git a/core/src/document/session/command.rs b/core/src/document/session/command.rs index 0b141e4..c4c385d 100644 --- a/core/src/document/session/command.rs +++ b/core/src/document/session/command.rs @@ -3,43 +3,66 @@ // // Commands that operate on sessions and their document collections. -use crate::document::session::data::CollectionKind; use std::path::PathBuf; /// Commands operating on sessions and their document collections. /// -/// All collection-mutating commands operate on the active session -/// unless they explicitly name a session. +/// Two implicit cursors exist in the manager: +/// - `active_session_index` – the currently active session +/// - `active_document_index` – the currently active document within that session +/// +/// `SelectSession` and `SelectDocument` set these cursors. All commands that +/// do not carry an explicit target operate on the active cursor. +/// +/// Only `DocumentCollection` sessions are user-managed. The single +/// `DirectoryBrowser` session is created by the UI and cannot be closed, +/// renamed, or removed. #[derive(Debug, Clone)] pub enum SessionCommand { - // Lifecycle - /// Create a new empty session of the given kind. - New { name: String, kind: CollectionKind }, - /// Open sessions from an existing .ron file. - Open { path: PathBuf }, - /// Switch the active session by name. - Select { name: String }, + // Session lifecycle + /// Create a new empty `DocumentCollection` session. + NewSession { name: String }, + /// Load sessions from an existing .ron file. + OpenSession { path: PathBuf }, + /// Close and remove the active session from the store. + CloseSession, + /// Persist all sessions to the .ron file. + SaveSession, + /// Set the active session cursor by name. + SelectSession { name: String }, + /// Duplicate the active session as a new `DocumentCollection`. + /// + /// When the source is a `DirectoryBrowser` this produces a snapshot: + /// the current items are copied into a new `DocumentCollection`. + DuplicateSession { new_name: String }, + /// Remove the active `DocumentCollection` session without saving. + RemoveSession, + /// Rename the active `DocumentCollection` session. + RenameSession { name: String }, - // Collection management - /// Scan a directory and replace the active session's items. - AddDirectory { dir: PathBuf }, - /// Add a single document to the active session. - AddDocument { path: PathBuf }, - /// Copy a document from one session to another. - CopyDocument { - source: String, - target: String, - source_index: usize, - target_index: usize, - }, - /// Remove a document by index from the active session. - RemoveDocument { index: usize }, + // Collection management within the active session + /// Scan a directory and populate the active `DirectoryBrowser` session. + AddDirectoryToSession { dir: PathBuf }, + /// Add a single document to the active `DocumentCollection` session. + AddDocumentToSession { path: PathBuf }, - // Navigation - /// Navigate to the next document in the active session. - NextDocument, - /// Navigate to the previous document in the active session. - PreviousDocument, - /// Select a document by index in the active session. + // Document management within the active session + /// Set the active document cursor by index. SelectDocument { index: usize }, + /// Copy the active document into the clipboard. + CopyDocument, + /// Paste the clipboard item at the end of the active session. + PasteDocument, + /// Remove the active document from the active session. + RemoveDocument, + /// Move the active document cursor to the next item. + NextDocument, + /// Move the active document cursor to the previous item. + PreviousDocument, + /// Move the active document one position towards the front. + MoveDocumentUp, + /// Move the active document one position towards the back. + MoveDocumentDown, + /// Move the active document to an explicit position. + MoveDocumentTo { index: usize }, } diff --git a/core/src/document/session/data.rs b/core/src/document/session/data.rs index 157982e..9697fdd 100644 --- a/core/src/document/session/data.rs +++ b/core/src/document/session/data.rs @@ -1,28 +1,19 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/document/session/data.rs // -// A session is the unit of work in Noctua. It groups a set of document items -// under a name and tracks which item is currently active. +// Pure data structures for sessions and their document items. // -// There are two kinds of collection: -// -// DirectoryBrowser -// Represents a directory on disk. Items are populated by scanning the -// directory at open time and re-scanned each time the session file is -// loaded – the persisted item list is intentionally ignored on load. -// The source directory is stored in `path`. -// -// DocumentCollection -// A curated, ordered list of documents (or individual pages within them). -// Items are persisted exactly as stored. Missing files are detected and -// removed with a warning when the session file is loaded. -// -// The on-disk representation of a session list is managed by `store.rs`. +// Operational state (active session cursor, active document cursor, clipboard) +// lives in the manager, not here. This module contains only what is persisted. use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// Determines how a session's items are managed. +/// +/// A `DirectoryBrowser` session is created once by the UI and cannot be closed +/// or removed by the user. It can be duplicated via `DuplicateSession`, which +/// produces a new `DocumentCollection` containing a snapshot of the current items. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum CollectionKind { /// Items are derived from a directory scan; `path` holds the source directory. @@ -48,19 +39,19 @@ impl SessionItem { } } -/// The full state of a session. +/// The persistable state of a single session. +/// +/// Operational state (active document index, clipboard) lives in the manager. +/// This struct is serialized as-is to the RON session file. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionData { pub name: String, pub kind: CollectionKind, /// For `DirectoryBrowser`: the source directory to scan. - /// For `DocumentCollection`: None (items are managed individually). - /// The .ron session file path is tracked separately. + /// For `DocumentCollection`: `None` (items are managed individually). #[serde(default)] pub path: Option, pub items: Vec, - /// Index of the currently active item, or `None` if the session is empty. - pub current_index: Option, } impl SessionData { @@ -71,13 +62,11 @@ impl SessionData { path: Option, items: Vec, ) -> Self { - let current_index = if items.is_empty() { None } else { Some(0) }; Self { name: name.to_string(), kind, path, items, - current_index, } } } diff --git a/core/src/document/session/store.rs b/core/src/document/session/store.rs index 480728f..9d00c3c 100644 --- a/core/src/document/session/store.rs +++ b/core/src/document/session/store.rs @@ -54,7 +54,6 @@ pub fn load_sessions(path: &Path) -> Result> { /// /// Returns [`Error::Session`] if the sessions cannot be serialized. /// Returns [`Error::Io`] if the file cannot be written. -#[allow(dead_code)] pub fn save_sessions(path: &Path, sessions: &[SessionData]) -> Result<()> { let content = ron::ser::to_string_pretty(sessions, ron::ser::PrettyConfig::default()) .map_err(|e| Error::Session(format!("Failed to serialize sessions: {e}")))?; From 1a0b8c49aaf653906b873e9cbd975766d8b165e3 Mon Sep 17 00:00:00 2001 From: mow Date: Tue, 10 Mar 2026 19:20:57 +0100 Subject: [PATCH 03/10] refactor: merge workspace (core/ + ui/) into a single flat binary crate integrate CLI into main.rs, no lib.rs by design (YAGNI) --- Cargo.toml | 73 +++++- core/Cargo.toml | 48 ---- core/src/lib.rs | 14 -- justfile | 8 +- {core/src => src}/document/command.rs | 0 {core/src => src}/document/loader.rs | 0 {core/src => src}/document/manager.rs | 0 {core/src => src}/document/mod.rs | 2 +- {core/src => src}/document/model/mod.rs | 0 {core/src => src}/document/model/portable.rs | 0 {core/src => src}/document/model/raster.rs | 0 {core/src => src}/document/model/vector.rs | 0 {core/src => src}/document/session/command.rs | 0 {core/src => src}/document/session/data.rs | 0 {core/src => src}/document/session/mod.rs | 3 - {core/src => src}/document/session/store.rs | 0 {core/src => src}/document/types/image.rs | 0 {core/src => src}/document/types/layout.rs | 0 {core/src => src}/document/types/mod.rs | 1 - {core/src => src}/document/types/viewbox.rs | 0 {core/src => src}/error.rs | 5 +- core/examples/cli.rs => src/main.rs | 208 +++++++++--------- {core/src => src}/render/converter.rs | 0 {core/src => src}/render/mod.rs | 0 {core/src => src}/render/thumbnail.rs | 0 ui/Cargo.toml | 50 ----- ui/src/main.rs | 8 - 27 files changed, 171 insertions(+), 249 deletions(-) delete mode 100644 core/Cargo.toml delete mode 100644 core/src/lib.rs rename {core/src => src}/document/command.rs (100%) rename {core/src => src}/document/loader.rs (100%) rename {core/src => src}/document/manager.rs (100%) rename {core/src => src}/document/mod.rs (87%) rename {core/src => src}/document/model/mod.rs (100%) rename {core/src => src}/document/model/portable.rs (100%) rename {core/src => src}/document/model/raster.rs (100%) rename {core/src => src}/document/model/vector.rs (100%) rename {core/src => src}/document/session/command.rs (100%) rename {core/src => src}/document/session/data.rs (100%) rename {core/src => src}/document/session/mod.rs (67%) rename {core/src => src}/document/session/store.rs (100%) rename {core/src => src}/document/types/image.rs (100%) rename {core/src => src}/document/types/layout.rs (100%) rename {core/src => src}/document/types/mod.rs (91%) rename {core/src => src}/document/types/viewbox.rs (100%) rename {core/src => src}/error.rs (86%) rename core/examples/cli.rs => src/main.rs (72%) rename {core/src => src}/render/converter.rs (100%) rename {core/src => src}/render/mod.rs (100%) rename {core/src => src}/render/thumbnail.rs (100%) delete mode 100644 ui/Cargo.toml delete mode 100644 ui/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 0a74905..d771507 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,73 @@ # SPDX-License-Identifier: GPL-3.0-or-later # Cargo.toml # -# Workspace manifest for the Noctua project. +# Noctua COSMIC desktop application -[workspace] -members = ["core", "ui"] -resolver = "2" +[package] +name = "noctua" +version = "0.1.0" +edition = "2021" +description = "Noctua – a document viewer for the COSMIC™ desktop" +repository = "https://codeberg.org/wfx/noctua" +authors = ["Wolfgang Morawetz "] +license = "GPL-3.0-or-later" + +[features] +default = ["image", "vector", "portable"] +image = ["dep:image", "dep:kamadak-exif"] +vector = ["dep:resvg"] +portable = ["dep:pdfium-render"] +full = ["image", "vector", "portable"] + +[dependencies] +# Serialization +serde = { version = "1", features = ["derive"] } +ron = "0.8" + +# Feature-gated document backends +image = { version = "0.25", optional = true } +kamadak-exif = { version = "0.5", optional = true } +pdfium-render = { version = "0.8", optional = true } +resvg = { version = "0.45", optional = true } + +# Logging +log = "0.4" +env_logger = "0.11" + +# i18n / localization +i18n-embed = { version = "0.16", features = [ + "fluent-system", + "desktop-requester", +] } +i18n-embed-fl = "0.10" + +# Resource embedding +rust-embed = "8" + +# Open files/URLs with the system default application +open = "5" + +# Thumbnail cache (freedesktop.org spec) +dirs = "5.0" +md5 = "0.7" +png = "0.17" + +# CLI argument parsing +clap = { version = "4", features = ["derive"] } + +# Async runtime (required by libcosmic) +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } + +[dependencies.libcosmic] +git = "https://github.com/pop-os/libcosmic.git" +features = [ + "a11y", + "about", + "dbus-config", + "multi-window", + "single-instance", + "tokio", + "winit", + "wayland", + "wgpu", +] diff --git a/core/Cargo.toml b/core/Cargo.toml deleted file mode 100644 index d582fe8..0000000 --- a/core/Cargo.toml +++ /dev/null @@ -1,48 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# core/Cargo.toml -# -# Core library crate – no UI dependencies. - -[package] -name = "noctua_core" -version = "0.1.0" -edition = "2021" -description = "Document core library for the Noctua viewer" -repository = "https://codeberg.org/wfx/noctua" -authors = ["Wolfgang Morawetz "] -license = "GPL-3.0-or-later" - -[lib] -name = "noctua_core" -path = "src/lib.rs" - -[features] -default = ["image", "vector", "portable"] -image = ["dep:image", "dep:kamadak-exif"] -vector = ["dep:resvg"] -portable = ["dep:pdfium-render"] -full = ["image", "vector", "portable"] - -[dependencies] -# Serialization -serde = { version = "1", features = ["derive"] } -ron = "0.8" - -# Feature-gated document backends -image = { version = "0.25", optional = true } -kamadak-exif = { version = "0.5", optional = true } -pdfium-render = { version = "0.8", optional = true } -resvg = { version = "0.45", optional = true } - -# Logging interface -log = "0.4" - -# Thumbnail cache (freedesktop.org spec) -dirs = "5.0" -md5 = "0.7" -png = "0.17" - -[dev-dependencies] -# CLI test harness (examples/cli.rs only) -clap = { version = "4", features = ["derive"] } -env_logger = "0.11" diff --git a/core/src/lib.rs b/core/src/lib.rs deleted file mode 100644 index b337937..0000000 --- a/core/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/lib.rs -// -// Public API of the noctua_core library. - -pub mod error; - -pub mod document; -pub mod render; - -pub use document::command::DocumentCommand; -pub use document::manager::DocumentManager; -pub use document::session::SessionCommand; -pub use document::types::RawImage; diff --git a/justfile b/justfile index c04c9d0..65263c9 100644 --- a/justfile +++ b/justfile @@ -60,13 +60,9 @@ check-json: (check '--message-format=json') test *args: cargo test --workspace {{args}} -# Runs the COSMIC UI application +# Runs the application (no args = COSMIC UI, with args = CLI mode) run *args: - env RUST_BACKTRACE=full cargo run --release -p noctua_ui {{args}} - -# Runs the CLI test harness (core only, no UI deps) -run-cli *args: - env RUST_BACKTRACE=full cargo run --example cli -p noctua_core {{args}} + env RUST_BACKTRACE=full cargo run --release {{args}} # Installs files install: diff --git a/core/src/document/command.rs b/src/document/command.rs similarity index 100% rename from core/src/document/command.rs rename to src/document/command.rs diff --git a/core/src/document/loader.rs b/src/document/loader.rs similarity index 100% rename from core/src/document/loader.rs rename to src/document/loader.rs diff --git a/core/src/document/manager.rs b/src/document/manager.rs similarity index 100% rename from core/src/document/manager.rs rename to src/document/manager.rs diff --git a/core/src/document/mod.rs b/src/document/mod.rs similarity index 87% rename from core/src/document/mod.rs rename to src/document/mod.rs index c719136..f620821 100644 --- a/core/src/document/mod.rs +++ b/src/document/mod.rs @@ -14,7 +14,7 @@ use self::model::{PortableModel, RasterModel, VectorModel}; /// The decoded content of a loaded document. /// -/// This enum is internal to `noctua_core`. The UI only ever sees a `RawImage` +/// This enum is internal to Noctua. The UI only ever sees a `RawImage` /// produced by the render subsystem – it never matches on this type. #[derive(Debug)] pub enum DocumentContent { diff --git a/core/src/document/model/mod.rs b/src/document/model/mod.rs similarity index 100% rename from core/src/document/model/mod.rs rename to src/document/model/mod.rs diff --git a/core/src/document/model/portable.rs b/src/document/model/portable.rs similarity index 100% rename from core/src/document/model/portable.rs rename to src/document/model/portable.rs diff --git a/core/src/document/model/raster.rs b/src/document/model/raster.rs similarity index 100% rename from core/src/document/model/raster.rs rename to src/document/model/raster.rs diff --git a/core/src/document/model/vector.rs b/src/document/model/vector.rs similarity index 100% rename from core/src/document/model/vector.rs rename to src/document/model/vector.rs diff --git a/core/src/document/session/command.rs b/src/document/session/command.rs similarity index 100% rename from core/src/document/session/command.rs rename to src/document/session/command.rs diff --git a/core/src/document/session/data.rs b/src/document/session/data.rs similarity index 100% rename from core/src/document/session/data.rs rename to src/document/session/data.rs diff --git a/core/src/document/session/mod.rs b/src/document/session/mod.rs similarity index 67% rename from core/src/document/session/mod.rs rename to src/document/session/mod.rs index 1e82946..5d25ca6 100644 --- a/core/src/document/session/mod.rs +++ b/src/document/session/mod.rs @@ -6,6 +6,3 @@ pub mod command; pub mod data; pub mod store; - -pub use command::SessionCommand; -pub use data::{CollectionKind, SessionData, SessionItem}; diff --git a/core/src/document/session/store.rs b/src/document/session/store.rs similarity index 100% rename from core/src/document/session/store.rs rename to src/document/session/store.rs diff --git a/core/src/document/types/image.rs b/src/document/types/image.rs similarity index 100% rename from core/src/document/types/image.rs rename to src/document/types/image.rs diff --git a/core/src/document/types/layout.rs b/src/document/types/layout.rs similarity index 100% rename from core/src/document/types/layout.rs rename to src/document/types/layout.rs diff --git a/core/src/document/types/mod.rs b/src/document/types/mod.rs similarity index 91% rename from core/src/document/types/mod.rs rename to src/document/types/mod.rs index eae29b1..7a5f836 100644 --- a/core/src/document/types/mod.rs +++ b/src/document/types/mod.rs @@ -7,6 +7,5 @@ pub mod image; pub mod layout; pub mod viewbox; -pub use image::RawImage; pub use layout::PageLayout; pub use viewbox::ViewBox; diff --git a/core/src/document/types/viewbox.rs b/src/document/types/viewbox.rs similarity index 100% rename from core/src/document/types/viewbox.rs rename to src/document/types/viewbox.rs diff --git a/core/src/error.rs b/src/error.rs similarity index 86% rename from core/src/error.rs rename to src/error.rs index c17566b..402a70a 100644 --- a/core/src/error.rs +++ b/src/error.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/error.rs // -// Central error and result types for noctua_core. +// Central error and result types for Noctua. use std::fmt; @@ -13,8 +13,6 @@ pub enum Error { NotFound(String), /// The file format or operation is not supported. Unsupported(String), - /// A rendering operation failed. - Render(String), /// A session could not be loaded or saved. Session(String), /// An underlying I/O error. @@ -26,7 +24,6 @@ impl fmt::Display for Error { match self { Error::NotFound(msg) => write!(f, "Not found: {msg}"), Error::Unsupported(msg) => write!(f, "Unsupported: {msg}"), - Error::Render(msg) => write!(f, "Render error: {msg}"), Error::Session(msg) => write!(f, "Session error: {msg}"), Error::Io(err) => write!(f, "I/O error: {err}"), } diff --git a/core/examples/cli.rs b/src/main.rs similarity index 72% rename from core/examples/cli.rs rename to src/main.rs index 7d4edb3..da53220 100644 --- a/core/examples/cli.rs +++ b/src/main.rs @@ -1,13 +1,17 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// examples/cli.rs +// src/main.rs // -// CLI test harness for the noctua_core document foundation. +// Entry point for the Noctua COSMIC application. -use clap::{ArgGroup, Parser}; -use noctua_core::document::manager::{DocumentManager, DocumentState}; -use noctua_core::document::session::command::SessionCommand; -use noctua_core::document::session::data::{CollectionKind, SessionData}; -use std::path::PathBuf; +mod document; +mod error; +mod render; + +use clap::Parser; +use document::manager::{DocumentManager, DocumentState}; +use document::session::command::SessionCommand; +use document::session::data::{CollectionKind, SessionData}; +use std::path::{Path, PathBuf}; // ANSI helpers @@ -87,50 +91,45 @@ fn print_active_session(mgr: &DocumentManager) { } #[derive(Parser, Debug)] -#[command( - name = "noctua", - about = "A document viewer for the COSMIC desktop", - group( - ArgGroup::new("source") - .required(true) - .args(["open_document", "open_directory", "open_session"]) - ) -)] -struct Opt { +#[command(name = "noctua", about = "A document viewer for the COSMIC desktop")] +struct Args { /// Open a document (raster, vector, portable) - #[arg(long, value_name = "PATH")] - open_document: Option, + #[arg(long = "open-document", value_name = "PATH")] + document: Option, /// Open a directory - #[arg(long, value_name = "PATH")] - open_directory: Option, + #[arg(long = "open-directory", value_name = "PATH")] + directory: Option, /// Open a session file (.ron) - #[arg(long, value_name = "PATH")] - open_session: Option, + #[arg(long = "open-session", value_name = "PATH")] + session: Option, } fn main() { env_logger::init(); - let cli = Opt::parse(); - let mut mgr = DocumentManager::new(); + let args = Args::parse(); - // Open source + if args.document.is_none() && args.directory.is_none() && args.session.is_none() { + // TODO: Launch COSMIC UI. + return; + } - heading("Opening source"); + run_cli(args); +} - if let Some(path) = cli.open_session { +fn open_source(mgr: &mut DocumentManager, args: Args) { + if let Some(path) = args.session { exec( - &mut mgr, + mgr, "Open session file", SessionCommand::OpenSession { path }, ); - } else if let Some(path) = cli.open_document { + } else if let Some(path) = args.document { let dir = path .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")); + .map_or_else(|| PathBuf::from("."), Path::to_path_buf); let session_name = dir .file_name() @@ -139,30 +138,30 @@ fn main() { .to_string(); exec( - &mut mgr, + mgr, &format!("New session '{session_name}'"), SessionCommand::NewSession { name: session_name.clone(), }, ); exec( - &mut mgr, + mgr, &format!("Select '{session_name}'"), SessionCommand::SelectSession { name: session_name.clone(), }, ); exec( - &mut mgr, + mgr, &format!("Add directory {}", dir.display()), SessionCommand::AddDirectoryToSession { dir }, ); exec( - &mut mgr, + mgr, &format!("Add document {}", path.display()), SessionCommand::AddDocumentToSession { path }, ); - } else if let Some(path) = cli.open_directory { + } else if let Some(path) = args.directory { let session_name = path .file_name() .and_then(|n| n.to_str()) @@ -170,69 +169,44 @@ fn main() { .to_string(); exec( - &mut mgr, + mgr, &format!("New session '{session_name}'"), SessionCommand::NewSession { name: session_name.clone(), }, ); exec( - &mut mgr, + mgr, &format!("Select '{session_name}'"), SessionCommand::SelectSession { name: session_name.clone(), }, ); exec( - &mut mgr, + mgr, &format!("Add directory {}", path.display()), SessionCommand::AddDirectoryToSession { dir: path }, ); } +} - // State summary - - heading("State after open"); - - match &mgr.state { - DocumentState::Empty => info("No document loaded"), - DocumentState::Loading => info("Loading…"), - DocumentState::Error(e) => err(&format!("{e}")), - DocumentState::Loaded(_) => ok("Document loaded"), - } - - let active_name = match mgr.active_session().map(|s| s.name.clone()) { - Some(n) => n, - None => { - info("No active session – nothing more to test."); - return; - } - }; - - print_active_session(&mgr); - - // Navigation - +fn run_navigation_test(mgr: &mut DocumentManager) { heading("Navigation"); - exec(&mut mgr, "NextDocument", SessionCommand::NextDocument); + exec(mgr, "NextDocument", SessionCommand::NextDocument); ok(&format!( "After NextDocument → index {:?}", mgr.active_document_index() )); - exec( - &mut mgr, - "PreviousDocument", - SessionCommand::PreviousDocument, - ); + exec(mgr, "PreviousDocument", SessionCommand::PreviousDocument); ok(&format!( "After PreviousDocument → index {:?}", mgr.active_document_index() )); exec( - &mut mgr, + mgr, "SelectDocument { index: 0 }", SessionCommand::SelectDocument { index: 0 }, ); @@ -241,42 +215,36 @@ fn main() { mgr.active_document_index() )); - // Move operations - heading("Move operations"); step("Before MoveDocumentDown:"); - print_active_session(&mgr); + print_active_session(mgr); - exec( - &mut mgr, - "MoveDocumentDown", - SessionCommand::MoveDocumentDown, - ); + exec(mgr, "MoveDocumentDown", SessionCommand::MoveDocumentDown); ok(&format!( "After MoveDocumentDown → index {:?}", mgr.active_document_index() )); - exec(&mut mgr, "MoveDocumentUp", SessionCommand::MoveDocumentUp); + exec(mgr, "MoveDocumentUp", SessionCommand::MoveDocumentUp); ok(&format!( "After MoveDocumentUp → index {:?}", mgr.active_document_index() )); +} - // Copy / Paste - +fn run_copy_paste_test(mgr: &mut DocumentManager, active_name: &str) { heading("Copy / Paste"); exec( - &mut mgr, + mgr, "New session 'Favorites'", SessionCommand::NewSession { name: "Favorites".to_string(), }, ); exec( - &mut mgr, + mgr, "New session 'Workspace'", SessionCommand::NewSession { name: "Workspace".to_string(), @@ -288,61 +256,56 @@ fn main() { print_session(session, None); } - // SelectSession source → SelectDocument → CopyDocument → SelectSession target → PasteDocument exec( - &mut mgr, + mgr, &format!("SelectSession '{active_name}'"), SessionCommand::SelectSession { - name: active_name.clone(), + name: active_name.to_string(), }, ); exec( - &mut mgr, + mgr, "SelectDocument { index: 0 }", SessionCommand::SelectDocument { index: 0 }, ); - exec(&mut mgr, "CopyDocument", SessionCommand::CopyDocument); + exec(mgr, "CopyDocument", SessionCommand::CopyDocument); exec( - &mut mgr, + mgr, "SelectSession 'Favorites'", SessionCommand::SelectSession { name: "Favorites".to_string(), }, ); - exec(&mut mgr, "PasteDocument", SessionCommand::PasteDocument); + exec(mgr, "PasteDocument", SessionCommand::PasteDocument); step("Favorites after paste:"); - print_active_session(&mgr); - - // Move pasted item to front via MoveDocumentTo + print_active_session(mgr); exec( - &mut mgr, + mgr, "MoveDocumentTo { index: 0 }", SessionCommand::MoveDocumentTo { index: 0 }, ); step("Favorites after MoveDocumentTo(0):"); - print_active_session(&mgr); + print_active_session(mgr); - // Remove active document - - exec(&mut mgr, "RemoveDocument", SessionCommand::RemoveDocument); + exec(mgr, "RemoveDocument", SessionCommand::RemoveDocument); step("Favorites after RemoveDocument:"); - print_active_session(&mgr); - - // Session operations + print_active_session(mgr); +} +fn run_session_test(mgr: &mut DocumentManager, active_name: &str) { heading("Session operations"); exec( - &mut mgr, + mgr, &format!("SelectSession '{active_name}'"), SessionCommand::SelectSession { - name: active_name.clone(), + name: active_name.to_string(), }, ); exec( - &mut mgr, + mgr, "DuplicateSession 'Snapshot'", SessionCommand::DuplicateSession { new_name: "Snapshot".to_string(), @@ -355,14 +318,14 @@ fn main() { } exec( - &mut mgr, + mgr, "SelectSession 'Workspace'", SessionCommand::SelectSession { name: "Workspace".to_string(), }, ); exec( - &mut mgr, + mgr, "RenameSession 'Archive'", SessionCommand::RenameSession { name: "Archive".to_string(), @@ -371,9 +334,7 @@ fn main() { step("All sessions after rename:"); for session in mgr.sessions() { - let is_active = mgr - .active_session() - .map_or(false, |a| a.name == session.name); + let is_active = mgr.active_session().is_some_and(|a| a.name == session.name); let marker = if is_active { format!("{GREEN}*{RESET}") } else { @@ -383,7 +344,7 @@ fn main() { } exec( - &mut mgr, + mgr, "CloseSession (closes 'Archive')", SessionCommand::CloseSession, ); @@ -392,6 +353,33 @@ fn main() { for session in mgr.sessions() { println!(" {DIM}-{RESET} {}", session.name); } +} + +fn run_cli(args: Args) { + let mut mgr = DocumentManager::new(); + + heading("Opening source"); + open_source(&mut mgr, args); + + heading("State after open"); + + match &mgr.state { + DocumentState::Empty => info("No document loaded"), + DocumentState::Loading => info("Loading…"), + DocumentState::Error(e) => err(&format!("{e}")), + DocumentState::Loaded(_) => ok("Document loaded"), + } + + let Some(active_name) = mgr.active_session().map(|s| s.name.clone()) else { + info("No active session – nothing more to test."); + return; + }; + + print_active_session(&mgr); + + run_navigation_test(&mut mgr); + run_copy_paste_test(&mut mgr, &active_name); + run_session_test(&mut mgr, &active_name); heading("Done"); } diff --git a/core/src/render/converter.rs b/src/render/converter.rs similarity index 100% rename from core/src/render/converter.rs rename to src/render/converter.rs diff --git a/core/src/render/mod.rs b/src/render/mod.rs similarity index 100% rename from core/src/render/mod.rs rename to src/render/mod.rs diff --git a/core/src/render/thumbnail.rs b/src/render/thumbnail.rs similarity index 100% rename from core/src/render/thumbnail.rs rename to src/render/thumbnail.rs diff --git a/ui/Cargo.toml b/ui/Cargo.toml deleted file mode 100644 index 98bb336..0000000 --- a/ui/Cargo.toml +++ /dev/null @@ -1,50 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# ui/Cargo.toml -# -# Binary crate for the Noctua COSMIC desktop application. - -[package] -name = "noctua" -version = "0.1.0" -edition = "2021" -description = "Noctua – a document viewer for the COSMIC™ desktop" -repository = "https://codeberg.org/wfx/noctua" -authors = ["Wolfgang Morawetz "] -license = "GPL-3.0-or-later" - -[dependencies] -noctua_core = { path = "../core" } - -# Logging -log = "0.4" -env_logger = "0.11" - -# i18n / localization -i18n-embed = { version = "0.16", features = [ - "fluent-system", - "desktop-requester", -] } -i18n-embed-fl = "0.10" - -# Resource embedding -rust-embed = "8" - -# Open files/URLs with the system default application -open = "5" - -# Async runtime (required by libcosmic) -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } - -[dependencies.libcosmic] -git = "https://github.com/pop-os/libcosmic.git" -features = [ - "a11y", - "about", - "dbus-config", - "multi-window", - "single-instance", - "tokio", - "winit", - "wayland", - "wgpu", -] diff --git a/ui/src/main.rs b/ui/src/main.rs deleted file mode 100644 index 7269760..0000000 --- a/ui/src/main.rs +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/main.rs -// -// Entry point for the Noctua COSMIC application. - -fn main() { - // TODO: Initialize the COSMIC application. -} From 30e3bf6dbe6b3f2963a5bccef570ce9c9b0bcee1 Mon Sep 17 00:00:00 2001 From: mow Date: Tue, 10 Mar 2026 20:12:16 +0100 Subject: [PATCH 04/10] fix: rename RemoveSession to DeleteSession and implement separate delete logic replace unnecessary clones with borrows in manager and main --- src/document/manager.rs | 23 ++++++++++++++++------- src/document/session/command.rs | 6 +++--- src/main.rs | 8 ++------ 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/document/manager.rs b/src/document/manager.rs index c7cb67d..ae6d4d2 100644 --- a/src/document/manager.rs +++ b/src/document/manager.rs @@ -63,7 +63,8 @@ impl DocumentManager { // Session lifecycle SessionCommand::NewSession { name } => self.new_session(&name), SessionCommand::OpenSession { path } => self.open_session_file(&path), - SessionCommand::CloseSession | SessionCommand::RemoveSession => self.close_session(), + SessionCommand::CloseSession => self.close_session(), + SessionCommand::DeleteSession => self.delete_session(), SessionCommand::SaveSession => self.save_session(), SessionCommand::SelectSession { name } => self.select_session(&name), SessionCommand::DuplicateSession { new_name } => self.duplicate_session(&new_name), @@ -151,8 +152,8 @@ impl DocumentManager { for session in &mut loaded { match session.kind { CollectionKind::DirectoryBrowser => { - if let Some(dir) = session.path.clone() { - scan_directory_into(session, &dir); + if let Some(ref dir) = session.path { + scan_directory_into(session, dir); } } CollectionKind::DocumentCollection => { @@ -189,11 +190,19 @@ impl DocumentManager { } fn close_session(&mut self) { + // Close session only removes it from runtime, keeps it in .ron file + // For actual deletion, use delete_session() + // Currently acts same as delete for backwards compatibility + // TODO: Implement proper close behavior (hide from UI but keep in .ron) + self.delete_session(); + } + + fn delete_session(&mut self) { let Some(idx) = self.active_session_index else { return; }; - // The DirectoryBrowser session is managed by the UI and cannot be closed. + // The DirectoryBrowser session is managed by the UI and cannot be deleted. if self.sessions[idx].kind == CollectionKind::DirectoryBrowser { return; } @@ -209,7 +218,7 @@ impl DocumentManager { } fn save_session(&mut self) { - let Some(path) = self.session_file_path.clone() else { + let Some(ref path) = self.session_file_path else { return; }; if let Err(e) = store::save_sessions(&path, &self.sessions) { @@ -298,7 +307,7 @@ impl DocumentManager { } fn paste_document(&mut self) { - let Some(item) = self.clipboard.clone() else { + let Some(ref item) = self.clipboard else { return; }; @@ -322,7 +331,7 @@ impl DocumentManager { return; } - session.items.push(item); + session.items.push(item.clone()); session.items.len() - 1 }; diff --git a/src/document/session/command.rs b/src/document/session/command.rs index c4c385d..da87086 100644 --- a/src/document/session/command.rs +++ b/src/document/session/command.rs @@ -24,7 +24,7 @@ pub enum SessionCommand { NewSession { name: String }, /// Load sessions from an existing .ron file. OpenSession { path: PathBuf }, - /// Close and remove the active session from the store. + /// Close the active session (removes from runtime, kept in .ron file). CloseSession, /// Persist all sessions to the .ron file. SaveSession, @@ -35,8 +35,8 @@ pub enum SessionCommand { /// When the source is a `DirectoryBrowser` this produces a snapshot: /// the current items are copied into a new `DocumentCollection`. DuplicateSession { new_name: String }, - /// Remove the active `DocumentCollection` session without saving. - RemoveSession, + /// Delete the active `DocumentCollection` session permanently (removed from .ron on save). + DeleteSession, /// Rename the active `DocumentCollection` session. RenameSession { name: String }, diff --git a/src/main.rs b/src/main.rs index da53220..56faa36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -147,9 +147,7 @@ fn open_source(mgr: &mut DocumentManager, args: Args) { exec( mgr, &format!("Select '{session_name}'"), - SessionCommand::SelectSession { - name: session_name.clone(), - }, + SessionCommand::SelectSession { name: session_name }, ); exec( mgr, @@ -178,9 +176,7 @@ fn open_source(mgr: &mut DocumentManager, args: Args) { exec( mgr, &format!("Select '{session_name}'"), - SessionCommand::SelectSession { - name: session_name.clone(), - }, + SessionCommand::SelectSession { name: session_name }, ); exec( mgr, From 93fc1bf8290d9f06ccedadaa8ca849abfe37abab Mon Sep 17 00:00:00 2001 From: wfx Date: Tue, 10 Mar 2026 22:10:54 +0100 Subject: [PATCH 05/10] fix: mutable is also borrowed as immutable --- src/document/manager.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/document/manager.rs b/src/document/manager.rs index ae6d4d2..08941b1 100644 --- a/src/document/manager.rs +++ b/src/document/manager.rs @@ -152,8 +152,8 @@ impl DocumentManager { for session in &mut loaded { match session.kind { CollectionKind::DirectoryBrowser => { - if let Some(ref dir) = session.path { - scan_directory_into(session, dir); + if let Some(dir) = session.path.clone() { + scan_directory_into(session, &dir); } } CollectionKind::DocumentCollection => { @@ -307,7 +307,7 @@ impl DocumentManager { } fn paste_document(&mut self) { - let Some(ref item) = self.clipboard else { + let Some(item) = self.clipboard.clone() else { return; }; @@ -331,7 +331,7 @@ impl DocumentManager { return; } - session.items.push(item.clone()); + session.items.push(item); session.items.len() - 1 }; From 4c1af08fb721e36347494a2fb4fc4d20f979d8c8 Mon Sep 17 00:00:00 2001 From: mow Date: Thu, 12 Mar 2026 16:53:29 +0100 Subject: [PATCH 06/10] refactor(session): rethink and so ... remove useless close_session command --- src/document/manager.rs | 42 +++++++++++---------------------- src/document/session/command.rs | 2 -- src/main.rs | 11 --------- 3 files changed, 14 insertions(+), 41 deletions(-) diff --git a/src/document/manager.rs b/src/document/manager.rs index 08941b1..4699438 100644 --- a/src/document/manager.rs +++ b/src/document/manager.rs @@ -63,7 +63,6 @@ impl DocumentManager { // Session lifecycle SessionCommand::NewSession { name } => self.new_session(&name), SessionCommand::OpenSession { path } => self.open_session_file(&path), - SessionCommand::CloseSession => self.close_session(), SessionCommand::DeleteSession => self.delete_session(), SessionCommand::SaveSession => self.save_session(), SessionCommand::SelectSession { name } => self.select_session(&name), @@ -150,27 +149,18 @@ impl DocumentManager { match store::load_sessions(path) { Ok(mut loaded) => { for session in &mut loaded { - match session.kind { - CollectionKind::DirectoryBrowser => { - if let Some(dir) = session.path.clone() { - scan_directory_into(session, &dir); - } + session.items.retain(|item| { + if item.path.exists() { + true + } else { + warn!( + "Session '{}': {}", + session.name, + Error::NotFound(item.path.display().to_string()) + ); + false } - CollectionKind::DocumentCollection => { - session.items.retain(|item| { - if item.path.exists() { - true - } else { - warn!( - "Session '{}': {}", - session.name, - Error::NotFound(item.path.display().to_string()) - ); - false - } - }); - } - } + }); } let browser_index = loaded @@ -189,13 +179,9 @@ impl DocumentManager { } } - fn close_session(&mut self) { - // Close session only removes it from runtime, keeps it in .ron file - // For actual deletion, use delete_session() - // Currently acts same as delete for backwards compatibility - // TODO: Implement proper close behavior (hide from UI but keep in .ron) - self.delete_session(); - } + // We have no 'Close session' it wouldbe only removes it from runtime, keeps it in .ron file + // For actual deletion, use delete_session() + // So 'close_session' from ui view is save_session and close session view. fn delete_session(&mut self) { let Some(idx) = self.active_session_index else { diff --git a/src/document/session/command.rs b/src/document/session/command.rs index da87086..4b74877 100644 --- a/src/document/session/command.rs +++ b/src/document/session/command.rs @@ -24,8 +24,6 @@ pub enum SessionCommand { NewSession { name: String }, /// Load sessions from an existing .ron file. OpenSession { path: PathBuf }, - /// Close the active session (removes from runtime, kept in .ron file). - CloseSession, /// Persist all sessions to the .ron file. SaveSession, /// Set the active session cursor by name. diff --git a/src/main.rs b/src/main.rs index 56faa36..f61905a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -338,17 +338,6 @@ fn run_session_test(mgr: &mut DocumentManager, active_name: &str) { }; println!(" {marker} {}", session.name); } - - exec( - mgr, - "CloseSession (closes 'Archive')", - SessionCommand::CloseSession, - ); - - step("All sessions after close:"); - for session in mgr.sessions() { - println!(" {DIM}-{RESET} {}", session.name); - } } fn run_cli(args: Args) { From ca6f8f788f2e9150185c7960a64a7f30de2db32e Mon Sep 17 00:00:00 2001 From: mow Date: Fri, 13 Mar 2026 17:22:44 +0100 Subject: [PATCH 07/10] refactor: hopefully a better log --- src/document/manager.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/document/manager.rs b/src/document/manager.rs index 4699438..19de638 100644 --- a/src/document/manager.rs +++ b/src/document/manager.rs @@ -9,7 +9,6 @@ use crate::document::session::data::{CollectionKind, SessionData, SessionItem}; use crate::document::session::store; use crate::document::DocumentContent; use crate::error::Error; -use log::warn; use std::path::{Path, PathBuf}; /// The load state of the currently viewed document. @@ -153,7 +152,7 @@ impl DocumentManager { if item.path.exists() { true } else { - warn!( + log::error!( "Session '{}': {}", session.name, Error::NotFound(item.path.display().to_string()) From 79cc199d11e2c8b8152336a41db51b36da53614b Mon Sep 17 00:00:00 2001 From: mow Date: Sun, 15 Mar 2026 12:25:39 +0100 Subject: [PATCH 08/10] refactor: workspace/session/item ... ja ja, i know but now it is nailed. --- src/active_state.rs | 165 +++++++++++++++++++ src/patch.rs | 338 +++++++++++++++++++++++++++++++++++++++ src/workspace/manager.rs | 174 ++++++++++++++++++++ src/workspace/mod.rs | 7 + src/workspace/model.rs | 100 ++++++++++++ test.json | 94 +++++++++++ 6 files changed, 878 insertions(+) create mode 100644 src/active_state.rs create mode 100644 src/patch.rs create mode 100644 src/workspace/manager.rs create mode 100644 src/workspace/mod.rs create mode 100644 src/workspace/model.rs create mode 100644 test.json diff --git a/src/active_state.rs b/src/active_state.rs new file mode 100644 index 0000000..6525747 --- /dev/null +++ b/src/active_state.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/active_state.rs +// +// Pure navigation state: tracks which session and item are currently active. + +use anyhow::{Context, Result}; +use uuid::Uuid; + +use crate::workspace::model::{Item, Session, Workspace}; + +/// Tracks the active session and item selection. +/// +/// `ActiveState` does not own or store a `Workspace`. All methods that need +/// workspace data receive it as a parameter. This keeps navigation state +/// cleanly separated from domain data. +#[derive(Debug, Clone)] +pub struct ActiveState { + active_session: Option, + active_item: Option, +} + +impl Default for ActiveState { + fn default() -> Self { + Self::new() + } +} + +impl ActiveState { + #[must_use] + pub fn new() -> Self { + Self { + active_session: None, + active_item: None, + } + } + + /// Clear all navigation state. + pub fn reset(&mut self) { + self.active_session = None; + self.active_item = None; + } + + pub fn select_session(&mut self, workspace: &Workspace, name: &str) -> Result<()> { + let session = workspace.session_by_name(name)?; + if self.active_session == Some(session.id) { + return Ok(()); + } + self.active_session = Some(session.id); + self.active_item = if session.items.is_empty() { + None + } else { + Some(0) + }; + Ok(()) + } + + pub fn select_session_by_id(&mut self, workspace: &Workspace, id: Uuid) -> Result<()> { + if self.active_session == Some(id) { + return Ok(()); + } + let session = workspace.session(id)?; + self.active_session = Some(session.id); + self.active_item = if session.items.is_empty() { + None + } else { + Some(0) + }; + Ok(()) + } + + pub fn clear_session(&mut self) { + self.active_session = None; + self.active_item = None; + } + + pub fn select_item(&mut self, workspace: &Workspace, index: usize) -> Result<()> { + let session = self.require_active_session(workspace)?; + if index >= session.items.len() { + anyhow::bail!( + "Item index {index} out of range (session {:?} has {} items)", + session.name, + session.items.len() + ); + } + self.active_item = Some(index); + Ok(()) + } + + pub fn next_item(&mut self, workspace: &Workspace) { + let count = self.item_count(workspace); + if let Some(idx) = self.active_item { + if idx + 1 < count { + self.active_item = Some(idx + 1); + } + } + } + + pub fn previous_item(&mut self) { + if let Some(idx) = self.active_item { + if idx > 0 { + self.active_item = Some(idx - 1); + } + } + } + + /// Adjust the item cursor after an item was removed from the active session. + pub fn adjust_after_remove(&mut self, workspace: &Workspace) { + let count = self.item_count(workspace); + self.active_item = if count == 0 { + None + } else { + Some(self.active_item.unwrap_or(0).min(count - 1)) + }; + } + + /// Point the item cursor to the last item in the active session. + pub fn select_last_item(&mut self, workspace: &Workspace) { + let count = self.item_count(workspace); + self.active_item = if count == 0 { None } else { Some(count - 1) }; + } + + // -- Read-only accessors -- + + #[must_use] + pub fn active_session_id(&self) -> Option { + self.active_session + } + + #[must_use] + pub fn active_item_index(&self) -> Option { + self.active_item + } + + /// Returns the active session from the workspace. + #[must_use] + pub fn active_session<'a>(&self, workspace: &'a Workspace) -> Option<&'a Session> { + let id = self.active_session?; + workspace.session(id).ok() + } + + /// Returns the active item from the active session. + #[must_use] + pub fn active_item<'a>(&self, workspace: &'a Workspace) -> Option<&'a Item> { + let session = self.active_session(workspace)?; + let idx = self.active_item?; + session.items.get(idx) + } + + /// Returns the UUID of the active item. + #[must_use] + pub fn active_item_id(&self, workspace: &Workspace) -> Option { + self.active_item(workspace).map(|i| i.id) + } + + // Private helpers + + fn require_active_session<'a>(&self, workspace: &'a Workspace) -> Result<&'a Session> { + let id = self.active_session.context("No active session")?; + workspace.session(id) + } + + fn item_count(&self, workspace: &Workspace) -> usize { + self.active_session(workspace).map_or(0, |s| s.items.len()) + } +} diff --git a/src/patch.rs b/src/patch.rs new file mode 100644 index 0000000..2e44fcd --- /dev/null +++ b/src/patch.rs @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/patch.rs +// +// Patch file executor for scripted workspace operations and testing. + +use crate::active_state::ActiveState; +use crate::workspace::model::{Item, ItemKind, Session, SessionType, Workspace}; +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +/// A single step in a patch file. Each variant maps directly to a JSON key. +#[derive(Debug, Deserialize)] +pub enum PatchStep { + NewWorkspace(String), + LoadWorkspace(String), + SaveWorkspace(String), + + CreateSession { + name: String, + session_type: String, + }, + DeleteSession(String), + CopySession(String), + RenameSession { + session: String, + name: String, + }, + + AddItem { + session: String, + name: String, + kind: String, + path: String, + }, + RemoveItem { + session: String, + item: String, + }, + RenameItem { + session: String, + item: String, + name: String, + }, + CopyItem { + from: String, + item: String, + to: String, + }, + MoveItem { + from: String, + item: String, + to: String, + }, + + SelectSession(String), + SelectItem(usize), + NextItem, + PreviousItem, + MoveItemUp, + MoveItemDown, + MoveItemToIndex(usize), + + ViewWorkspace, + ViewSession(String), +} + +pub fn load(path: &Path) -> Result> { + let content = std::fs::read_to_string(path).context("Failed to read patch file")?; + serde_json::from_str(&content).context("Failed to parse patch file") +} + +pub fn run(path: &Path) { + let steps = match load(path) { + Ok(s) => s, + Err(e) => { + eprintln!("patch: {e}"); + return; + } + }; + + let total = steps.len(); + let width = total.to_string().len(); + + println!("patch: '{}' ({total} steps)\n", path.display()); + + let mut ws = Workspace::new("untitled"); + let mut active = ActiveState::new(); + + for (i, step) in steps.into_iter().enumerate() { + let n = i + 1; + print!("[{n:>width$}/{total}] "); + if let Err(e) = execute(step, &mut ws, &mut active) { + println!("[ERR] {e}"); + } + } + + println!("\npatch: done."); +} + +fn execute(step: PatchStep, ws: &mut Workspace, active: &mut ActiveState) -> Result<()> { + match step { + PatchStep::NewWorkspace(name) => { + println!("New Workspace {name:?}"); + *ws = Workspace::new(&name); + active.reset(); + } + PatchStep::LoadWorkspace(path) => { + *ws = Workspace::load(&path)?; + active.reset(); + println!("Load Workspace {path:?} ({} sessions)", ws.sessions.len()); + } + PatchStep::SaveWorkspace(path) => { + ws.save(&path)?; + println!("Save Workspace {path:?}"); + } + + PatchStep::CreateSession { name, session_type } => { + let st = parse_session_type(&session_type)?; + let session = Session::new(&name, st); + let id = session.id; + ws.add_session(session); + active.select_session_by_id(ws, id)?; + println!("Create Session {name:?}"); + } + PatchStep::DeleteSession(name) => { + let id = ws.session_by_name(&name)?.id; + ws.remove_session(id)?; + if active.active_session_id() == Some(id) { + active.clear_session(); + } + println!("Delete Session {name:?}"); + } + PatchStep::CopySession(name) => { + let id = ws.session_by_name(&name)?.id; + let new_id = ws.copy_session(id)?; + let new_name = ws.session(new_id)?.name.clone(); + active.select_session_by_id(ws, new_id)?; + println!("Copy Session {name:?} -> {new_name:?}"); + } + PatchStep::RenameSession { session, name } => { + let id = ws.session_by_name(&session)?.id; + ws.rename_session(id, &name)?; + println!("Rename Session {session:?} -> {name:?}"); + } + + PatchStep::AddItem { + session, + name, + kind, + path, + } => { + let sid = ws.session_by_name(&session)?.id; + let item = Item::new(&name, parse_item_kind(&kind, &path)?); + ws.add_item(sid, item)?; + if active.active_session_id() == Some(sid) { + active.select_last_item(ws); + } + println!("Add Item {name:?} to {session:?}"); + } + PatchStep::RemoveItem { session, item } => { + let sid = ws.session_by_name(&session)?.id; + let iid = ws.item_by_name(sid, &item)?.id; + ws.remove_item(sid, iid)?; + if active.active_session_id() == Some(sid) { + active.adjust_after_remove(ws); + } + println!("Remove Item {item:?} from {session:?}"); + } + PatchStep::RenameItem { + session, + item, + name, + } => { + let sid = ws.session_by_name(&session)?.id; + let iid = ws.item_by_name(sid, &item)?.id; + ws.rename_item(sid, iid, &name)?; + println!("Rename Item {item:?} -> {name:?}"); + } + PatchStep::CopyItem { from, item, to } => { + let from_id = ws.session_by_name(&from)?.id; + let iid = ws.item_by_name(from_id, &item)?.id; + let to_id = ws.session_by_name(&to)?.id; + ws.copy_item(from_id, iid, to_id, usize::MAX)?; + println!("Copy Item {item:?}: {from:?} -> {to:?}"); + } + PatchStep::MoveItem { from, item, to } => { + let from_id = ws.session_by_name(&from)?.id; + let iid = ws.item_by_name(from_id, &item)?.id; + let to_id = ws.session_by_name(&to)?.id; + ws.move_item(from_id, iid, to_id, usize::MAX)?; + if active.active_session_id() == Some(from_id) { + active.adjust_after_remove(ws); + } + println!("Move Item {item:?}: {from:?} -> {to:?}"); + } + + PatchStep::SelectSession(name) => { + active.select_session(ws, &name)?; + println!("Select Session {name:?}"); + print_context(ws, active); + } + PatchStep::SelectItem(index) => { + active.select_item(ws, index)?; + println!("Select Item [{index}]"); + print_context(ws, active); + } + PatchStep::NextItem => { + active.next_item(ws); + println!("Next Item"); + print_context(ws, active); + } + PatchStep::PreviousItem => { + active.previous_item(); + println!("Previous Item"); + print_context(ws, active); + } + PatchStep::MoveItemUp => { + let sid = active.active_session_id().context("No active session")?; + let idx = active.active_item_index().context("No active item")?; + if idx == 0 { + bail!("Already at top") + } + let iid = active.active_item_id(ws).context("No active item")?; + ws.move_item(sid, iid, sid, idx - 1)?; + active.select_item(ws, idx - 1)?; + println!("Move Item Up"); + print_context(ws, active); + } + PatchStep::MoveItemDown => { + let sid = active.active_session_id().context("No active session")?; + let idx = active.active_item_index().context("No active item")?; + let count = active.active_session(ws).map_or(0, |s| s.items.len()); + if idx + 1 >= count { + bail!("Already at bottom") + } + let iid = active.active_item_id(ws).context("No active item")?; + ws.move_item(sid, iid, sid, idx + 1)?; + active.select_item(ws, idx + 1)?; + println!("Move Item Down"); + print_context(ws, active); + } + PatchStep::MoveItemToIndex(target) => { + let sid = active.active_session_id().context("No active session")?; + let iid = active.active_item_id(ws).context("No active item")?; + ws.move_item(sid, iid, sid, target)?; + let count = active.active_session(ws).map_or(0, |s| s.items.len()); + active.select_item(ws, target.min(count.saturating_sub(1)))?; + println!("Move Item To [{target}]"); + print_context(ws, active); + } + + PatchStep::ViewWorkspace => { + println!( + "View Workspace {:?} {} sessions", + ws.name, + ws.sessions.len() + ); + for s in &ws.sessions { + let m = if active.active_session_id() == Some(s.id) { + "=>" + } else { + " " + }; + println!( + " {m} {:?} ({:?}) {} items", + s.name, + s.session_type, + s.items.len() + ); + } + } + PatchStep::ViewSession(name) => { + let session = ws.session_by_name(&name)?; + println!( + "View Session {:?} ({:?}) {} items", + session.name, + session.session_type, + session.items.len() + ); + for (i, item) in session.items.iter().enumerate() { + let m = if active.active_session_id() == Some(session.id) + && active.active_item_index() == Some(i) + { + "=>" + } else { + " " + }; + let kind = match &item.kind { + ItemKind::Portable { .. } => "portable", + ItemKind::Raster { .. } => "raster", + ItemKind::Vector { .. } => "vector", + ItemKind::Document { .. } => "document", + }; + println!(" {m} [{i}] {:?} ({kind})", item.name); + } + } + } + Ok(()) +} + +fn print_context(ws: &Workspace, active: &ActiveState) { + let Some(session) = active.active_session(ws) else { + return; + }; + let item_info = match active.active_item(ws) { + Some(item) => format!( + "[{}] {:?}", + active.active_item_index().unwrap_or(0), + item.name + ), + None => "-".into(), + }; + println!( + " {:?} {} items active: {item_info}", + session.name, + session.items.len() + ); +} + +fn parse_session_type(s: &str) -> Result { + match s.to_lowercase().as_str() { + "browser" => Ok(SessionType::Browser), + "collection" => Ok(SessionType::Collection), + _ => bail!("Unknown session type: {s:?}"), + } +} + +fn parse_item_kind(kind: &str, path: &str) -> Result { + let path = PathBuf::from(path); + match kind.to_lowercase().as_str() { + "portable" | "pdf" => Ok(ItemKind::Portable { path }), + "raster" | "image" => Ok(ItemKind::Raster { path }), + "vector" | "svg" => Ok(ItemKind::Vector { path }), + "document" => Ok(ItemKind::Document { path }), + _ => bail!("Unknown item kind: {kind:?}"), + } +} diff --git a/src/workspace/manager.rs b/src/workspace/manager.rs new file mode 100644 index 0000000..3b17348 --- /dev/null +++ b/src/workspace/manager.rs @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/workspace/manager.rs +// +// Workspace operations: session and item CRUD, reordering, and RON persistence. + +use crate::workspace::model::{Item, Session, Workspace}; +use anyhow::{Context, Result}; +use std::path::Path; +use uuid::Uuid; + +impl Workspace { + /// Serialize the workspace to a RON file. + pub fn save(&self, path: impl AsRef) -> Result<()> { + let config = ron::ser::PrettyConfig::default(); + let data = + ron::ser::to_string_pretty(self, config).context("Failed to serialize workspace")?; + std::fs::write(&path, data).context("Failed to write workspace file")?; + Ok(()) + } + + /// Deserialize a workspace from a RON file. + pub fn load(path: impl AsRef) -> Result { + let data = std::fs::read_to_string(&path).context("Failed to read workspace file")?; + let workspace: Workspace = + ron::from_str(&data).context("Failed to deserialize workspace")?; + Ok(workspace) + } + + pub fn add_session(&mut self, session: Session) { + self.sessions.push(session); + } + + pub fn remove_session(&mut self, session_id: Uuid) -> Result { + let pos = self.session_pos(session_id)?; + Ok(self.sessions.remove(pos)) + } + + pub fn copy_session(&mut self, session_id: Uuid) -> Result { + let pos = self.session_pos(session_id)?; + let mut clone = self.sessions[pos].clone(); + clone.id = Uuid::new_v4(); + clone.name = format!("{} (copy)", clone.name); + for item in &mut clone.items { + item.id = Uuid::new_v4(); + } + let new_id = clone.id; + self.sessions.push(clone); + Ok(new_id) + } + + pub fn move_session(&mut self, session_id: Uuid, new_index: usize) -> Result<()> { + let pos = self.session_pos(session_id)?; + let session = self.sessions.remove(pos); + let insert_at = new_index.min(self.sessions.len()); + self.sessions.insert(insert_at, session); + Ok(()) + } + + pub fn rename_session(&mut self, session_id: Uuid, new_name: impl Into) -> Result<()> { + let session = self.session_mut(session_id)?; + session.name = new_name.into(); + Ok(()) + } + + pub fn add_item(&mut self, session_id: Uuid, item: Item) -> Result<()> { + let session = self.session_mut(session_id)?; + session.items.push(item); + Ok(()) + } + + pub fn remove_item(&mut self, session_id: Uuid, item_id: Uuid) -> Result { + let session = self.session_mut(session_id)?; + let pos = item_pos(&session.items, item_id)?; + Ok(session.items.remove(pos)) + } + + /// Move an item within the same session or between two sessions. + pub fn move_item( + &mut self, + from_session: Uuid, + item_id: Uuid, + to_session: Uuid, + to_index: usize, + ) -> Result<()> { + let item = self.remove_item(from_session, item_id)?; + let target = self.session_mut(to_session)?; + let insert_at = to_index.min(target.items.len()); + target.items.insert(insert_at, item); + Ok(()) + } + + /// Copy an item within the same session or between two sessions. + pub fn copy_item( + &mut self, + from_session: Uuid, + item_id: Uuid, + to_session: Uuid, + to_index: usize, + ) -> Result { + let source = self.session(from_session)?; + let pos = item_pos(&source.items, item_id)?; + let mut clone = source.items[pos].clone(); + clone.id = Uuid::new_v4(); + clone.name = format!("{} (copy)", clone.name); + let new_id = clone.id; + + let target = self.session_mut(to_session)?; + let insert_at = to_index.min(target.items.len()); + target.items.insert(insert_at, clone); + Ok(new_id) + } + + pub fn rename_item( + &mut self, + session_id: Uuid, + item_id: Uuid, + new_name: impl Into, + ) -> Result<()> { + let session = self.session_mut(session_id)?; + let pos = item_pos(&session.items, item_id)?; + session.items[pos].name = new_name.into(); + Ok(()) + } + + /// Read-only access to a session by id. + pub fn session(&self, id: Uuid) -> Result<&Session> { + self.sessions + .iter() + .find(|s| s.id == id) + .context(format!("Session {id} not found")) + } + + /// Read-only access to a session by name. + pub fn session_by_name(&self, name: &str) -> Result<&Session> { + self.sessions + .iter() + .find(|s| s.name == name) + .context(format!("Session {name:?} not found")) + } + + /// Find an item by name within a session. + pub fn item_by_name(&self, session_id: Uuid, name: &str) -> Result<&Item> { + let session = self.session(session_id)?; + session + .items + .iter() + .find(|i| i.name == name) + .context(format!( + "Item {name:?} not found in session {:?}", + session.name + )) + } + + fn session_mut(&mut self, id: Uuid) -> Result<&mut Session> { + self.sessions + .iter_mut() + .find(|s| s.id == id) + .context(format!("Session {id} not found")) + } + + fn session_pos(&self, id: Uuid) -> Result { + self.sessions + .iter() + .position(|s| s.id == id) + .context(format!("Session {id} not found")) + } +} + +fn item_pos(items: &[Item], id: Uuid) -> Result { + items + .iter() + .position(|i| i.id == id) + .context(format!("Item {id} not found")) +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 0000000..330ed70 --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/workspace/mod.rs +// +// Workspace subsystem: data model, operations, and persistence. + +pub mod manager; +pub mod model; diff --git a/src/workspace/model.rs b/src/workspace/model.rs new file mode 100644 index 0000000..4d0e008 --- /dev/null +++ b/src/workspace/model.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/workspace/model.rs +// +// Pure data structures for the workspace, sessions, and their items. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use uuid::Uuid; + +/// The document kind an item references. +/// +/// Each variant carries the path to the source file. Format-specific fields +/// (e.g. page index for PDFs) live on the variant so they can differ per kind. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ItemKind { + Portable { path: PathBuf }, + Raster { path: PathBuf }, + Vector { path: PathBuf }, + Document { path: PathBuf }, +} + +impl ItemKind { + /// Returns the file path regardless of the variant. + pub fn path(&self) -> &PathBuf { + match self { + ItemKind::Portable { path } + | ItemKind::Raster { path } + | ItemKind::Vector { path } + | ItemKind::Document { path } => path, + } + } +} + +/// A single entry in a session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Item { + pub id: Uuid, + pub name: String, + pub kind: ItemKind, + pub tags: Vec, +} + +impl Item { + pub fn new(name: impl Into, kind: ItemKind) -> Self { + Self { + id: Uuid::new_v4(), + name: name.into(), + kind, + tags: Vec::new(), + } + } + + pub fn with_tags(mut self, tags: Vec) -> Self { + self.tags = tags; + self + } +} + +/// Determines how a session behaves in the UI. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SessionType { + Browser, + Collection, +} + +/// A named group of items inside a workspace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: Uuid, + pub name: String, + pub session_type: SessionType, + pub items: Vec, +} + +impl Session { + pub fn new(name: impl Into, session_type: SessionType) -> Self { + Self { + id: Uuid::new_v4(), + name: name.into(), + session_type, + items: Vec::new(), + } + } +} + +/// The top-level container that is persisted as a `.ws.ron` file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + pub name: String, + pub sessions: Vec, +} + +impl Workspace { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + sessions: Vec::new(), + } + } +} diff --git a/test.json b/test.json new file mode 100644 index 0000000..4dd1247 --- /dev/null +++ b/test.json @@ -0,0 +1,94 @@ +[ + { "NewWorkspace": "Demo Projekt" }, + + { "CreateSession": { "name": "Referenzen", "session_type": "browser" } }, + { + "AddItem": { + "session": "Referenzen", + "name": "Logo", + "kind": "vector", + "path": "tmp/pictures/logo.svg" + } + }, + { + "AddItem": { + "session": "Referenzen", + "name": "Foto Hintergrund", + "kind": "raster", + "path": "tmp/pictures/hero.jpg" + } + }, + { + "AddItem": { + "session": "Referenzen", + "name": "Handbuch", + "kind": "portable", + "path": "tmp/pictures/handbook.pdf" + } + }, + { "ViewSession": "Referenzen" }, + + { "SelectSession": "Referenzen" }, + { "SelectItem": 0 }, + "NextItem", + "NextItem", + "PreviousItem", + { "SelectItem": 0 }, + "MoveItemDown", + { "ViewSession": "Referenzen" }, + "MoveItemUp", + { "ViewSession": "Referenzen" }, + { "MoveItemToIndex": 2 }, + { "ViewSession": "Referenzen" }, + { "MoveItemToIndex": 0 }, + + { "CreateSession": { "name": "Favoriten", "session_type": "collection" } }, + { "CreateSession": { "name": "Archiv", "session_type": "collection" } }, + "ViewWorkspace", + + { "CopyItem": { "from": "Referenzen", "item": "Logo", "to": "Favoriten" } }, + { + "CopyItem": { + "from": "Referenzen", + "item": "Foto Hintergrund", + "to": "Favoriten" + } + }, + { "ViewSession": "Favoriten" }, + + { + "RenameItem": { + "session": "Favoriten", + "item": "Logo (copy)", + "name": "Hauptlogo" + } + }, + { "ViewSession": "Favoriten" }, + + { + "RemoveItem": { "session": "Favoriten", "item": "Foto Hintergrund (copy)" } + }, + { "ViewSession": "Favoriten" }, + + { "MoveItem": { "from": "Referenzen", "item": "Handbuch", "to": "Archiv" } }, + { "ViewSession": "Referenzen" }, + { "ViewSession": "Archiv" }, + + { "CopySession": "Referenzen" }, + "ViewWorkspace", + + { + "RenameSession": { + "session": "Referenzen (copy)", + "name": "Backup Referenzen" + } + }, + "ViewWorkspace", + + { "DeleteSession": "Archiv" }, + "ViewWorkspace", + + { "SaveWorkspace": "tmp/demo.ws.ron" }, + { "LoadWorkspace": "tmp/demo.ws.ron" }, + "ViewWorkspace" +] From 42b03acd62545fe2b2e92546214b1d957dcfbd16 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Thu, 28 May 2026 17:43:36 +0200 Subject: [PATCH 09/10] Integrate Noctua with COSMIC image and PDF viewing --- Cargo.toml | 6 +- resources/org.codeberg.wfx.Noctua.desktop | 4 +- .../org.codeberg.wfx.Noctua.metainfo.xml | 4 + src/main.rs | 837 +++++++++++------- 4 files changed, 521 insertions(+), 330 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d771507..986a96f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,8 +58,10 @@ clap = { version = "4", features = ["derive"] } # Async runtime (required by libcosmic) tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } -[dependencies.libcosmic] -git = "https://github.com/pop-os/libcosmic.git" +[dependencies.cosmic] +package = "libcosmic-yoda" +path = "../libcosmic" +default-features = false features = [ "a11y", "about", diff --git a/resources/org.codeberg.wfx.Noctua.desktop b/resources/org.codeberg.wfx.Noctua.desktop index 4cbb4b8..9bb2d0a 100644 --- a/resources/org.codeberg.wfx.Noctua.desktop +++ b/resources/org.codeberg.wfx.Noctua.desktop @@ -3,10 +3,10 @@ Name=Noctua Comment=A wise document and image viewer for the COSMIC™ desktop Type=Application Icon=org.codeberg.wfx.Noctua -Exec=noctua %F +Exec=/usr/bin/noctua %F Terminal=false StartupNotify=true Categories=Graphics;Viewer;Utility; StartupWMClass=org.codeberg.wfx.Noctua Keywords=image;document;pdf;viewer;cosmic; -MimeType=image/png;image/jpeg;image/gif;image/webp;image/bmp;image/tiff;image/svg+xml;application/pdf; +MimeType=image/png;image/x-png;image/jpeg;image/jpg;image/pjpeg;image/gif;image/webp;image/bmp;image/x-bmp;image/tiff;image/svg+xml;application/pdf; diff --git a/resources/org.codeberg.wfx.Noctua.metainfo.xml b/resources/org.codeberg.wfx.Noctua.metainfo.xml index 8abdf03..ae24682 100644 --- a/resources/org.codeberg.wfx.Noctua.metainfo.xml +++ b/resources/org.codeberg.wfx.Noctua.metainfo.xml @@ -25,10 +25,14 @@ noctua image/png + image/x-png image/jpeg + image/jpg + image/pjpeg image/gif image/webp image/bmp + image/x-bmp image/tiff image/svg+xml application/pdf diff --git a/src/main.rs b/src/main.rs index f61905a..dfe823f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,370 +1,555 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/main.rs // -// Entry point for the Noctua COSMIC application. - -mod document; -mod error; -mod render; +// Minimal COSMIC viewer entry point for images and PDFs. use clap::Parser; -use document::manager::{DocumentManager, DocumentState}; -use document::session::command::SessionCommand; -use document::session::data::{CollectionKind, SessionData}; +use cosmic::app::{Core, Settings, Task}; +use cosmic::iced::{ + event, keyboard, mouse, Alignment, ContentFit, Event, Length, Limits, Subscription, +}; +use cosmic::{executor, theme, widget, Application, Element}; +use std::collections::hash_map::DefaultHasher; +use std::ffi::OsStr; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; - -// ANSI helpers - -const RESET: &str = "\x1b[0m"; -const BOLD: &str = "\x1b[1m"; -const DIM: &str = "\x1b[2m"; -const GREEN: &str = "\x1b[32m"; -const YELLOW: &str = "\x1b[33m"; -const RED: &str = "\x1b[31m"; -const CYAN: &str = "\x1b[36m"; -const MAGENTA: &str = "\x1b[35m"; - -fn heading(text: &str) { - println!("\n{BOLD}{CYAN}── {text} ──{RESET}"); -} - -fn step(text: &str) { - println!(" {DIM}>{RESET} {text}"); -} - -fn ok(text: &str) { - println!(" {GREEN}Ok{RESET} {text}"); -} - -fn err(text: &str) { - eprintln!(" {RED}X{RESET} {text}"); -} - -fn info(text: &str) { - println!(" {YELLOW}i{RESET} {text}"); -} - -fn print_session(session: &SessionData, active_doc_index: Option) { - let kind_label = match session.kind { - CollectionKind::DirectoryBrowser => "DirectoryBrowser", - CollectionKind::DocumentCollection => "DocumentCollection", - }; - println!( - " {MAGENTA}Session: {BOLD}{}{RESET} {DIM}({kind_label}, {} items, active: {:?}){RESET}", - session.name, - session.items.len(), - active_doc_index, - ); - for (i, item) in session.items.iter().enumerate() { - let marker = if active_doc_index == Some(i) { - format!("{GREEN}=>{RESET}") - } else { - format!("{DIM} {RESET}") - }; - let name = item - .path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("?"); - println!( - " {marker} {DIM}[{i}]{RESET} {name} {DIM}(page {}){RESET}", - item.page_index - ); - } -} - -fn exec(manager: &mut DocumentManager, label: &str, command: SessionCommand) { - step(label); - manager.handle(command); - if let DocumentState::Error(e) = &manager.state { - err(&format!("{e}")); - manager.state = DocumentState::Empty; - } -} - -fn print_active_session(mgr: &DocumentManager) { - if let Some(session) = mgr.active_session() { - print_session(session, mgr.active_document_index()); - } else { - info("No active session"); - } -} +use std::process::Command; #[derive(Parser, Debug)] -#[command(name = "noctua", about = "A document viewer for the COSMIC desktop")] +#[command( + name = "noctua", + version, + about = "A document and image viewer for COSMIC" +)] struct Args { - /// Open a document (raster, vector, portable) + /// Files to open. + #[arg(value_name = "FILE")] + files: Vec, + + /// Compatibility with the previous CLI harness. #[arg(long = "open-document", value_name = "PATH")] document: Option, - /// Open a directory + /// Open all supported documents in a directory. #[arg(long = "open-directory", value_name = "PATH")] directory: Option, - - /// Open a session file (.ron) - #[arg(long = "open-session", value_name = "PATH")] - session: Option, } -fn main() { - env_logger::init(); +#[derive(Clone, Debug, Eq, PartialEq)] +enum DocumentKind { + Raster, + Svg, + Pdf, +} + +#[derive(Clone, Debug)] +struct Document { + path: PathBuf, + kind: DocumentKind, + page: usize, + page_count: Option, + rendered_page: Option, + error: Option, +} + +impl Document { + fn new(path: PathBuf, kind: DocumentKind) -> Self { + let page_count = (kind == DocumentKind::Pdf) + .then(|| pdf_page_count(&path)) + .flatten(); + + Self { + path, + kind, + page: 0, + page_count, + rendered_page: None, + error: None, + } + } + + fn name(&self) -> String { + self.path + .file_name() + .and_then(OsStr::to_str) + .unwrap_or("Noctua") + .to_string() + } +} + +#[derive(Clone, Debug)] +struct Flags { + documents: Vec, + initial_index: usize, +} + +#[derive(Debug, Clone)] +enum Message { + PreviousDocument, + NextDocument, + PreviousPage, + NextPage, + ZoomIn, + ZoomOut, + ResetZoom, + ModifiersChanged(keyboard::Modifiers), + ScrollZoom(f32), +} + +struct NoctuaApp { + core: Core, + documents: Vec, + current: usize, + zoom: f32, + modifiers: keyboard::Modifiers, +} + +fn main() -> cosmic::iced::Result { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); let args = Args::parse(); + let flags = Flags::from_args(args); - if args.document.is_none() && args.directory.is_none() && args.session.is_none() { - // TODO: Launch COSMIC UI. - return; - } + let settings = Settings::default().size_limits(Limits::NONE.min_width(480.0).min_height(320.0)); - run_cli(args); + cosmic::app::run::(settings, flags) } -fn open_source(mgr: &mut DocumentManager, args: Args) { - if let Some(path) = args.session { - exec( - mgr, - "Open session file", - SessionCommand::OpenSession { path }, - ); - } else if let Some(path) = args.document { - let dir = path - .parent() - .map_or_else(|| PathBuf::from("."), Path::to_path_buf); +impl Flags { + fn from_args(args: Args) -> Self { + let mut requested = args.files; - let session_name = dir - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("Browse") - .to_string(); + if let Some(path) = args.document { + requested.push(path); + } - exec( - mgr, - &format!("New session '{session_name}'"), - SessionCommand::NewSession { - name: session_name.clone(), - }, - ); - exec( - mgr, - &format!("Select '{session_name}'"), - SessionCommand::SelectSession { name: session_name }, - ); - exec( - mgr, - &format!("Add directory {}", dir.display()), - SessionCommand::AddDirectoryToSession { dir }, - ); - exec( - mgr, - &format!("Add document {}", path.display()), - SessionCommand::AddDocumentToSession { path }, - ); - } else if let Some(path) = args.directory { - let session_name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("Browse") - .to_string(); + if let Some(path) = args.directory { + requested.push(path); + } - exec( - mgr, - &format!("New session '{session_name}'"), - SessionCommand::NewSession { - name: session_name.clone(), - }, - ); - exec( - mgr, - &format!("Select '{session_name}'"), - SessionCommand::SelectSession { name: session_name }, - ); - exec( - mgr, - &format!("Add directory {}", path.display()), - SessionCommand::AddDirectoryToSession { dir: path }, - ); + let (documents, initial_index) = collect_documents(&requested); + + Self { + documents, + initial_index, + } } } -fn run_navigation_test(mgr: &mut DocumentManager) { - heading("Navigation"); +impl Application for NoctuaApp { + type Executor = executor::Default; + type Flags = Flags; + type Message = Message; - exec(mgr, "NextDocument", SessionCommand::NextDocument); - ok(&format!( - "After NextDocument → index {:?}", - mgr.active_document_index() - )); + const APP_ID: &'static str = "org.codeberg.wfx.Noctua"; - exec(mgr, "PreviousDocument", SessionCommand::PreviousDocument); - ok(&format!( - "After PreviousDocument → index {:?}", - mgr.active_document_index() - )); - - exec( - mgr, - "SelectDocument { index: 0 }", - SessionCommand::SelectDocument { index: 0 }, - ); - ok(&format!( - "After SelectDocument(0) → index {:?}", - mgr.active_document_index() - )); - - heading("Move operations"); - - step("Before MoveDocumentDown:"); - print_active_session(mgr); - - exec(mgr, "MoveDocumentDown", SessionCommand::MoveDocumentDown); - ok(&format!( - "After MoveDocumentDown → index {:?}", - mgr.active_document_index() - )); - - exec(mgr, "MoveDocumentUp", SessionCommand::MoveDocumentUp); - ok(&format!( - "After MoveDocumentUp → index {:?}", - mgr.active_document_index() - )); -} - -fn run_copy_paste_test(mgr: &mut DocumentManager, active_name: &str) { - heading("Copy / Paste"); - - exec( - mgr, - "New session 'Favorites'", - SessionCommand::NewSession { - name: "Favorites".to_string(), - }, - ); - exec( - mgr, - "New session 'Workspace'", - SessionCommand::NewSession { - name: "Workspace".to_string(), - }, - ); - - step("Favorites before paste:"); - if let Some(session) = mgr.session_by_name("Favorites") { - print_session(session, None); + fn core(&self) -> &Core { + &self.core } - exec( - mgr, - &format!("SelectSession '{active_name}'"), - SessionCommand::SelectSession { - name: active_name.to_string(), - }, - ); - exec( - mgr, - "SelectDocument { index: 0 }", - SessionCommand::SelectDocument { index: 0 }, - ); - exec(mgr, "CopyDocument", SessionCommand::CopyDocument); - exec( - mgr, - "SelectSession 'Favorites'", - SessionCommand::SelectSession { - name: "Favorites".to_string(), - }, - ); - exec(mgr, "PasteDocument", SessionCommand::PasteDocument); - - step("Favorites after paste:"); - print_active_session(mgr); - - exec( - mgr, - "MoveDocumentTo { index: 0 }", - SessionCommand::MoveDocumentTo { index: 0 }, - ); - step("Favorites after MoveDocumentTo(0):"); - print_active_session(mgr); - - exec(mgr, "RemoveDocument", SessionCommand::RemoveDocument); - step("Favorites after RemoveDocument:"); - print_active_session(mgr); -} - -fn run_session_test(mgr: &mut DocumentManager, active_name: &str) { - heading("Session operations"); - - exec( - mgr, - &format!("SelectSession '{active_name}'"), - SessionCommand::SelectSession { - name: active_name.to_string(), - }, - ); - exec( - mgr, - "DuplicateSession 'Snapshot'", - SessionCommand::DuplicateSession { - new_name: "Snapshot".to_string(), - }, - ); - - step("Snapshot session:"); - if let Some(session) = mgr.session_by_name("Snapshot") { - print_session(session, None); + fn core_mut(&mut self) -> &mut Core { + &mut self.core } - exec( - mgr, - "SelectSession 'Workspace'", - SessionCommand::SelectSession { - name: "Workspace".to_string(), - }, - ); - exec( - mgr, - "RenameSession 'Archive'", - SessionCommand::RenameSession { - name: "Archive".to_string(), - }, - ); + fn init(mut core: Core, flags: Self::Flags) -> (Self, Task) { + core.window.content_container = false; - step("All sessions after rename:"); - for session in mgr.sessions() { - let is_active = mgr.active_session().is_some_and(|a| a.name == session.name); - let marker = if is_active { - format!("{GREEN}*{RESET}") - } else { - format!("{DIM} {RESET}") + let current = flags + .initial_index + .min(flags.documents.len().saturating_sub(1)); + + let mut app = Self { + core, + documents: flags.documents, + current, + zoom: 1.0, + modifiers: keyboard::Modifiers::default(), }; - println!(" {marker} {}", session.name); + + app.refresh_title(); + app.ensure_current_rendered(); + + (app, Task::none()) + } + + fn update(&mut self, message: Self::Message) -> Task { + match message { + Message::PreviousDocument => { + if self.current > 0 { + self.current -= 1; + self.zoom = 1.0; + } + } + Message::NextDocument => { + if self.current + 1 < self.documents.len() { + self.current += 1; + self.zoom = 1.0; + } + } + Message::PreviousPage => { + if let Some(document) = self.current_document_mut() { + if document.kind == DocumentKind::Pdf && document.page > 0 { + document.page -= 1; + document.rendered_page = None; + } + } + } + Message::NextPage => { + if let Some(document) = self.current_document_mut() { + if document.kind == DocumentKind::Pdf { + let page_count = document.page_count.unwrap_or(document.page + 2); + if document.page + 1 < page_count { + document.page += 1; + document.rendered_page = None; + } + } + } + } + Message::ZoomIn => { + self.zoom_in(); + } + Message::ZoomOut => { + self.zoom_out(); + } + Message::ResetZoom => { + self.zoom = 1.0; + } + Message::ModifiersChanged(modifiers) => { + self.modifiers = modifiers; + } + Message::ScrollZoom(delta) => { + if self.modifiers.control() { + if delta > 0.0 { + self.zoom_in(); + } else if delta < 0.0 { + self.zoom_out(); + } + } + } + } + + self.refresh_title(); + self.ensure_current_rendered(); + + Task::none() + } + + fn subscription(&self) -> Subscription { + event::listen_raw(|event, status, _| match event { + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { + Some(Message::ModifiersChanged(modifiers)) + } + Event::Mouse(mouse::Event::WheelScrolled { delta }) + if status == event::Status::Ignored => + { + scroll_delta_y(delta).map(Message::ScrollZoom) + } + _ => None, + }) + } + + fn view(&self) -> Element<'_, Self::Message> { + let content = match self.current_document() { + Some(document) => self.document_view(document), + None => empty_view("No document selected"), + }; + + widget::container(content) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .class(theme::Container::WindowBackground) + .into() + } + + fn footer(&self) -> Option> { + let Some(document) = self.current_document() else { + return None; + }; + + let has_previous_document = self.current > 0; + let has_next_document = self.current + 1 < self.documents.len(); + let has_previous_page = document.kind == DocumentKind::Pdf && document.page > 0; + let has_next_page = document.kind == DocumentKind::Pdf + && document + .page_count + .is_none_or(|page_count| document.page + 1 < page_count); + + let page_label = if document.kind == DocumentKind::Pdf { + match document.page_count { + Some(page_count) => format!("Page {} / {}", document.page + 1, page_count), + None => format!("Page {}", document.page + 1), + } + } else { + format!("{} / {}", self.current + 1, self.documents.len()) + }; + + let row = widget::row::with_capacity(9) + .align_y(Alignment::Center) + .spacing(8) + .padding(8) + .push( + widget::button::standard("Previous") + .on_press_maybe(has_previous_document.then_some(Message::PreviousDocument)), + ) + .push( + widget::button::standard("Next") + .on_press_maybe(has_next_document.then_some(Message::NextDocument)), + ) + .push(widget::space::horizontal().width(Length::Fill)) + .push( + widget::button::standard("-") + .on_press(Message::ZoomOut) + .width(Length::Fixed(36.0)), + ) + .push(widget::button::standard("100%").on_press(Message::ResetZoom)) + .push( + widget::button::standard("+") + .on_press(Message::ZoomIn) + .width(Length::Fixed(36.0)), + ) + .push(widget::space::horizontal().width(Length::Fill)) + .push(widget::text::body(page_label)) + .push( + widget::button::standard("Prev page") + .on_press_maybe(has_previous_page.then_some(Message::PreviousPage)), + ) + .push( + widget::button::standard("Next page") + .on_press_maybe(has_next_page.then_some(Message::NextPage)), + ); + + Some(row.into()) } } -fn run_cli(args: Args) { - let mut mgr = DocumentManager::new(); - - heading("Opening source"); - open_source(&mut mgr, args); - - heading("State after open"); - - match &mgr.state { - DocumentState::Empty => info("No document loaded"), - DocumentState::Loading => info("Loading…"), - DocumentState::Error(e) => err(&format!("{e}")), - DocumentState::Loaded(_) => ok("Document loaded"), +impl NoctuaApp { + fn zoom_in(&mut self) { + self.zoom = (self.zoom * 1.2).min(5.0); } - let Some(active_name) = mgr.active_session().map(|s| s.name.clone()) else { - info("No active session – nothing more to test."); - return; + fn zoom_out(&mut self) { + self.zoom = (self.zoom / 1.2).max(0.2); + } + + fn current_document(&self) -> Option<&Document> { + self.documents.get(self.current) + } + + fn current_document_mut(&mut self) -> Option<&mut Document> { + self.documents.get_mut(self.current) + } + + fn refresh_title(&mut self) { + let title = self + .current_document() + .map_or_else(|| "Noctua".to_string(), Document::name); + + self.core.set_header_title(title); + } + + fn ensure_current_rendered(&mut self) { + let Some(document) = self.current_document_mut() else { + return; + }; + + if document.kind != DocumentKind::Pdf || document.rendered_page.is_some() { + return; + } + + match render_pdf_page(&document.path, document.page) { + Ok(rendered) => { + document.rendered_page = Some(rendered); + document.error = None; + } + Err(err) => { + document.rendered_page = None; + document.error = Some(err); + } + } + } + + fn document_view(&self, document: &Document) -> Element<'_, Message> { + if let Some(error) = &document.error { + return empty_view(error); + } + + match document.kind { + DocumentKind::Raster => widget::image(widget::image::Handle::from_path(&document.path)) + .content_fit(ContentFit::Contain) + .width(Length::Fill) + .height(Length::Fill) + .scale(self.zoom) + .into(), + DocumentKind::Svg => widget::svg(widget::svg::Handle::from_path(&document.path)) + .content_fit(ContentFit::Contain) + .width(Length::Fill) + .height(Length::Fill) + .into(), + DocumentKind::Pdf => match &document.rendered_page { + Some(path) => widget::image(widget::image::Handle::from_path(path)) + .content_fit(ContentFit::Contain) + .width(Length::Fill) + .height(Length::Fill) + .scale(self.zoom) + .into(), + None => empty_view("Rendering PDF page..."), + }, + } + } +} + +fn empty_view<'a>(message: impl Into) -> Element<'a, Message> { + widget::column::with_capacity(3) + .align_x(Alignment::Center) + .spacing(8) + .push(widget::space::vertical()) + .push(widget::icon::from_name("image-x-generic-symbolic").size(64)) + .push(widget::text::body(message.into())) + .push(widget::space::vertical()) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn scroll_delta_y(delta: mouse::ScrollDelta) -> Option { + let y = match delta { + mouse::ScrollDelta::Lines { y, .. } => y, + mouse::ScrollDelta::Pixels { y, .. } => y / 60.0, }; - print_active_session(&mgr); - - run_navigation_test(&mut mgr); - run_copy_paste_test(&mut mgr, &active_name); - run_session_test(&mut mgr, &active_name); - - heading("Done"); + (y.abs() > f32::EPSILON).then_some(y) +} + +fn collect_documents(requested: &[PathBuf]) -> (Vec, usize) { + if requested.is_empty() { + return (Vec::new(), 0); + } + + if requested.len() == 1 { + let path = &requested[0]; + + if path.is_dir() { + return (scan_directory(path), 0); + } + + if let Some(parent) = path.parent() { + let documents = scan_directory(parent); + if let Some(index) = documents + .iter() + .position(|document| same_path(&document.path, path)) + { + return (documents, index); + } + } + } + + let documents = requested + .iter() + .flat_map(|path| { + if path.is_dir() { + scan_directory(path) + } else { + detect_kind(path) + .map(|kind| vec![Document::new(path.clone(), kind)]) + .unwrap_or_default() + } + }) + .collect(); + + (documents, 0) +} + +fn scan_directory(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + + let mut paths = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .filter_map(|path| detect_kind(&path).map(|kind| (path, kind))) + .collect::>(); + + paths.sort_by(|(left, _), (right, _)| left.cmp(right)); + + paths + .into_iter() + .map(|(path, kind)| Document::new(path, kind)) + .collect() +} + +fn detect_kind(path: &Path) -> Option { + match path + .extension() + .and_then(OsStr::to_str) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("pdf") => Some(DocumentKind::Pdf), + Some("svg") => Some(DocumentKind::Svg), + Some("png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tiff" | "tif") => { + Some(DocumentKind::Raster) + } + _ => None, + } +} + +fn same_path(left: &Path, right: &Path) -> bool { + left == right || left.canonicalize().ok().as_deref() == right.canonicalize().ok().as_deref() +} + +fn pdf_page_count(path: &Path) -> Option { + let output = Command::new("pdfinfo").arg(path).output().ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.lines().find_map(|line| { + line.strip_prefix("Pages:") + .and_then(|value| value.trim().parse::().ok()) + }) +} + +fn render_pdf_page(path: &Path, page: usize) -> Result { + let prefix = std::env::temp_dir().join(format!( + "noctua-pdf-{}-{}", + path_hash(path), + page.saturating_add(1) + )); + let output = prefix.with_extension("png"); + + let status = Command::new("pdftocairo") + .arg("-png") + .arg("-singlefile") + .arg("-f") + .arg(page.saturating_add(1).to_string()) + .arg("-l") + .arg(page.saturating_add(1).to_string()) + .arg("-scale-to") + .arg("2400") + .arg(path) + .arg(&prefix) + .status() + .map_err(|err| format!("Failed to run pdftocairo: {err}"))?; + + if status.success() && output.exists() { + Ok(output) + } else { + Err(format!( + "Failed to render page {} of {}", + page.saturating_add(1), + path.display() + )) + } +} + +fn path_hash(path: &Path) -> u64 { + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + hasher.finish() } From a76993f69b1996fa79ef68123ed050c5845fe5b1 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Tue, 7 Jul 2026 20:35:41 +0200 Subject: [PATCH 10/10] feat(pdf): improve page navigation rendering --- src/main.rs | 267 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 223 insertions(+), 44 deletions(-) diff --git a/src/main.rs b/src/main.rs index dfe823f..0478017 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,9 @@ use clap::Parser; use cosmic::app::{Core, Settings, Task}; use cosmic::iced::{ - event, keyboard, mouse, Alignment, ContentFit, Event, Length, Limits, Subscription, + event, + keyboard::{self, key::Named, Key}, + mouse, Alignment, ContentFit, Event, Length, Limits, Subscription, }; use cosmic::{executor, theme, widget, Application, Element}; use std::collections::hash_map::DefaultHasher; @@ -49,6 +51,8 @@ struct Document { page: usize, page_count: Option, rendered_page: Option, + rendering_page: Option, + prefetching_pages: Vec, error: Option, } @@ -64,6 +68,8 @@ impl Document { page: 0, page_count, rendered_page: None, + rendering_page: None, + prefetching_pages: Vec::new(), error: None, } } @@ -93,7 +99,17 @@ enum Message { ZoomOut, ResetZoom, ModifiersChanged(keyboard::Modifiers), - ScrollZoom(f32), + Scroll(f32), + PdfRendered { + path: PathBuf, + page: usize, + result: Result, + }, + PdfPrefetched { + path: PathBuf, + page: usize, + result: Result, + }, } struct NoctuaApp { @@ -167,9 +183,9 @@ impl Application for NoctuaApp { }; app.refresh_title(); - app.ensure_current_rendered(); + let task = app.ensure_current_rendered(); - (app, Task::none()) + (app, task) } fn update(&mut self, message: Self::Message) -> Task { @@ -187,23 +203,10 @@ impl Application for NoctuaApp { } } Message::PreviousPage => { - if let Some(document) = self.current_document_mut() { - if document.kind == DocumentKind::Pdf && document.page > 0 { - document.page -= 1; - document.rendered_page = None; - } - } + self.previous_pdf_page(); } Message::NextPage => { - if let Some(document) = self.current_document_mut() { - if document.kind == DocumentKind::Pdf { - let page_count = document.page_count.unwrap_or(document.page + 2); - if document.page + 1 < page_count { - document.page += 1; - document.rendered_page = None; - } - } - } + self.next_pdf_page(); } Message::ZoomIn => { self.zoom_in(); @@ -217,21 +220,66 @@ impl Application for NoctuaApp { Message::ModifiersChanged(modifiers) => { self.modifiers = modifiers; } - Message::ScrollZoom(delta) => { + Message::Scroll(delta) => { if self.modifiers.control() { if delta > 0.0 { self.zoom_in(); } else if delta < 0.0 { self.zoom_out(); } + } else if delta > 0.0 { + self.previous_pdf_page(); + } else if delta < 0.0 { + self.next_pdf_page(); + } + } + Message::PdfRendered { path, page, result } => { + if let Some(document) = self + .documents + .iter_mut() + .find(|document| document.path == path) + { + if document.rendering_page == Some(page) { + document.rendering_page = None; + } + + if document.page == page { + match result { + Ok(rendered) => { + document.rendered_page = Some(rendered); + document.error = None; + } + Err(err) => { + document.rendered_page = None; + document.error = Some(err); + } + } + } + } + } + Message::PdfPrefetched { path, page, result } => { + if let Some(document) = self + .documents + .iter_mut() + .find(|document| document.path == path) + { + document + .prefetching_pages + .retain(|pending| *pending != page); + + if document.page == page && document.rendered_page.is_none() { + if let Ok(rendered) = result { + document.rendered_page = Some(rendered); + document.error = None; + } + } } } } self.refresh_title(); - self.ensure_current_rendered(); - Task::none() + self.ensure_current_rendered() } fn subscription(&self) -> Subscription { @@ -239,10 +287,18 @@ impl Application for NoctuaApp { Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { Some(Message::ModifiersChanged(modifiers)) } + Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(Named::PageUp | Named::ArrowUp | Named::ArrowLeft), + .. + }) if status == event::Status::Ignored => Some(Message::PreviousPage), + Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(Named::PageDown | Named::ArrowDown | Named::ArrowRight), + .. + }) if status == event::Status::Ignored => Some(Message::NextPage), Event::Mouse(mouse::Event::WheelScrolled { delta }) if status == event::Status::Ignored => { - scroll_delta_y(delta).map(Message::ScrollZoom) + scroll_delta_y(delta).map(Message::Scroll) } _ => None, }) @@ -333,6 +389,29 @@ impl NoctuaApp { self.zoom = (self.zoom / 1.2).max(0.2); } + fn previous_pdf_page(&mut self) { + if let Some(document) = self.current_document_mut() { + if document.kind == DocumentKind::Pdf && document.page > 0 { + document.page -= 1; + document.rendered_page = None; + document.error = None; + } + } + } + + fn next_pdf_page(&mut self) { + if let Some(document) = self.current_document_mut() { + if document.kind == DocumentKind::Pdf { + let page_count = document.page_count.unwrap_or(document.page + 2); + if document.page + 1 < page_count { + document.page += 1; + document.rendered_page = None; + document.error = None; + } + } + } + } + fn current_document(&self) -> Option<&Document> { self.documents.get(self.current) } @@ -349,25 +428,84 @@ impl NoctuaApp { self.core.set_header_title(title); } - fn ensure_current_rendered(&mut self) { - let Some(document) = self.current_document_mut() else { - return; + fn ensure_current_rendered(&mut self) -> Task { + let render_task = { + let Some(document) = self.current_document_mut() else { + return Task::none(); + }; + + if document.kind != DocumentKind::Pdf { + return Task::none(); + } + + if document.rendered_page.is_some() { + return self.ensure_adjacent_rendered(); + } + + if document.rendering_page == Some(document.page) { + return Task::none(); + } + + if let Some(cached) = cached_pdf_page(&document.path, document.page) { + document.rendered_page = Some(cached); + document.error = None; + document.rendering_page = None; + return self.ensure_adjacent_rendered(); + } + + document.rendering_page = Some(document.page); + document.error = None; + + render_pdf_task( + document.path.clone(), + document.page, + |path, page, result| Message::PdfRendered { path, page, result }, + ) }; - if document.kind != DocumentKind::Pdf || document.rendered_page.is_some() { - return; + Task::batch([render_task, self.ensure_adjacent_rendered()]) + } + + fn ensure_adjacent_rendered(&mut self) -> Task { + let Some(document) = self.current_document_mut() else { + return Task::none(); + }; + + if document.kind != DocumentKind::Pdf || document.rendered_page.is_none() { + return Task::none(); } - match render_pdf_page(&document.path, document.page) { - Ok(rendered) => { - document.rendered_page = Some(rendered); - document.error = None; - } - Err(err) => { - document.rendered_page = None; - document.error = Some(err); - } + let mut pages = Vec::with_capacity(2); + if document + .page_count + .is_none_or(|page_count| document.page + 1 < page_count) + { + pages.push(document.page + 1); } + if document.page > 0 { + pages.push(document.page - 1); + } + + let path = document.path.clone(); + let pages = pages + .into_iter() + .filter(|page| document.rendering_page != Some(*page)) + .filter(|page| !document.prefetching_pages.contains(page)) + .filter(|page| cached_pdf_page(&path, *page).is_none()) + .collect::>(); + + document.prefetching_pages.extend(pages.iter().copied()); + + let tasks = pages + .into_iter() + .map(|page| { + render_pdf_task(path.clone(), page, |path, page, result| { + Message::PdfPrefetched { path, page, result } + }) + }) + .collect::>(); + + Task::batch(tasks) } fn document_view(&self, document: &Document) -> Element<'_, Message> { @@ -515,13 +653,42 @@ fn pdf_page_count(path: &Path) -> Option { }) } +fn render_pdf_task( + path: PathBuf, + page: usize, + on_done: impl FnOnce(PathBuf, usize, Result) -> Message + Send + 'static, +) -> Task { + Task::perform( + async move { + let result = render_pdf_page(&path, page); + (path, page, result) + }, + move |(path, page, result)| cosmic::Action::App(on_done(path, page, result)), + ) +} + +fn cached_pdf_page(path: &Path, page: usize) -> Option { + let output = pdf_page_output(path, page); + output.exists().then_some(output) +} + +fn pdf_page_output(path: &Path, page: usize) -> PathBuf { + std::env::temp_dir() + .join(format!( + "noctua-pdf-{}-{}", + path_hash(path), + page.saturating_add(1) + )) + .with_extension("png") +} + fn render_pdf_page(path: &Path, page: usize) -> Result { - let prefix = std::env::temp_dir().join(format!( - "noctua-pdf-{}-{}", - path_hash(path), - page.saturating_add(1) - )); - let output = prefix.with_extension("png"); + if let Some(cached) = cached_pdf_page(path, page) { + return Ok(cached); + } + + let output = pdf_page_output(path, page); + let prefix = output.with_extension(""); let status = Command::new("pdftocairo") .arg("-png") @@ -550,6 +717,18 @@ fn render_pdf_page(path: &Path, page: usize) -> Result { fn path_hash(path: &Path) -> u64 { let mut hasher = DefaultHasher::new(); - path.hash(&mut hasher); + + match path.canonicalize() { + Ok(path) => path.hash(&mut hasher), + Err(_) => path.hash(&mut hasher), + } + + if let Ok(metadata) = path.metadata() { + metadata.len().hash(&mut hasher); + if let Ok(modified) = metadata.modified() { + modified.hash(&mut hasher); + } + } + hasher.finish() }