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:
parent
9ec407d9aa
commit
1a0b8c49aa
27 changed files with 171 additions and 249 deletions
13
src/document/command.rs
Normal file
13
src/document/command.rs
Normal 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
101
src/document/loader.rs
Normal 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
424
src/document/manager.rs
Normal 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
24
src/document/mod.rs
Normal 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
12
src/document/model/mod.rs
Normal 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;
|
||||
14
src/document/model/portable.rs
Normal file
14
src/document/model/portable.rs
Normal 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,
|
||||
}
|
||||
12
src/document/model/raster.rs
Normal file
12
src/document/model/raster.rs
Normal 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),
|
||||
}
|
||||
13
src/document/model/vector.rs
Normal file
13
src/document/model/vector.rs
Normal 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>,
|
||||
}
|
||||
68
src/document/session/command.rs
Normal file
68
src/document/session/command.rs
Normal 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 },
|
||||
}
|
||||
72
src/document/session/data.rs
Normal file
72
src/document/session/data.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/document/session/mod.rs
Normal file
8
src/document/session/mod.rs
Normal 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;
|
||||
62
src/document/session/store.rs
Normal file
62
src/document/session/store.rs
Normal 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)
|
||||
}
|
||||
17
src/document/types/image.rs
Normal file
17
src/document/types/image.rs
Normal 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,
|
||||
}
|
||||
45
src/document/types/layout.rs
Normal file
45
src/document/types/layout.rs
Normal 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
11
src/document/types/mod.rs
Normal 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;
|
||||
16
src/document/types/viewbox.rs
Normal file
16
src/document/types/viewbox.rs
Normal 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
46
src/error.rs
Normal 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
385
src/main.rs
Normal 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
4
src/render/converter.rs
Normal 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
7
src/render/mod.rs
Normal 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
4
src/render/thumbnail.rs
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue