Integrate Noctua with COSMIC image and PDF viewing

This commit is contained in:
Lionel DARNIS 2026-05-28 17:43:36 +02:00
parent 79cc199d11
commit 42b03acd62
4 changed files with 521 additions and 330 deletions

View file

@ -58,8 +58,10 @@ 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"
[dependencies.cosmic]
package = "libcosmic-yoda"
path = "../libcosmic"
default-features = false
features = [
"a11y",
"about",

View file

@ -3,10 +3,10 @@ Name=Noctua
Comment=A wise document and image viewer for the COSMIC desktop
Type=Application
Icon=org.codeberg.wfx.Noctua
Exec=noctua %F
Exec=/usr/bin/noctua %F
Terminal=false
StartupNotify=true
Categories=Graphics;Viewer;Utility;
StartupWMClass=org.codeberg.wfx.Noctua
Keywords=image;document;pdf;viewer;cosmic;
MimeType=image/png;image/jpeg;image/gif;image/webp;image/bmp;image/tiff;image/svg+xml;application/pdf;
MimeType=image/png;image/x-png;image/jpeg;image/jpg;image/pjpeg;image/gif;image/webp;image/bmp;image/x-bmp;image/tiff;image/svg+xml;application/pdf;

View file

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

View file

@ -1,370 +1,555 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/main.rs
//
// Entry point for the Noctua COSMIC application.
mod document;
mod error;
mod render;
// Minimal COSMIC viewer entry point for images and PDFs.
use clap::Parser;
use document::manager::{DocumentManager, DocumentState};
use document::session::command::SessionCommand;
use document::session::data::{CollectionKind, SessionData};
use cosmic::app::{Core, Settings, Task};
use cosmic::iced::{
event, keyboard, mouse, Alignment, ContentFit, Event, Length, Limits, Subscription,
};
use cosmic::{executor, theme, widget, Application, Element};
use std::collections::hash_map::DefaultHasher;
use std::ffi::OsStr;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
// 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");
}
}
use std::process::Command;
#[derive(Parser, Debug)]
#[command(name = "noctua", about = "A document viewer for the COSMIC desktop")]
#[command(
name = "noctua",
version,
about = "A document and image viewer for COSMIC"
)]
struct Args {
/// Open a document (raster, vector, portable)
/// Files to open.
#[arg(value_name = "FILE")]
files: Vec<PathBuf>,
/// Compatibility with the previous CLI harness.
#[arg(long = "open-document", value_name = "PATH")]
document: Option<PathBuf>,
/// Open a directory
/// Open all supported documents in 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();
#[derive(Clone, Debug, Eq, PartialEq)]
enum DocumentKind {
Raster,
Svg,
Pdf,
}
#[derive(Clone, Debug)]
struct Document {
path: PathBuf,
kind: DocumentKind,
page: usize,
page_count: Option<usize>,
rendered_page: Option<PathBuf>,
error: Option<String>,
}
impl Document {
fn new(path: PathBuf, kind: DocumentKind) -> Self {
let page_count = (kind == DocumentKind::Pdf)
.then(|| pdf_page_count(&path))
.flatten();
Self {
path,
kind,
page: 0,
page_count,
rendered_page: None,
error: None,
}
}
fn name(&self) -> String {
self.path
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("Noctua")
.to_string()
}
}
#[derive(Clone, Debug)]
struct Flags {
documents: Vec<Document>,
initial_index: usize,
}
#[derive(Debug, Clone)]
enum Message {
PreviousDocument,
NextDocument,
PreviousPage,
NextPage,
ZoomIn,
ZoomOut,
ResetZoom,
ModifiersChanged(keyboard::Modifiers),
ScrollZoom(f32),
}
struct NoctuaApp {
core: Core,
documents: Vec<Document>,
current: usize,
zoom: f32,
modifiers: keyboard::Modifiers,
}
fn main() -> cosmic::iced::Result {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let args = Args::parse();
let flags = Flags::from_args(args);
if args.document.is_none() && args.directory.is_none() && args.session.is_none() {
// TODO: Launch COSMIC UI.
return;
}
let settings = Settings::default().size_limits(Limits::NONE.min_width(480.0).min_height(320.0));
run_cli(args);
cosmic::app::run::<NoctuaApp>(settings, flags)
}
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);
impl Flags {
fn from_args(args: Args) -> Self {
let mut requested = args.files;
let session_name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Browse")
.to_string();
if let Some(path) = args.document {
requested.push(path);
}
exec(
mgr,
&format!("New session '{session_name}'"),
SessionCommand::NewSession {
name: session_name.clone(),
},
);
exec(
mgr,
&format!("Select '{session_name}'"),
SessionCommand::SelectSession { name: session_name },
);
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();
if let Some(path) = args.directory {
requested.push(path);
}
exec(
mgr,
&format!("New session '{session_name}'"),
SessionCommand::NewSession {
name: session_name.clone(),
},
);
exec(
mgr,
&format!("Select '{session_name}'"),
SessionCommand::SelectSession { name: session_name },
);
exec(
mgr,
&format!("Add directory {}", path.display()),
SessionCommand::AddDirectoryToSession { dir: path },
);
let (documents, initial_index) = collect_documents(&requested);
Self {
documents,
initial_index,
}
}
}
fn run_navigation_test(mgr: &mut DocumentManager) {
heading("Navigation");
impl Application for NoctuaApp {
type Executor = executor::Default;
type Flags = Flags;
type Message = Message;
exec(mgr, "NextDocument", SessionCommand::NextDocument);
ok(&format!(
"After NextDocument → index {:?}",
mgr.active_document_index()
));
const APP_ID: &'static str = "org.codeberg.wfx.Noctua";
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);
fn core(&self) -> &Core {
&self.core
}
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);
fn core_mut(&mut self) -> &mut Core {
&mut self.core
}
exec(
mgr,
"SelectSession 'Workspace'",
SessionCommand::SelectSession {
name: "Workspace".to_string(),
},
);
exec(
mgr,
"RenameSession 'Archive'",
SessionCommand::RenameSession {
name: "Archive".to_string(),
},
);
fn init(mut core: Core, flags: Self::Flags) -> (Self, Task<Self::Message>) {
core.window.content_container = false;
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}")
let current = flags
.initial_index
.min(flags.documents.len().saturating_sub(1));
let mut app = Self {
core,
documents: flags.documents,
current,
zoom: 1.0,
modifiers: keyboard::Modifiers::default(),
};
println!(" {marker} {}", session.name);
app.refresh_title();
app.ensure_current_rendered();
(app, Task::none())
}
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
match message {
Message::PreviousDocument => {
if self.current > 0 {
self.current -= 1;
self.zoom = 1.0;
}
}
Message::NextDocument => {
if self.current + 1 < self.documents.len() {
self.current += 1;
self.zoom = 1.0;
}
}
Message::PreviousPage => {
if let Some(document) = self.current_document_mut() {
if document.kind == DocumentKind::Pdf && document.page > 0 {
document.page -= 1;
document.rendered_page = None;
}
}
}
Message::NextPage => {
if let Some(document) = self.current_document_mut() {
if document.kind == DocumentKind::Pdf {
let page_count = document.page_count.unwrap_or(document.page + 2);
if document.page + 1 < page_count {
document.page += 1;
document.rendered_page = None;
}
}
}
}
Message::ZoomIn => {
self.zoom_in();
}
Message::ZoomOut => {
self.zoom_out();
}
Message::ResetZoom => {
self.zoom = 1.0;
}
Message::ModifiersChanged(modifiers) => {
self.modifiers = modifiers;
}
Message::ScrollZoom(delta) => {
if self.modifiers.control() {
if delta > 0.0 {
self.zoom_in();
} else if delta < 0.0 {
self.zoom_out();
}
}
}
}
self.refresh_title();
self.ensure_current_rendered();
Task::none()
}
fn subscription(&self) -> Subscription<Self::Message> {
event::listen_raw(|event, status, _| match event {
Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
Some(Message::ModifiersChanged(modifiers))
}
Event::Mouse(mouse::Event::WheelScrolled { delta })
if status == event::Status::Ignored =>
{
scroll_delta_y(delta).map(Message::ScrollZoom)
}
_ => None,
})
}
fn view(&self) -> Element<'_, Self::Message> {
let content = match self.current_document() {
Some(document) => self.document_view(document),
None => empty_view("No document selected"),
};
widget::container(content)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.class(theme::Container::WindowBackground)
.into()
}
fn footer(&self) -> Option<Element<'_, Self::Message>> {
let Some(document) = self.current_document() else {
return None;
};
let has_previous_document = self.current > 0;
let has_next_document = self.current + 1 < self.documents.len();
let has_previous_page = document.kind == DocumentKind::Pdf && document.page > 0;
let has_next_page = document.kind == DocumentKind::Pdf
&& document
.page_count
.is_none_or(|page_count| document.page + 1 < page_count);
let page_label = if document.kind == DocumentKind::Pdf {
match document.page_count {
Some(page_count) => format!("Page {} / {}", document.page + 1, page_count),
None => format!("Page {}", document.page + 1),
}
} else {
format!("{} / {}", self.current + 1, self.documents.len())
};
let row = widget::row::with_capacity(9)
.align_y(Alignment::Center)
.spacing(8)
.padding(8)
.push(
widget::button::standard("Previous")
.on_press_maybe(has_previous_document.then_some(Message::PreviousDocument)),
)
.push(
widget::button::standard("Next")
.on_press_maybe(has_next_document.then_some(Message::NextDocument)),
)
.push(widget::space::horizontal().width(Length::Fill))
.push(
widget::button::standard("-")
.on_press(Message::ZoomOut)
.width(Length::Fixed(36.0)),
)
.push(widget::button::standard("100%").on_press(Message::ResetZoom))
.push(
widget::button::standard("+")
.on_press(Message::ZoomIn)
.width(Length::Fixed(36.0)),
)
.push(widget::space::horizontal().width(Length::Fill))
.push(widget::text::body(page_label))
.push(
widget::button::standard("Prev page")
.on_press_maybe(has_previous_page.then_some(Message::PreviousPage)),
)
.push(
widget::button::standard("Next page")
.on_press_maybe(has_next_page.then_some(Message::NextPage)),
);
Some(row.into())
}
}
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"),
impl NoctuaApp {
fn zoom_in(&mut self) {
self.zoom = (self.zoom * 1.2).min(5.0);
}
let Some(active_name) = mgr.active_session().map(|s| s.name.clone()) else {
info("No active session nothing more to test.");
return;
fn zoom_out(&mut self) {
self.zoom = (self.zoom / 1.2).max(0.2);
}
fn current_document(&self) -> Option<&Document> {
self.documents.get(self.current)
}
fn current_document_mut(&mut self) -> Option<&mut Document> {
self.documents.get_mut(self.current)
}
fn refresh_title(&mut self) {
let title = self
.current_document()
.map_or_else(|| "Noctua".to_string(), Document::name);
self.core.set_header_title(title);
}
fn ensure_current_rendered(&mut self) {
let Some(document) = self.current_document_mut() else {
return;
};
if document.kind != DocumentKind::Pdf || document.rendered_page.is_some() {
return;
}
match render_pdf_page(&document.path, document.page) {
Ok(rendered) => {
document.rendered_page = Some(rendered);
document.error = None;
}
Err(err) => {
document.rendered_page = None;
document.error = Some(err);
}
}
}
fn document_view(&self, document: &Document) -> Element<'_, Message> {
if let Some(error) = &document.error {
return empty_view(error);
}
match document.kind {
DocumentKind::Raster => widget::image(widget::image::Handle::from_path(&document.path))
.content_fit(ContentFit::Contain)
.width(Length::Fill)
.height(Length::Fill)
.scale(self.zoom)
.into(),
DocumentKind::Svg => widget::svg(widget::svg::Handle::from_path(&document.path))
.content_fit(ContentFit::Contain)
.width(Length::Fill)
.height(Length::Fill)
.into(),
DocumentKind::Pdf => match &document.rendered_page {
Some(path) => widget::image(widget::image::Handle::from_path(path))
.content_fit(ContentFit::Contain)
.width(Length::Fill)
.height(Length::Fill)
.scale(self.zoom)
.into(),
None => empty_view("Rendering PDF page..."),
},
}
}
}
fn empty_view<'a>(message: impl Into<String>) -> Element<'a, Message> {
widget::column::with_capacity(3)
.align_x(Alignment::Center)
.spacing(8)
.push(widget::space::vertical())
.push(widget::icon::from_name("image-x-generic-symbolic").size(64))
.push(widget::text::body(message.into()))
.push(widget::space::vertical())
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn scroll_delta_y(delta: mouse::ScrollDelta) -> Option<f32> {
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } => y,
mouse::ScrollDelta::Pixels { y, .. } => y / 60.0,
};
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");
(y.abs() > f32::EPSILON).then_some(y)
}
fn collect_documents(requested: &[PathBuf]) -> (Vec<Document>, usize) {
if requested.is_empty() {
return (Vec::new(), 0);
}
if requested.len() == 1 {
let path = &requested[0];
if path.is_dir() {
return (scan_directory(path), 0);
}
if let Some(parent) = path.parent() {
let documents = scan_directory(parent);
if let Some(index) = documents
.iter()
.position(|document| same_path(&document.path, path))
{
return (documents, index);
}
}
}
let documents = requested
.iter()
.flat_map(|path| {
if path.is_dir() {
scan_directory(path)
} else {
detect_kind(path)
.map(|kind| vec![Document::new(path.clone(), kind)])
.unwrap_or_default()
}
})
.collect();
(documents, 0)
}
fn scan_directory(dir: &Path) -> Vec<Document> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut paths = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_file())
.filter_map(|path| detect_kind(&path).map(|kind| (path, kind)))
.collect::<Vec<_>>();
paths.sort_by(|(left, _), (right, _)| left.cmp(right));
paths
.into_iter()
.map(|(path, kind)| Document::new(path, kind))
.collect()
}
fn detect_kind(path: &Path) -> Option<DocumentKind> {
match path
.extension()
.and_then(OsStr::to_str)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("pdf") => Some(DocumentKind::Pdf),
Some("svg") => Some(DocumentKind::Svg),
Some("png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tiff" | "tif") => {
Some(DocumentKind::Raster)
}
_ => None,
}
}
fn same_path(left: &Path, right: &Path) -> bool {
left == right || left.canonicalize().ok().as_deref() == right.canonicalize().ok().as_deref()
}
fn pdf_page_count(path: &Path) -> Option<usize> {
let output = Command::new("pdfinfo").arg(path).output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.lines().find_map(|line| {
line.strip_prefix("Pages:")
.and_then(|value| value.trim().parse::<usize>().ok())
})
}
fn render_pdf_page(path: &Path, page: usize) -> Result<PathBuf, String> {
let prefix = std::env::temp_dir().join(format!(
"noctua-pdf-{}-{}",
path_hash(path),
page.saturating_add(1)
));
let output = prefix.with_extension("png");
let status = Command::new("pdftocairo")
.arg("-png")
.arg("-singlefile")
.arg("-f")
.arg(page.saturating_add(1).to_string())
.arg("-l")
.arg(page.saturating_add(1).to_string())
.arg("-scale-to")
.arg("2400")
.arg(path)
.arg(&prefix)
.status()
.map_err(|err| format!("Failed to run pdftocairo: {err}"))?;
if status.success() && output.exists() {
Ok(output)
} else {
Err(format!(
"Failed to render page {} of {}",
page.saturating_add(1),
path.display()
))
}
}
fn path_hash(path: &Path) -> u64 {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
hasher.finish()
}