refactor: merge workspace (core/ + ui/) into a single flat binary crate

integrate CLI into main.rs, no lib.rs by design (YAGNI)
This commit is contained in:
mow 2026-03-10 19:20:57 +01:00
parent 9ec407d9aa
commit 1a0b8c49aa
27 changed files with 171 additions and 249 deletions

13
src/document/command.rs Normal file
View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/command.rs
//
// Commands that operate on the active document itself.
/// Commands operating on the active document (zoom, export, annotate, …).
///
/// The `DocumentManager` dispatches these after resolving which document is
/// active – the UI never needs to know the underlying format.
#[derive(Debug, Clone)]
pub enum DocumentCommand {
// Reserved for future use: Rotate, Flip, Zoom, Export, Annotate, …
}

101
src/document/loader.rs Normal file
View file

@ -0,0 +1,101 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/loader.rs
//
// Document loading: format detection, format dispatch, and directory scanning.
use crate::document::model::{PortableModel, RasterModel, VectorModel};
use crate::document::session::data::{SessionData, SessionItem};
use crate::document::types::PageLayout;
use crate::document::DocumentContent;
use crate::error::{Error, Result};
use std::path::Path;
/// The three document formats Noctua understands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DocumentFormat {
/// Raster images – anything the image crate can decode.
Raster,
/// Vector graphics (SVG).
Vector,
/// Portable documents (PDF).
Portable,
}
/// Detects the document format from the file extension.
///
/// Returns `None` for unknown extensions so directory scans can skip
/// unsupported files early. At load time the underlying library
/// (`ImageReader`, `usvg`, `pdfium`) is the final authority.
fn detect_format(path: &Path) -> Option<DocumentFormat> {
match path
.extension()
.and_then(|e| e.to_str())
.map(str::to_lowercase)
.as_deref()
{
Some("pdf") => Some(DocumentFormat::Portable),
Some("svg") => Some(DocumentFormat::Vector),
Some("png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tiff" | "tif") => {
Some(DocumentFormat::Raster)
}
_ => None,
}
}
/// Loads a document from a path and returns its content.
///
/// # Errors
///
/// Returns [`Error::NotFound`] if the path does not exist.
/// Returns [`Error::Unsupported`] if the file extension is not recognised.
pub(crate) fn load_document(path: &Path) -> Result<DocumentContent> {
if !path.exists() {
return Err(Error::NotFound(format!("{}", path.display())));
}
match detect_format(path) {
Some(DocumentFormat::Portable) => {
// TODO: Replace with actual pdfium-render loading.
Ok(DocumentContent::Portable(PortableModel {
path: path.to_path_buf(),
page_count: 3,
layout: PageLayout::new(vec![(595.0, 842.0); 3], 10.0),
}))
}
Some(DocumentFormat::Vector) => {
// TODO: Replace with actual usvg parsing.
Ok(DocumentContent::Vector(VectorModel {
path: path.to_path_buf(),
view_box: None,
}))
}
Some(DocumentFormat::Raster) => {
// TODO: Replace with actual image-crate loading.
Ok(DocumentContent::Raster(RasterModel {
path: path.to_path_buf(),
dimensions: (1920, 1080),
}))
}
None => Err(Error::Unsupported(format!(
"extension: {}",
path.extension()
.and_then(|e| e.to_str())
.unwrap_or("(none)")
))),
}
}
/// Scans a directory for supported files and populates the session.
pub(crate) fn scan_directory_into(session: &mut SessionData, dir: &Path) {
if let Ok(entries) = std::fs::read_dir(dir) {
let mut paths: Vec<_> = entries
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.filter(|p| p.is_file() && detect_format(p).is_some())
.collect();
paths.sort();
session.items = paths.into_iter().map(|p| SessionItem::new(p, 0)).collect();
}
}

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

