Compare commits

...

10 commits

38 changed files with 2189 additions and 833 deletions

View file

@ -1,8 +1,75 @@
# 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 <wfx@mailbox.org>"]
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",
]

View file

@ -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 <wfx@mailbox.org>"]
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"

View file

@ -1,324 +0,0 @@
// 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<PathBuf>,
/// Open a directory
#[arg(long, value_name = "PATH")]
open_directory: Option<PathBuf>,
/// Open a session file (.ron)
#[arg(long, value_name = "PATH")]
open_session: Option<PathBuf>,
}
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");
}

View file

@ -1,293 +0,0 @@
// 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<SessionData>,
pub active_session_name: Option<String>,
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);
}
}
}
}

View file

@ -1,45 +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 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 },
}

View file

@ -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;

View file

@ -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:

View file

@ -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;

View file

@ -25,10 +25,14 @@
<provides>
<binary>noctua</binary>
<mediatype>image/png</mediatype>
<mediatype>image/x-png</mediatype>
<mediatype>image/jpeg</mediatype>
<mediatype>image/jpg</mediatype>
<mediatype>image/pjpeg</mediatype>
<mediatype>image/gif</mediatype>
<mediatype>image/webp</mediatype>
<mediatype>image/bmp</mediatype>
<mediatype>image/x-bmp</mediatype>
<mediatype>image/tiff</mediatype>
<mediatype>image/svg+xml</mediatype>
<mediatype>application/pdf</mediatype>

165
src/active_state.rs Normal file
View file

@ -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<Uuid>,
active_item: Option<usize>,
}
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<Uuid> {
self.active_session
}
#[must_use]
pub fn active_item_index(&self) -> Option<usize> {
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<Uuid> {
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())
}
}

View file

@ -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)
};
}

418
src/document/manager.rs Normal file
View file

@ -0,0 +1,418 @@
// 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<SessionData>,
active_session_index: Option<usize>,
active_document_index: Option<usize>,
clipboard: Option<SessionItem>,
session_file_path: Option<PathBuf>,
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<usize> {
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<usize> {
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);
}
}

View file

@ -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 {

View file

@ -0,0 +1,66 @@
// 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 },
}

View file

@ -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<PathBuf>,
pub items: Vec<SessionItem>,
/// Index of the currently active item, or `None` if the session is empty.
pub current_index: Option<usize>,
}
impl SessionData {
@ -71,13 +62,11 @@ impl SessionData {
path: Option<PathBuf>,
items: Vec<SessionItem>,
) -> Self {
let current_index = if items.is_empty() { None } else { Some(0) };
Self {
name: name.to_string(),
kind,
path,
items,
current_index,
}
}
}

View file

@ -6,6 +6,3 @@
pub mod command;
pub mod data;
pub mod store;
pub use command::SessionCommand;
pub use data::{CollectionKind, SessionData, SessionItem};

View file

@ -54,7 +54,6 @@ pub fn load_sessions(path: &Path) -> Result<Vec<SessionData>> {
///
/// 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}")))?;

View file

@ -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;

View file

@ -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}"),
}

734
src/main.rs Normal file
View file

@ -0,0 +1,734 @@
// 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<PathBuf>,
/// Compatibility with the previous CLI harness.
#[arg(long = "open-document", value_name = "PATH")]
document: Option<PathBuf>,
/// Open all supported documents in a directory.
#[arg(long = "open-directory", value_name = "PATH")]
directory: Option<PathBuf>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum DocumentKind {
Raster,
Svg,
Pdf,
}
#[derive(Clone, Debug)]
struct Document {
path: PathBuf,
kind: DocumentKind,
page: usize,
page_count: Option<usize>,
rendered_page: Option<PathBuf>,
rendering_page: Option<usize>,
prefetching_pages: Vec<usize>,
error: Option<String>,
}
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<Document>,
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<PathBuf, String>,
},
PdfPrefetched {
path: PathBuf,
page: usize,
result: Result<PathBuf, String>,
},
}
struct NoctuaApp {
core: Core,
documents: Vec<Document>,
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::<NoctuaApp>(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<Self::Message>) {
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<Self::Message> {
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<Self::Message> {
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<Element<'_, Self::Message>> {
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<Message> {
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<Message> {
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::<Vec<_>>();
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::<Vec<_>>();
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<String>) -> 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<f32> {
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<Document>, 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<Document> {
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::<Vec<_>>();
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<DocumentKind> {
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<usize> {
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::<usize>().ok())
})
}
fn render_pdf_task(
path: PathBuf,
page: usize,
on_done: impl FnOnce(PathBuf, usize, Result<PathBuf, String>) -> Message + Send + 'static,
) -> Task<Message> {
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<PathBuf> {
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<PathBuf, String> {
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()
}

338
src/patch.rs Normal file
View file

@ -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<Vec<PatchStep>> {
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<SessionType> {
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<ItemKind> {
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:?}"),
}
}

174
src/workspace/manager.rs Normal file
View file

@ -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<Path>) -> 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<Path>) -> Result<Self> {
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<Session> {
let pos = self.session_pos(session_id)?;
Ok(self.sessions.remove(pos))
}
pub fn copy_session(&mut self, session_id: Uuid) -> Result<Uuid> {
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<String>) -> 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<Item> {
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<Uuid> {
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<String>,
) -> 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<usize> {
self.sessions
.iter()
.position(|s| s.id == id)
.context(format!("Session {id} not found"))
}
}
fn item_pos(items: &[Item], id: Uuid) -> Result<usize> {
items
.iter()
.position(|i| i.id == id)
.context(format!("Item {id} not found"))
}

7
src/workspace/mod.rs Normal file
View file

@ -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;

100
src/workspace/model.rs Normal file
View file

@ -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<String>,
}
impl Item {
pub fn new(name: impl Into<String>, kind: ItemKind) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
kind,
tags: Vec::new(),
}
}
pub fn with_tags(mut self, tags: Vec<String>) -> 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<Item>,
}
impl Session {
pub fn new(name: impl Into<String>, 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<Session>,
}
impl Workspace {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
sessions: Vec::new(),
}
}
}

94
test.json Normal file
View file

@ -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"
]

View file

@ -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 <wfx@mailbox.org>"]
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",
]

View file

@ -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.
}