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

View file

@ -1,8 +1,73 @@
# 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.libcosmic]
git = "https://github.com/pop-os/libcosmic.git"
features = [
"a11y",
"about",
"dbus-config",
"multi-window",
"single-instance",
"tokio",
"winit",
"wayland",
"wgpu",
]

View file

@ -1,48 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# core/Cargo.toml
#
# Core library crate no UI dependencies.
[package]
name = "noctua_core"
version = "0.1.0"
edition = "2021"
description = "Document core library for the Noctua viewer"
repository = "https://codeberg.org/wfx/noctua"
authors = ["Wolfgang Morawetz <wfx@mailbox.org>"]
license = "GPL-3.0-or-later"
[lib]
name = "noctua_core"
path = "src/lib.rs"
[features]
default = ["image", "vector", "portable"]
image = ["dep:image", "dep:kamadak-exif"]
vector = ["dep:resvg"]
portable = ["dep:pdfium-render"]
full = ["image", "vector", "portable"]
[dependencies]
# Serialization
serde = { version = "1", features = ["derive"] }
ron = "0.8"
# Feature-gated document backends
image = { version = "0.25", optional = true }
kamadak-exif = { version = "0.5", optional = true }
pdfium-render = { version = "0.8", optional = true }
resvg = { version = "0.45", optional = true }
# Logging interface
log = "0.4"
# Thumbnail cache (freedesktop.org spec)
dirs = "5.0"
md5 = "0.7"
png = "0.17"
[dev-dependencies]
# CLI test harness (examples/cli.rs only)
clap = { version = "4", features = ["derive"] }
env_logger = "0.11"

View file

@ -1,14 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/lib.rs
//
// Public API of the noctua_core library.
pub mod error;
pub mod document;
pub mod render;
pub use document::command::DocumentCommand;
pub use document::manager::DocumentManager;
pub use document::session::SessionCommand;
pub use document::types::RawImage;

View file

@ -60,13 +60,9 @@ check-json: (check '--message-format=json')
test *args:
cargo test --workspace {{args}}
# Runs the COSMIC UI application
# Runs the application (no args = COSMIC UI, with args = CLI mode)
run *args:
env RUST_BACKTRACE=full cargo run --release -p noctua_ui {{args}}
# Runs the CLI test harness (core only, no UI deps)
run-cli *args:
env RUST_BACKTRACE=full cargo run --example cli -p noctua_core {{args}}
env RUST_BACKTRACE=full cargo run --release {{args}}
# Installs files
install:

View file