@ -0,0 +1,424 @@
// 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 log::warn;
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::CloseSession | SessionCommand::RemoveSession => self.close_session(),
SessionCommand::SaveSession => self.save_session(),
SessionCommand::SelectSession { name } => self.select_session(&name),
SessionCommand::DuplicateSession { new_name } => self.duplicate_session(&new_name),
SessionCommand::RenameSession { name } => self.rename_session(&name),
// Collection management
SessionCommand::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 {
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
}
});
}
}
}
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);
}
}
}
fn close_session(&mut self) {
let Some(idx) = self.active_session_index else {
return;
};
// The DirectoryBrowser session is managed by the UI and cannot be closed.
if self.sessions[idx].kind == CollectionKind::DirectoryBrowser {
return;
}
self.sessions.remove(idx);
self.active_document_index = None;
self.active_session_index = if self.sessions.is_empty() {
None
} else {
Some(idx.saturating_sub(1))
};
}
fn save_session(&mut self) {
let Some(path) = self.session_file_path.clone() else {
return;
};
if let Err(e) = store::save_sessions(&path, &self.sessions) {
self.state = DocumentState::Error(e);
}
}
fn select_session(&mut self, name: &str) {
if let Some(idx) = self.index_by_name(name) {
self.active_session_index = Some(idx);
self.reset_document_cursor();
}
}
fn duplicate_session(&mut self, new_name: &str) {
if self.index_by_name(new_name).is_some() {
return;
}
let Some(idx) = self.active_session_index else {
return;
};
let items = self.sessions[idx].items.clone();
self.sessions.push(SessionData::new(
new_name,
CollectionKind::DocumentCollection,
None,
items,
));
}
fn rename_session(&mut self, name: &str) {
let Some(idx) = self.active_session_index else {
return;
};
// The DirectoryBrowser session cannot be renamed.
if self.sessions[idx].kind == CollectionKind::DirectoryBrowser {
return;
}
if self.index_by_name(name).is_some() {
return;
}
self.sessions[idx].name = name.to_string();
}
fn add_document(&mut self, path: &Path) {
self.state = DocumentState::Loading;
match load_document(path) {
Ok(content) => {
if let Some(session) = self.active_session_mut() {
if 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);
}
}

24
src/document/mod.rs Normal file
View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/mod.rs
//
// Document abstraction – the public surface of the document subsystem.
pub mod command;
pub mod loader;
pub mod manager;
pub mod model;
pub mod session;
pub mod types;
use self::model::{PortableModel, RasterModel, VectorModel};
/// The decoded content of a loaded document.
///
/// This enum is internal to Noctua. The UI only ever sees a `RawImage`
/// produced by the render subsystem – it never matches on this type.
#[derive(Debug)]
pub enum DocumentContent {
Raster(RasterModel),
Vector(VectorModel),
Portable(PortableModel),
}

12
src/document/model/mod.rs Normal file
View file

@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/model/mod.rs
//
// The three concrete document models Noctua understands.
pub mod portable;
pub mod raster;
pub mod vector;
pub use portable::PortableModel;
pub use raster::RasterModel;
pub use vector::VectorModel;

View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/model/portable.rs
//
// Portable document format model (PDF).
use crate::document::types::PageLayout;
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct PortableModel {
pub path: PathBuf,
pub page_count: usize,
pub layout: PageLayout,
}

View file

@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/model/raster.rs
//
// Raster image document model (JPEG, PNG, etc.).
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct RasterModel {
pub path: PathBuf,
pub dimensions: (u32, u32),
}

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/model/vector.rs
//
// Vector image document model (SVG).
use crate::document::types::ViewBox;
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct VectorModel {
pub path: PathBuf,
pub view_box: Option<ViewBox>,
}

View file

@ -0,0 +1,68 @@
// 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 },
/// Close and remove the active session from the store.
CloseSession,
/// Persist all sessions to the .ron file.
SaveSession,
/// Set the active session cursor by name.
SelectSession { name: String },
/// Duplicate the active session as a new `DocumentCollection`.
///
/// When the source is a `DirectoryBrowser` this produces a snapshot:
/// the current items are copied into a new `DocumentCollection`.
DuplicateSession { new_name: String },
/// Remove the active `DocumentCollection` session without saving.
RemoveSession,
/// Rename the active `DocumentCollection` session.
RenameSession { name: String },
// Collection management within the active session
/// Scan a directory and populate the active `DirectoryBrowser` session.
AddDirectoryToSession { dir: PathBuf },
/// Add a single document to the active `DocumentCollection` session.
AddDocumentToSession { path: PathBuf },
// Document management within the active session
/// Set the active document cursor by index.
SelectDocument { index: usize },
/// Copy the active document into the clipboard.
CopyDocument,
/// Paste the clipboard item at the end of the active session.
PasteDocument,
/// Remove the active document from the active session.
RemoveDocument,
/// Move the active document cursor to the next item.
NextDocument,
/// Move the active document cursor to the previous item.
PreviousDocument,
/// Move the active document one position towards the front.
MoveDocumentUp,
/// Move the active document one position towards the back.
MoveDocumentDown,
/// Move the active document to an explicit position.
MoveDocumentTo { index: usize },
}

