Compare commits
10 commits
7fa218e68c
...
a76993f69b
| Author | SHA1 | Date | |
|---|---|---|---|
| a76993f69b | |||
| 42b03acd62 | |||
|
|
79cc199d11 | ||
|
|
ca6f8f788f | ||
|
|
4c1af08fb7 | ||
|
|
93fc1bf829 | ||
|
|
30e3bf6dbe | ||
|
|
1a0b8c49aa | ||
|
|
9ec407d9aa | ||
|
|
767315a65e |
38 changed files with 2189 additions and 833 deletions
75
Cargo.toml
75
Cargo.toml
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
}
|
||||
|
|
@ -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;
|
||||
8
justfile
8
justfile
|
|
@ -60,13 +60,9 @@ check-json: (check '--message-format=json')
|
|||
test *args:
|
||||
cargo test --workspace {{args}}
|
||||
|
||||
# Runs the COSMIC UI application
|
||||
# Runs the application (no args = COSMIC UI, with args = CLI mode)
|
||||
run *args:
|
||||
env RUST_BACKTRACE=full cargo run --release -p noctua_ui {{args}}
|
||||
|
||||
# Runs the CLI test harness (core only, no UI deps)
|
||||
run-cli *args:
|
||||
env RUST_BACKTRACE=full cargo run --example cli -p noctua_core {{args}}
|
||||
env RUST_BACKTRACE=full cargo run --release {{args}}
|
||||
|
||||
# Installs files
|
||||
install:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
165
src/active_state.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
418
src/document/manager.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
66
src/document/session/command.rs
Normal file
66
src/document/session/command.rs
Normal 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 },
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,3 @@
|
|||
pub mod command;
|
||||
pub mod data;
|
||||
pub mod store;
|
||||
|
||||
pub use command::SessionCommand;
|
||||
pub use data::{CollectionKind, SessionData, SessionItem};
|
||||
|
|
@ -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}")))?;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
734
src/main.rs
Normal 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
338
src/patch.rs
Normal 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
174
src/workspace/manager.rs
Normal 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
7
src/workspace/mod.rs
Normal 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
100
src/workspace/model.rs
Normal 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
94
test.json
Normal 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"
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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.
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue