diff --git a/Cargo.toml b/Cargo.toml index 986a96f..0a74905 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,75 +1,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later # Cargo.toml # -# Noctua COSMIC desktop application +# Workspace manifest for the Noctua project. -[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.cosmic] -package = "libcosmic-yoda" -path = "../libcosmic" -default-features = false -features = [ - "a11y", - "about", - "dbus-config", - "multi-window", - "single-instance", - "tokio", - "winit", - "wayland", - "wgpu", -] +[workspace] +members = ["core", "ui"] +resolver = "2" diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..d582fe8 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,48 @@ +# 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/examples/cli.rs b/core/examples/cli.rs new file mode 100644 index 0000000..cf4304e --- /dev/null +++ b/core/examples/cli.rs @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// examples/cli.rs +// +// CLI test harness for the noctua_core document foundation. + +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 std::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(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", + }; + println!( + " {MAGENTA}Collection: {BOLD}{}{RESET} {DIM}({kind_label}, {} items, index: {:?}){RESET}", + session.name, + session.items.len(), + session.current_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")); + } +} + +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_nav_index(manager: &DocumentManager, label: &str) { + if let Some(session) = manager.active_session() { + ok(&format!("{label:<28} → index {:?}", session.current_index)); + } +} + +#[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 { + /// Open a document (raster, vector, portable) + #[arg(long, value_name = "PATH")] + open_document: Option, + + /// Open a directory + #[arg(long, value_name = "PATH")] + open_directory: Option, + + /// Open a session file (.ron) + #[arg(long, value_name = "PATH")] + open_session: Option, +} + +fn main() { + env_logger::init(); + + let cli = Opt::parse(); + let mut mgr = DocumentManager::new(); + + // ── Open source ── + + heading("Opening source"); + + if let Some(path) = cli.open_session { + exec(&mut mgr, "Open session file", SessionCommand::Open { path }); + } else if let Some(path) = cli.open_document { + let dir = path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + + let session_name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Browse") + .to_string(); + + exec( + &mut mgr, + &format!("New browser session '{session_name}'"), + SessionCommand::New { + name: session_name.clone(), + kind: CollectionKind::DirectoryBrowser, + }, + ); + exec( + &mut mgr, + &format!("Select '{session_name}'"), + SessionCommand::Select { + name: session_name.clone(), + }, + ); + exec( + &mut mgr, + &format!("Add directory {}", dir.display()), + SessionCommand::AddDirectory { dir }, + ); + exec( + &mut mgr, + &format!("Add document {}", path.display()), + SessionCommand::AddDocument { path }, + ); + } else if let Some(path) = cli.open_directory { + let session_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Browse") + .to_string(); + + exec( + &mut mgr, + &format!("New session '{session_name}'"), + SessionCommand::New { + name: session_name.clone(), + kind: CollectionKind::DirectoryBrowser, + }, + ); + exec( + &mut mgr, + &format!("Select '{session_name}'"), + SessionCommand::Select { + name: session_name.clone(), + }, + ); + exec( + &mut mgr, + &format!("Add directory {}", path.display()), + SessionCommand::AddDirectory { 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_name.clone() { + Some(n) => n, + None => { + info("No active session – nothing more to test."); + return; + } + }; + + print_session(&mgr, &active_name); + + // ── Navigation ── + + heading("Navigation"); + + exec(&mut mgr, "NextDocument", SessionCommand::NextDocument); + print_nav_index(&mgr, "After NextDocument"); + + exec( + &mut mgr, + "PreviousDocument", + SessionCommand::PreviousDocument, + ); + print_nav_index(&mgr, "After PreviousDocument"); + + exec( + &mut mgr, + "SelectDocument { index: 0 }", + SessionCommand::SelectDocument { index: 0 }, + ); + print_nav_index(&mgr, "After SelectDocument(0)"); + + // ── Collection operations ── + + heading("Collection operations"); + + exec( + &mut mgr, + "New session 'Favorites'", + SessionCommand::New { + name: "Favorites".to_string(), + kind: CollectionKind::DocumentCollection, + }, + ); + exec( + &mut mgr, + "New session 'Workspace'", + SessionCommand::New { + name: "Workspace".to_string(), + kind: CollectionKind::DocumentCollection, + }, + ); + + // Copy first item from browser → Favorites + heading("Copy item to Favorites"); + step("Before:"); + print_session(&mgr, "Favorites"); + + 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 { + name: active_name.clone(), + }, + ); + exec( + &mut mgr, + "RemoveDocument { index: 0 }", + SessionCommand::RemoveDocument { index: 0 }, + ); + + // Move: Favorites → Workspace (copy + delete) + heading("Move: Favorites → Workspace"); + step("Before:"); + print_session(&mgr, "Favorites"); + print_session(&mgr, "Workspace"); + + 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 { + name: "Favorites".to_string(), + }, + ); + exec( + &mut mgr, + "RemoveDocument { index: 0 }", + SessionCommand::RemoveDocument { index: 0 }, + ); + + step("After:"); + print_session(&mgr, "Favorites"); + print_session(&mgr, "Workspace"); + + heading("Done"); +} diff --git a/src/document/command.rs b/core/src/document/command.rs similarity index 100% rename from src/document/command.rs rename to core/src/document/command.rs diff --git a/src/document/loader.rs b/core/src/document/loader.rs similarity index 96% rename from src/document/loader.rs rename to core/src/document/loader.rs index f361d0b..c94dc31 100644 --- a/src/document/loader.rs +++ b/core/src/document/loader.rs @@ -98,4 +98,10 @@ 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 new file mode 100644 index 0000000..29c7b0a --- /dev/null +++ b/core/src/document/manager.rs @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/document/manager.rs +// +// Document Manager – single entry point via SessionCommand. + +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::store; +use crate::document::DocumentContent; +use crate::error::Error; +use log::warn; +use std::path::Path; + +#[allow(dead_code)] +#[derive(Debug)] +pub enum DocumentState { + Empty, + Loading, + Loaded(DocumentContent), + Error(Error), +} + +pub struct DocumentManager { + sessions: Vec, + pub active_session_name: Option, + pub state: DocumentState, +} + +impl Default for DocumentManager { + fn default() -> Self { + Self { + sessions: Vec::new(), + active_session_name: None, + state: DocumentState::Empty, + } + } +} + +impl DocumentManager { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The single public entry point for all session/collection 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); + } + } + + // -- Collection management -- + SessionCommand::AddDirectory { dir } => { + if let Some(session) = self.active_session_mut() { + scan_directory_into(session, &dir); + } + } + 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); + } + + // -- Navigation -- + SessionCommand::NextDocument => self.navigate_next(), + SessionCommand::PreviousDocument => self.navigate_previous(), + SessionCommand::SelectDocument { index } => self.select_document(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.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) + } + + 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 open_session_file(&mut self, path: &Path) { + 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); + } + } + CollectionKind::DocumentCollection => { + session.items.retain(|item| { + if item.path.exists() { + true + } else { + warn!( + "Session '{}': {}", + session.name, + Error::NotFound(item.path.display().to_string()) + ); + 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 + .iter() + .find(|s| s.kind == CollectionKind::DirectoryBrowser) + .map(|s| s.name.clone()); + + self.sessions = loaded; + self.active_session_name = browser_name; + self.state = DocumentState::Empty; + } + Err(e) => { + self.state = DocumentState::Error(e); + } + } + } + + 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); + } + } + self.state = DocumentState::Loaded(content); + } + Err(e) => { + self.state = DocumentState::Error(e); + } + } + } + + fn select_document(&mut self, index: usize) { + if let Some(session) = self.active_session_mut() { + if index < session.items.len() { + session.current_index = Some(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); + } + } + } + } + + 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); + } + } + } + } + + 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" + ))); + 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(), + )); + 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; + }; + + if index >= session.items.len() { + return; + } + + 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); + } + } + } +} diff --git a/src/document/mod.rs b/core/src/document/mod.rs similarity index 87% rename from src/document/mod.rs rename to core/src/document/mod.rs index f620821..c719136 100644 --- a/src/document/mod.rs +++ b/core/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. The UI only ever sees a `RawImage` +/// This enum is internal to `noctua_core`. 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/src/document/model/mod.rs b/core/src/document/model/mod.rs similarity index 100% rename from src/document/model/mod.rs rename to core/src/document/model/mod.rs diff --git a/src/document/model/portable.rs b/core/src/document/model/portable.rs similarity index 100% rename from src/document/model/portable.rs rename to core/src/document/model/portable.rs diff --git a/src/document/model/raster.rs b/core/src/document/model/raster.rs similarity index 100% rename from src/document/model/raster.rs rename to core/src/document/model/raster.rs diff --git a/src/document/model/vector.rs b/core/src/document/model/vector.rs similarity index 100% rename from src/document/model/vector.rs rename to core/src/document/model/vector.rs diff --git a/core/src/document/session/command.rs b/core/src/document/session/command.rs new file mode 100644 index 0000000..0b141e4 --- /dev/null +++ b/core/src/document/session/command.rs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/document/session/command.rs +// +// 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. +#[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 }, + + // 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 }, + + // 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. + SelectDocument { index: usize }, +} diff --git a/src/document/session/data.rs b/core/src/document/session/data.rs similarity index 57% rename from src/document/session/data.rs rename to core/src/document/session/data.rs index 9697fdd..157982e 100644 --- a/src/document/session/data.rs +++ b/core/src/document/session/data.rs @@ -1,19 +1,28 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/document/session/data.rs // -// Pure data structures for sessions and their document items. +// 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. // -// Operational state (active session cursor, active document cursor, clipboard) -// lives in the manager, not here. This module contains only what is persisted. +// 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`. 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. @@ -39,19 +48,19 @@ impl SessionItem { } } -/// 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. +/// The full state of a session. #[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). + /// For `DocumentCollection`: None (items are managed individually). + /// The .ron session file path is tracked separately. #[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 { @@ -62,11 +71,13 @@ 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/src/document/session/mod.rs b/core/src/document/session/mod.rs similarity index 67% rename from src/document/session/mod.rs rename to core/src/document/session/mod.rs index 5d25ca6..1e82946 100644 --- a/src/document/session/mod.rs +++ b/core/src/document/session/mod.rs @@ -6,3 +6,6 @@ pub mod command; pub mod data; pub mod store; + +pub use command::SessionCommand; +pub use data::{CollectionKind, SessionData, SessionItem}; diff --git a/src/document/session/store.rs b/core/src/document/session/store.rs similarity index 98% rename from src/document/session/store.rs rename to core/src/document/session/store.rs index 9d00c3c..480728f 100644 --- a/src/document/session/store.rs +++ b/core/src/document/session/store.rs @@ -54,6 +54,7 @@ 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}")))?; diff --git a/src/document/types/image.rs b/core/src/document/types/image.rs similarity index 100% rename from src/document/types/image.rs rename to core/src/document/types/image.rs diff --git a/src/document/types/layout.rs b/core/src/document/types/layout.rs similarity index 100% rename from src/document/types/layout.rs rename to core/src/document/types/layout.rs diff --git a/src/document/types/mod.rs b/core/src/document/types/mod.rs similarity index 91% rename from src/document/types/mod.rs rename to core/src/document/types/mod.rs index 7a5f836..eae29b1 100644 --- a/src/document/types/mod.rs +++ b/core/src/document/types/mod.rs @@ -7,5 +7,6 @@ pub mod image; pub mod layout; pub mod viewbox; +pub use image::RawImage; pub use layout::PageLayout; pub use viewbox::ViewBox; diff --git a/src/document/types/viewbox.rs b/core/src/document/types/viewbox.rs similarity index 100% rename from src/document/types/viewbox.rs rename to core/src/document/types/viewbox.rs diff --git a/src/error.rs b/core/src/error.rs similarity index 86% rename from src/error.rs rename to core/src/error.rs index 402a70a..c17566b 100644 --- a/src/error.rs +++ b/core/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. +// Central error and result types for noctua_core. use std::fmt; @@ -13,6 +13,8 @@ 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. @@ -24,6 +26,7 @@ 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/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..b337937 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,14 @@ +// 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/src/render/converter.rs b/core/src/render/converter.rs similarity index 100% rename from src/render/converter.rs rename to core/src/render/converter.rs diff --git a/src/render/mod.rs b/core/src/render/mod.rs similarity index 100% rename from src/render/mod.rs rename to core/src/render/mod.rs diff --git a/src/render/thumbnail.rs b/core/src/render/thumbnail.rs similarity index 100% rename from src/render/thumbnail.rs rename to core/src/render/thumbnail.rs diff --git a/justfile b/justfile index 65263c9..c04c9d0 100644 --- a/justfile +++ b/justfile @@ -60,9 +60,13 @@ check-json: (check '--message-format=json') test *args: cargo test --workspace {{args}} -# Runs the application (no args = COSMIC UI, with args = CLI mode) +# Runs the COSMIC UI application run *args: - env RUST_BACKTRACE=full cargo run --release {{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}} # Installs files install: diff --git a/resources/org.codeberg.wfx.Noctua.desktop b/resources/org.codeberg.wfx.Noctua.desktop index 9bb2d0a..4cbb4b8 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=/usr/bin/noctua %F +Exec=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/x-png;image/jpeg;image/jpg;image/pjpeg;image/gif;image/webp;image/bmp;image/x-bmp;image/tiff;image/svg+xml;application/pdf; +MimeType=image/png;image/jpeg;image/gif;image/webp;image/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 ae24682..8abdf03 100644 --- a/resources/org.codeberg.wfx.Noctua.metainfo.xml +++ b/resources/org.codeberg.wfx.Noctua.metainfo.xml @@ -25,14 +25,10 @@ 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/active_state.rs b/src/active_state.rs deleted file mode 100644 index 6525747..0000000 --- a/src/active_state.rs +++ /dev/null @@ -1,165 +0,0 @@ -// 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/document/manager.rs b/src/document/manager.rs deleted file mode 100644 index 19de638..0000000 --- a/src/document/manager.rs +++ /dev/null @@ -1,418 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/manager.rs -// -// 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, SessionItem}; -use crate::document::session::store; -use crate::document::DocumentContent; -use crate::error::Error; -use std::path::{Path, PathBuf}; - -/// The load state of the currently viewed document. -#[derive(Debug)] -pub enum DocumentState { - Empty, - Loading, - Loaded(DocumentContent), - 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, - active_session_index: Option, - active_document_index: Option, - clipboard: Option, - session_file_path: Option, - pub state: DocumentState, -} - -impl Default for DocumentManager { - fn default() -> Self { - Self { - sessions: Vec::new(), - active_session_index: None, - active_document_index: None, - clipboard: None, - session_file_path: None, - state: DocumentState::Empty, - } - } -} - -impl DocumentManager { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// The single public entry point for all session and document state changes. - pub fn handle(&mut self, command: SessionCommand) { - match command { - // Session lifecycle - SessionCommand::NewSession { name } => self.new_session(&name), - SessionCommand::OpenSession { path } => self.open_session_file(&path), - 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), - SessionCommand::RenameSession { name } => self.rename_session(&name), - - // Collection management - SessionCommand::AddDirectoryToSession { dir } => { - if let Some(session) = self.active_session_mut() { - scan_directory_into(session, &dir); - } - self.reset_document_cursor(); - } - SessionCommand::AddDocumentToSession { path } => self.add_document(&path), - - // 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::MoveDocumentUp => self.move_document_up(), - SessionCommand::MoveDocumentDown => self.move_document_down(), - SessionCommand::MoveDocumentTo { index } => self.move_document_to(index), - } - } - - /// Read-only access to the active session. - #[must_use] - pub fn active_session(&self) -> Option<&SessionData> { - 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 active_session_mut(&mut self) -> Option<&mut SessionData> { - self.active_session_index.map(|i| &mut self.sessions[i]) - } - - 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) { - match store::load_sessions(path) { - Ok(mut loaded) => { - for session in &mut loaded { - session.items.retain(|item| { - if item.path.exists() { - true - } else { - log::error!( - "Session '{}': {}", - session.name, - Error::NotFound(item.path.display().to_string()) - ); - false - } - }); - } - - let browser_index = loaded - .iter() - .position(|s| s.kind == CollectionKind::DirectoryBrowser); - - self.sessions = loaded; - 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); - } - } - } - - // 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 { - return; - }; - - // The DirectoryBrowser session is managed by the UI and cannot be deleted. - 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(ref path) = self.session_file_path 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 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) => { - self.state = DocumentState::Error(e); - } - } - } - - fn select_document(&mut self, index: usize) { - if let Some(session) = self.active_session() { - if index < session.items.len() { - 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) { - 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(idx) = self.active_document_index { - if idx > 0 { - self.active_document_index = Some(idx - 1); - } - } - } - - fn move_document_up(&mut self) { - let Some(idx) = self.active_document_index else { - return; - }; - if idx == 0 { - return; - } - let Some(session) = self.active_session_mut() else { - return; - }; - session.items.swap(idx, idx - 1); - self.active_document_index = Some(idx - 1); - } - - 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); - } - - 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/src/document/session/command.rs b/src/document/session/command.rs deleted file mode 100644 index 4b74877..0000000 --- a/src/document/session/command.rs +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/session/command.rs -// -// Commands that operate on sessions and their document collections. - -use std::path::PathBuf; - -/// Commands operating on sessions and their document collections. -/// -/// 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 { - // Session lifecycle - /// Create a new empty `DocumentCollection` session. - NewSession { name: String }, - /// Load sessions from an existing .ron file. - OpenSession { path: PathBuf }, - /// 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 }, - /// Delete the active `DocumentCollection` session permanently (removed from .ron on save). - DeleteSession, - /// Rename the active `DocumentCollection` session. - RenameSession { name: String }, - - // 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 }, - - // 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/src/main.rs b/src/main.rs deleted file mode 100644 index 0478017..0000000 --- a/src/main.rs +++ /dev/null @@ -1,734 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/main.rs -// -// Minimal COSMIC viewer entry point for images and PDFs. - -use clap::Parser; -use cosmic::app::{Core, Settings, Task}; -use cosmic::iced::{ - 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; -use std::ffi::OsStr; -use std::hash::{Hash, Hasher}; -use std::path::{Path, PathBuf}; -use std::process::Command; - -#[derive(Parser, Debug)] -#[command( - name = "noctua", - version, - about = "A document and image viewer for COSMIC" -)] -struct Args { - /// 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 all supported documents in a directory. - #[arg(long = "open-directory", value_name = "PATH")] - directory: Option, -} - -#[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, - rendering_page: Option, - prefetching_pages: Vec, - 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, - rendering_page: None, - prefetching_pages: Vec::new(), - 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), - Scroll(f32), - PdfRendered { - path: PathBuf, - page: usize, - result: Result, - }, - PdfPrefetched { - path: PathBuf, - page: usize, - result: Result, - }, -} - -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); - - let settings = Settings::default().size_limits(Limits::NONE.min_width(480.0).min_height(320.0)); - - cosmic::app::run::(settings, flags) -} - -impl Flags { - fn from_args(args: Args) -> Self { - let mut requested = args.files; - - if let Some(path) = args.document { - requested.push(path); - } - - if let Some(path) = args.directory { - requested.push(path); - } - - let (documents, initial_index) = collect_documents(&requested); - - Self { - documents, - initial_index, - } - } -} - -impl Application for NoctuaApp { - type Executor = executor::Default; - type Flags = Flags; - type Message = Message; - - const APP_ID: &'static str = "org.codeberg.wfx.Noctua"; - - fn core(&self) -> &Core { - &self.core - } - - fn core_mut(&mut self) -> &mut Core { - &mut self.core - } - - fn init(mut core: Core, flags: Self::Flags) -> (Self, Task) { - core.window.content_container = false; - - 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(), - }; - - app.refresh_title(); - let task = app.ensure_current_rendered(); - - (app, task) - } - - 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 => { - self.previous_pdf_page(); - } - Message::NextPage => { - self.next_pdf_page(); - } - Message::ZoomIn => { - self.zoom_in(); - } - Message::ZoomOut => { - self.zoom_out(); - } - Message::ResetZoom => { - self.zoom = 1.0; - } - Message::ModifiersChanged(modifiers) => { - self.modifiers = modifiers; - } - 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() - } - - fn subscription(&self) -> Subscription { - event::listen_raw(|event, status, _| match event { - 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::Scroll) - } - _ => 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()) - } -} - -impl NoctuaApp { - fn zoom_in(&mut self) { - self.zoom = (self.zoom * 1.2).min(5.0); - } - - fn zoom_out(&mut self) { - 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) - } - - 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) -> 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 }, - ) - }; - - 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(); - } - - 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> { - 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, - }; - - (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_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 { - 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") - .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(); - - 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() -} diff --git a/src/patch.rs b/src/patch.rs deleted file mode 100644 index 2e44fcd..0000000 --- a/src/patch.rs +++ /dev/null @@ -1,338 +0,0 @@ -// 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 deleted file mode 100644 index 3b17348..0000000 --- a/src/workspace/manager.rs +++ /dev/null @@ -1,174 +0,0 @@ -// 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 deleted file mode 100644 index 330ed70..0000000 --- a/src/workspace/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -// 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 deleted file mode 100644 index 4d0e008..0000000 --- a/src/workspace/model.rs +++ /dev/null @@ -1,100 +0,0 @@ -// 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 deleted file mode 100644 index 4dd1247..0000000 --- a/test.json +++ /dev/null @@ -1,94 +0,0 @@ -[ - { "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" -] diff --git a/ui/Cargo.toml b/ui/Cargo.toml new file mode 100644 index 0000000..98bb336 --- /dev/null +++ b/ui/Cargo.toml @@ -0,0 +1,50 @@ +# 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 new file mode 100644 index 0000000..7269760 --- /dev/null +++ b/ui/src/main.rs @@ -0,0 +1,8 @@ +// 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. +}