View file

@ -0,0 +1,72 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/session/data.rs
//
// Pure data structures for sessions and their document items.
//
// 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.
DirectoryBrowser,
/// Items are a curated list of documents or pages; `path` is None.
DocumentCollection,
}
/// A single entry in a session – a file path and an optional page within that file.
///
/// `page_index` is 0 for single-page formats (raster, SVG) and non-zero for
/// multi-page documents (PDF, multi-frame TIFF) where each page is its own item.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionItem {
pub path: PathBuf,
pub page_index: usize,
}
impl SessionItem {
#[must_use]
pub fn new(path: PathBuf, page_index: usize) -> Self {
Self { path, page_index }
}
}
/// 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).
#[serde(default)]
pub path: Option<PathBuf>,
pub items: Vec<SessionItem>,
}
impl SessionData {
#[must_use]
pub fn new(
name: &str,
kind: CollectionKind,
path: Option<PathBuf>,
items: Vec<SessionItem>,
) -> Self {
Self {
name: name.to_string(),
kind,
path,
items,
}
}
}

View file

@ -0,0 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/session/mod.rs
//
// Session subsystem: data model, commands, and persistence.
pub mod command;
pub mod data;
pub mod store;

View file

@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/session/store.rs
//
// RON-based persistence for session data: load and save session lists.
//
// For the session concept and the meaning of each field see `session.rs`.
//
// On disk a session file contains a RON list of SessionData records:
//
// [
// (
// name: "Pictures",
// kind: DirectoryBrowser,
// path: Some("/home/user/Pictures"),
// items: [],
// current_index: None,
// ),
// (
// name: "Project Docs",
// kind: DocumentCollection,
// path: None,
// items: [
// (path: "/home/user/docs/spec.pdf", page_index: 0),
// (path: "/home/user/docs/notes.pdf", page_index: 2),
// ],
// current_index: Some(0),
// ),
// ]
use crate::document::session::data::SessionData;
use crate::error::{Error, Result};
use std::path::Path;
/// Loads all sessions from a RON file.
///
/// # Errors
///
/// Returns [`Error::Io`] if the file cannot be read.
/// Returns [`Error::Session`] if the file cannot be parsed.
pub fn load_sessions(path: &Path) -> Result<Vec<SessionData>> {
let content = std::fs::read_to_string(path).map_err(Error::Io)?;
ron::from_str(&content).map_err(|e| {
Error::Session(format!(
"Failed to parse session file {}: {e}",
path.display()
))
})
}
/// Saves all sessions to a RON file.
///
/// # Errors
///
/// Returns [`Error::Session`] if the sessions cannot be serialized.
/// Returns [`Error::Io`] if the file cannot be written.
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}")))?;
std::fs::write(path, content).map_err(Error::Io)
}

View file

@ -0,0 +1,17 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/types/image.rs
//
// Raw pixel buffer passed from the render subsystem to the UI.
use std::sync::Arc;
/// A decoded, format-independent pixel buffer ready for display.
///
/// The UI receives this type from the `DocumentManager` and never needs to
/// know which document format (PDF, SVG, raster) produced it.
#[derive(Clone, Debug)]
pub struct RawImage {
pub data: Arc<Vec<u8>>,
pub width: u32,
pub height: u32,
}