@ -14,7 +14,7 @@ use self::model::{PortableModel, RasterModel, VectorModel};
/// The decoded content of a loaded document.
///
/// This enum is internal to `noctua_core`. The UI only ever sees a `RawImage`
/// This enum is internal to Noctua. The UI only ever sees a `RawImage`
/// produced by the render subsystem it never matches on this type.
#[derive(Debug)]
pub enum DocumentContent {

View file

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

View file

@ -7,6 +7,5 @@ pub mod image;
pub mod layout;
pub mod viewbox;
pub use image::RawImage;
pub use layout::PageLayout;
pub use viewbox::ViewBox;

View file

@ -1,7 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/error.rs
//
// Central error and result types for noctua_core.
// Central error and result types for Noctua.
use std::fmt;
@ -13,8 +13,6 @@ pub enum Error {
NotFound(String),
/// The file format or operation is not supported.
Unsupported(String),
/// A rendering operation failed.
Render(String),
/// A session could not be loaded or saved.
Session(String),
/// An underlying I/O error.
@ -26,7 +24,6 @@ impl fmt::Display for Error {
match self {
Error::NotFound(msg) => write!(f, "Not found: {msg}"),
Error::Unsupported(msg) => write!(f, "Unsupported: {msg}"),
Error::Render(msg) => write!(f, "Render error: {msg}"),
Error::Session(msg) => write!(f, "Session error: {msg}"),
Error::Io(err) => write!(f, "I/O error: {err}"),
}

View file

@ -1,13 +1,17 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// examples/cli.rs
// src/main.rs
//
// CLI test harness for the noctua_core document foundation.
// Entry point for the Noctua COSMIC application.
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, SessionData};
use std::path::PathBuf;
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
@ -87,50 +91,45 @@ fn print_active_session(mgr: &DocumentManager) {
}
#[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 {
#[command(name = "noctua", about = "A document viewer for the COSMIC desktop")]
struct Args {
/// Open a document (raster, vector, portable)
#[arg(long, value_name = "PATH")]
open_document: Option<PathBuf>,
#[arg(long = "open-document", value_name = "PATH")]
document: Option<PathBuf>,
/// Open a directory
#[arg(long, value_name = "PATH")]
open_directory: Option<PathBuf>,
#[arg(long = "open-directory", value_name = "PATH")]
directory: Option<PathBuf>,
/// Open a session file (.ron)
#[arg(long, value_name = "PATH")]
open_session: Option<PathBuf>,
#[arg(long = "open-session", value_name = "PATH")]
session: Option<PathBuf>,
}
fn main() {
env_logger::init();
let cli = Opt::parse();
let mut mgr = DocumentManager::new();
let args = Args::parse();
// Open source
if args.document.is_none() && args.directory.is_none() && args.session.is_none() {
// TODO: Launch COSMIC UI.
return;
}
heading("Opening source");
run_cli(args);
}
if let Some(path) = cli.open_session {
fn open_source(mgr: &mut DocumentManager, args: Args) {
if let Some(path) = args.session {
exec(
&mut mgr,
mgr,
"Open session file",
SessionCommand::OpenSession { path },
);
} else if let Some(path) = cli.open_document {
} else if let Some(path) = args.document {
let dir = path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let session_name = dir
.file_name()
@ -139,30 +138,30 @@ fn main() {
.to_string();
exec(
&mut mgr,
mgr,
&format!("New session '{session_name}'"),
SessionCommand::NewSession {
name: session_name.clone(),
},
);
exec(
&mut mgr,
mgr,
&format!("Select '{session_name}'"),
SessionCommand::SelectSession {
name: session_name.clone(),
},
);
exec(
&mut mgr,
mgr,
&format!("Add directory {}", dir.display()),
SessionCommand::AddDirectoryToSession { dir },
);
exec(
&mut mgr,
mgr,
&format!("Add document {}", path.display()),
SessionCommand::AddDocumentToSession { path },
);
} else if let Some(path) = cli.open_directory {
} else if let Some(path) = args.directory {
let session_name = path
.file_name()
.and_then(|n| n.to_str())
@ -170,69 +169,44 @@ fn main() {
.to_string();
exec(
&mut mgr,
mgr,
&format!("New session '{session_name}'"),
SessionCommand::NewSession {
name: session_name.clone(),
},
);
exec(
&mut mgr,
mgr,
&format!("Select '{session_name}'"),
SessionCommand::SelectSession {
name: session_name.clone(),
},
);
exec(
&mut mgr,
mgr,
&format!("Add directory {}", path.display()),
SessionCommand::AddDirectoryToSession { 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().map(|s| s.name.clone()) {
Some(n) => n,
None => {
info("No active session nothing more to test.");
return;
}
};
print_active_session(&mgr);
// Navigation
fn run_navigation_test(mgr: &mut DocumentManager) {
heading("Navigation");
exec(&mut mgr, "NextDocument", SessionCommand::NextDocument);
exec(mgr, "NextDocument", SessionCommand::NextDocument);
ok(&format!(
"After NextDocument → index {:?}",
mgr.active_document_index()
));
exec(
&mut mgr,
"PreviousDocument",
SessionCommand::PreviousDocument,
);
exec(mgr, "PreviousDocument", SessionCommand::PreviousDocument);
ok(&format!(
"After PreviousDocument → index {:?}",
mgr.active_document_index()
));
exec(
&mut mgr,
mgr,
"SelectDocument { index: 0 }",
SessionCommand::SelectDocument { index: 0 },
);
@ -241,42 +215,36 @@ fn main() {
mgr.active_document_index()
));
// Move operations
heading("Move operations");
step("Before MoveDocumentDown:");
print_active_session(&mgr);
print_active_session(mgr);
exec(
&mut mgr,
"MoveDocumentDown",
SessionCommand::MoveDocumentDown,
);
exec(mgr, "MoveDocumentDown", SessionCommand::MoveDocumentDown);
ok(&format!(
"After MoveDocumentDown → index {:?}",
mgr.active_document_index()
));
exec(&mut mgr, "MoveDocumentUp", SessionCommand::MoveDocumentUp);
exec(mgr, "MoveDocumentUp", SessionCommand::MoveDocumentUp);
ok(&format!(
"After MoveDocumentUp → index {:?}",
mgr.active_document_index()
));
}
// Copy / Paste
fn run_copy_paste_test(mgr: &mut DocumentManager, active_name: &str) {
heading("Copy / Paste");
exec(
&mut mgr,
mgr,
"New session 'Favorites'",
SessionCommand::NewSession {
name: "Favorites".to_string(),
},
);
exec(
&mut mgr,
mgr,
"New session 'Workspace'",
SessionCommand::NewSession {
name: "Workspace".to_string(),
@ -288,61 +256,56 @@ fn main() {
print_session(session, None);
}
// SelectSession source → SelectDocument → CopyDocument → SelectSession target → PasteDocument
exec(
&mut mgr,
mgr,
&format!("SelectSession '{active_name}'"),
SessionCommand::SelectSession {
name: active_name.clone(),
name: active_name.to_string(),
},
);
exec(
&mut mgr,
mgr,
"SelectDocument { index: 0 }",
SessionCommand::SelectDocument { index: 0 },
);
exec(&mut mgr, "CopyDocument", SessionCommand::CopyDocument);
exec(mgr, "CopyDocument", SessionCommand::CopyDocument);
exec(
&mut mgr,
mgr,
"SelectSession 'Favorites'",
SessionCommand::SelectSession {
name: "Favorites".to_string(),
},
);
exec(&mut mgr, "PasteDocument", SessionCommand::PasteDocument);
exec(mgr, "PasteDocument", SessionCommand::PasteDocument);
step("Favorites after paste:");
print_active_session(&mgr);
// Move pasted item to front via MoveDocumentTo
print_active_session(mgr);
exec(
&mut mgr,
mgr,
"MoveDocumentTo { index: 0 }",
SessionCommand::MoveDocumentTo { index: 0 },
);
step("Favorites after MoveDocumentTo(0):");
print_active_session(&mgr);
print_active_session(mgr);
// Remove active document
exec(&mut mgr, "RemoveDocument", SessionCommand::RemoveDocument);
exec(mgr, "RemoveDocument", SessionCommand::RemoveDocument);
step("Favorites after RemoveDocument:");
print_active_session(&mgr);
// Session operations
print_active_session(mgr);
}
fn run_session_test(mgr: &mut DocumentManager, active_name: &str) {
heading("Session operations");
exec(
&mut mgr,
mgr,
&format!("SelectSession '{active_name}'"),
SessionCommand::SelectSession {
name: active_name.clone(),
name: active_name.to_string(),
},
);
exec(
&mut mgr,
mgr,
"DuplicateSession 'Snapshot'",
SessionCommand::DuplicateSession {
new_name: "Snapshot".to_string(),
@ -355,14 +318,14 @@ fn main() {
}
exec(
&mut mgr,
mgr,
"SelectSession 'Workspace'",
SessionCommand::SelectSession {
name: "Workspace".to_string(),
},
);
exec(
&mut mgr,
mgr,
"RenameSession 'Archive'",
SessionCommand::RenameSession {
name: "Archive".to_string(),
@ -371,9 +334,7 @@ fn main() {
step("All sessions after rename:");
for session in mgr.sessions() {
let is_active = mgr
.active_session()
.map_or(false, |a| a.name == session.name);
let is_active = mgr.active_session().is_some_and(|a| a.name == session.name);
let marker = if is_active {
format!("{GREEN}*{RESET}")
} else {
@ -383,7 +344,7 @@ fn main() {
}
exec(
&mut mgr,
mgr,
"CloseSession (closes 'Archive')",
SessionCommand::CloseSession,
);
@ -392,6 +353,33 @@ fn main() {
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");
}

View file

@ -1,50 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# ui/Cargo.toml
#
# Binary crate for the Noctua COSMIC desktop application.
[package]
name = "noctua"
version = "0.1.0"
edition = "2021"
description = "Noctua a document viewer for the COSMIC™ desktop"
repository = "https://codeberg.org/wfx/noctua"
authors = ["Wolfgang Morawetz <wfx@mailbox.org>"]
license = "GPL-3.0-or-later"
[dependencies]
noctua_core = { path = "../core" }
# Logging
log = "0.4"
env_logger = "0.11"
# i18n / localization
i18n-embed = { version = "0.16", features = [
"fluent-system",
"desktop-requester",
] }
i18n-embed-fl = "0.10"
# Resource embedding
rust-embed = "8"
# Open files/URLs with the system default application
open = "5"
# Async runtime (required by libcosmic)
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
[dependencies.libcosmic]
git = "https://github.com/pop-os/libcosmic.git"
features = [
"a11y",
"about",
"dbus-config",
"multi-window",
"single-instance",
"tokio",
"winit",
"wayland",
"wgpu",
]

View file

@ -1,8 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/main.rs
//
// Entry point for the Noctua COSMIC application.
fn main() {
// TODO: Initialize the COSMIC application.
}