View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/types/layout.rs
//
// PageLayout – describes the vertical arrangement of pages in a document.
/// The computed layout of all pages in a multi-page document.
///
/// Stores absolute y-offsets for each page so the render subsystem can
/// determine which pages are visible at a given scroll position without
/// iterating the full list on every frame.
#[derive(Clone, Debug)]
pub struct PageLayout {
pub page_starts: Vec<f64>,
pub page_sizes: Vec<(f64, f64)>,
pub gap: f64,
pub total_height: f64,
pub max_width: f64,
}
impl PageLayout {
#[must_use]
pub fn new(page_sizes: Vec<(f64, f64)>, gap: f64) -> Self {
let mut page_starts = Vec::with_capacity(page_sizes.len());
let mut y = 0.0_f64;
let mut max_width = 0.0_f64;
for &(w, h) in &page_sizes {
page_starts.push(y);
y += h + gap;
if w > max_width {
max_width = w;
}
}
let total_height = if page_sizes.is_empty() { 0.0 } else { y - gap };
Self {
page_starts,
page_sizes,
gap,
total_height,
max_width,
}
}
}

11
src/document/types/mod.rs Normal file
View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/types/mod.rs
//
// Shared domain primitives used across the document and render subsystems.
pub mod image;
pub mod layout;
pub mod viewbox;
pub use layout::PageLayout;
pub use viewbox::ViewBox;

View file

@ -0,0 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/document/types/viewbox.rs
//
// ViewBox – describes the visible coordinate space of a vector document.
/// The coordinate space declared by an SVG `viewBox` attribute.
///
/// Used by the render subsystem to map document coordinates to screen pixels
/// without exposing SVG internals to the UI.
#[derive(Clone, Debug)]
pub struct ViewBox {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}

46
src/error.rs Normal file
View file

@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/error.rs
//
// Central error and result types for Noctua.
use std::fmt;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
/// A requested file or resource was not found.
NotFound(String),
/// The file format or operation is not supported.
Unsupported(String),
/// A session could not be loaded or saved.
Session(String),
/// An underlying I/O error.
Io(std::io::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::NotFound(msg) => write!(f, "Not found: {msg}"),
Error::Unsupported(msg) => write!(f, "Unsupported: {msg}"),
Error::Session(msg) => write!(f, "Session error: {msg}"),
Error::Io(err) => write!(f, "I/O error: {err}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Error::Io(err)
}
}

385
src/main.rs Normal file
View file

@ -0,0 +1,385 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/main.rs
//
// Entry point for the Noctua COSMIC application.
mod document;
mod error;
mod render;
use clap::Parser;
use document::manager::{DocumentManager, DocumentState};
use document::session::command::SessionCommand;
use document::session::data::{CollectionKind, SessionData};
use std::path::{Path, PathBuf};
// ANSI helpers
const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const RED: &str = "\x1b[31m";
const CYAN: &str = "\x1b[36m";
const MAGENTA: &str = "\x1b[35m";
fn heading(text: &str) {
println!("\n{BOLD}{CYAN}── {text} ──{RESET}");
}
fn step(text: &str) {
println!(" {DIM}>{RESET} {text}");
}
fn ok(text: &str) {
println!(" {GREEN}Ok{RESET} {text}");
}
fn err(text: &str) {
eprintln!(" {RED}X{RESET} {text}");
}
fn info(text: &str) {
println!(" {YELLOW}i{RESET} {text}");
}
fn print_session(session: &SessionData, active_doc_index: Option<usize>) {
let kind_label = match session.kind {
CollectionKind::DirectoryBrowser => "DirectoryBrowser",
CollectionKind::DocumentCollection => "DocumentCollection",
};
println!(
" {MAGENTA}Session: {BOLD}{}{RESET} {DIM}({kind_label}, {} items, active: {:?}){RESET}",
session.name,
session.items.len(),
active_doc_index,
);
for (i, item) in session.items.iter().enumerate() {
let marker = if active_doc_index == Some(i) {
format!("{GREEN}=>{RESET}")
} else {
format!("{DIM} {RESET}")
};
let name = item
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");
println!(
" {marker} {DIM}[{i}]{RESET} {name} {DIM}(page {}){RESET}",
item.page_index
);
}
}
fn exec(manager: &mut DocumentManager, label: &str, command: SessionCommand) {
step(label);
manager.handle(command);
if let DocumentState::Error(e) = &manager.state {
err(&format!("{e}"));
manager.state = DocumentState::Empty;
}
}
fn print_active_session(mgr: &DocumentManager) {
if let Some(session) = mgr.active_session() {
print_session(session, mgr.active_document_index());
} else {
info("No active session");
}
}
#[derive(Parser, Debug)]
#[command(name = "noctua", about = "A document viewer for the COSMIC desktop")]
struct Args {
/// Open a document (raster, vector, portable)
#[arg(long = "open-document", value_name = "PATH")]
document: Option<PathBuf>,
/// Open a directory
#[arg(long = "open-directory", value_name = "PATH")]
directory: Option<PathBuf>,
/// Open a session file (.ron)
#[arg(long = "open-session", value_name = "PATH")]
session: Option<PathBuf>,
}
fn main() {
env_logger::init();
let args = Args::parse();
if args.document.is_none() && args.directory.is_none() && args.session.is_none() {
// TODO: Launch COSMIC UI.
return;
}
run_cli(args);
}
fn open_source(mgr: &mut DocumentManager, args: Args) {
if let Some(path) = args.session {
exec(
mgr,
"Open session file",
SessionCommand::OpenSession { path },
);
} else if let Some(path) = args.document {
let dir = path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let session_name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Browse")
.to_string();
exec(
mgr,
&format!("New session '{session_name}'"),
SessionCommand::NewSession {
name: session_name.clone(),
},
);
exec(
mgr,
&format!("Select '{session_name}'"),
SessionCommand::SelectSession {
name: session_name.clone(),
},
);
exec(
mgr,
&format!("Add directory {}", dir.display()),
SessionCommand::AddDirectoryToSession { dir },
);
exec(
mgr,
&format!("Add document {}", path.display()),
SessionCommand::AddDocumentToSession { path },
);
} else if let Some(path) = args.directory {
let session_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Browse")
.to_string();
exec(
mgr,
&format!("New session '{session_name}'"),
SessionCommand::NewSession {
name: session_name.clone(),
},
);
exec(
mgr,
&format!("Select '{session_name}'"),
SessionCommand::SelectSession {
name: session_name.clone(),
},
);
exec(
mgr,
&format!("Add directory {}", path.display()),
SessionCommand::AddDirectoryToSession { dir: path },
);
}
}
fn run_navigation_test(mgr: &mut DocumentManager) {
heading("Navigation");
exec(mgr, "NextDocument", SessionCommand::NextDocument);
ok(&format!(
"After NextDocument → index {:?}",
mgr.active_document_index()
));
exec(mgr, "PreviousDocument", SessionCommand::PreviousDocument);
ok(&format!(
"After PreviousDocument → index {:?}",
mgr.active_document_index()
));
exec(
mgr,
"SelectDocument { index: 0 }",
SessionCommand::SelectDocument { index: 0 },
);
ok(&format!(
"After SelectDocument(0) → index {:?}",
mgr.active_document_index()
));
heading("Move operations");
step("Before MoveDocumentDown:");
print_active_session(mgr);
exec(mgr, "MoveDocumentDown", SessionCommand::MoveDocumentDown);
ok(&format!(
"After MoveDocumentDown → index {:?}",
mgr.active_document_index()
));
exec(mgr, "MoveDocumentUp", SessionCommand::MoveDocumentUp);
ok(&format!(
"After MoveDocumentUp → index {:?}",
mgr.active_document_index()
));
}
fn run_copy_paste_test(mgr: &mut DocumentManager, active_name: &str) {
heading("Copy / Paste");
exec(
mgr,
"New session 'Favorites'",
SessionCommand::NewSession {
name: "Favorites".to_string(),
},
);
exec(
mgr,
"New session 'Workspace'",
SessionCommand::NewSession {
name: "Workspace".to_string(),
},
);
step("Favorites before paste:");
if let Some(session) = mgr.session_by_name("Favorites") {
print_session(session, None);
}
exec(
mgr,
&format!("SelectSession '{active_name}'"),
SessionCommand::SelectSession {
name: active_name.to_string(),
},
);
exec(
mgr,
"SelectDocument { index: 0 }",
SessionCommand::SelectDocument { index: 0 },
);
exec(mgr, "CopyDocument", SessionCommand::CopyDocument);
exec(
mgr,
"SelectSession 'Favorites'",
SessionCommand::SelectSession {
name: "Favorites".to_string(),
},
);
exec(mgr, "PasteDocument", SessionCommand::PasteDocument);
step("Favorites after paste:");
print_active_session(mgr);
exec(
mgr,
"MoveDocumentTo { index: 0 }",
SessionCommand::MoveDocumentTo { index: 0 },
);
step("Favorites after MoveDocumentTo(0):");
print_active_session(mgr);
exec(mgr, "RemoveDocument", SessionCommand::RemoveDocument);
step("Favorites after RemoveDocument:");
print_active_session(mgr);
}
fn run_session_test(mgr: &mut DocumentManager, active_name: &str) {
heading("Session operations");
exec(
mgr,
&format!("SelectSession '{active_name}'"),
SessionCommand::SelectSession {
name: active_name.to_string(),
},
);
exec(
mgr,
"DuplicateSession 'Snapshot'",
SessionCommand::DuplicateSession {
new_name: "Snapshot".to_string(),
},
);
step("Snapshot session:");
if let Some(session) = mgr.session_by_name("Snapshot") {
print_session(session, None);
}
exec(
mgr,
"SelectSession 'Workspace'",
SessionCommand::SelectSession {
name: "Workspace".to_string(),
},
);
exec(
mgr,
"RenameSession 'Archive'",
SessionCommand::RenameSession {
name: "Archive".to_string(),
},
);
step("All sessions after rename:");
for session in mgr.sessions() {
let is_active = mgr.active_session().is_some_and(|a| a.name == session.name);
let marker = if is_active {
format!("{GREEN}*{RESET}")
} else {
format!("{DIM} {RESET}")
};
println!(" {marker} {}", session.name);
}
exec(
mgr,
"CloseSession (closes 'Archive')",
SessionCommand::CloseSession,
);
step("All sessions after close:");
for session in mgr.sessions() {
println!(" {DIM}-{RESET} {}", session.name);
}
}
fn run_cli(args: Args) {
let mut mgr = DocumentManager::new();
heading("Opening source");
open_source(&mut mgr, args);
heading("State after open");
match &mgr.state {
DocumentState::Empty => info("No document loaded"),
DocumentState::Loading => info("Loading…"),
DocumentState::Error(e) => err(&format!("{e}")),
DocumentState::Loaded(_) => ok("Document loaded"),
}
let Some(active_name) = mgr.active_session().map(|s| s.name.clone()) else {
info("No active session – nothing more to test.");
return;
};
print_active_session(&mgr);
run_navigation_test(&mut mgr);
run_copy_paste_test(&mut mgr, &active_name);
run_session_test(&mut mgr, &active_name);
heading("Done");
}

4
src/render/converter.rs Normal file
View file

@ -0,0 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/render/converter.rs
//
// Converts raw document data into renderable page output.

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

@ -0,0 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/render/mod.rs
//
// Rendering subsystem: converts document content into displayable output.
pub mod converter;
pub mod thumbnail;

4
src/render/thumbnail.rs Normal file
View file

@ -0,0 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/render/thumbnail.rs
//
// Thumbnail cache following the freedesktop.org thumbnail spec.