Integrate Noctua with COSMIC image and PDF viewing
This commit is contained in:
parent
79cc199d11
commit
42b03acd62
4 changed files with 521 additions and 330 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ Name=Noctua
|
|||
Comment=A wise document and image viewer for the COSMIC™ desktop
|
||||
Type=Application
|
||||
Icon=org.codeberg.wfx.Noctua
|
||||
Exec=noctua %F
|
||||
Exec=/usr/bin/noctua %F
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
Categories=Graphics;Viewer;Utility;
|
||||
StartupWMClass=org.codeberg.wfx.Noctua
|
||||
Keywords=image;document;pdf;viewer;cosmic;
|
||||
MimeType=image/png;image/jpeg;image/gif;image/webp;image/bmp;image/tiff;image/svg+xml;application/pdf;
|
||||
MimeType=image/png;image/x-png;image/jpeg;image/jpg;image/pjpeg;image/gif;image/webp;image/bmp;image/x-bmp;image/tiff;image/svg+xml;application/pdf;
|
||||
|
|
|
|||
|
|
@ -25,10 +25,14 @@
|
|||
<provides>
|
||||
<binary>noctua</binary>
|
||||
<mediatype>image/png</mediatype>
|
||||
<mediatype>image/x-png</mediatype>
|
||||
<mediatype>image/jpeg</mediatype>
|
||||
<mediatype>image/jpg</mediatype>
|
||||
<mediatype>image/pjpeg</mediatype>
|
||||
<mediatype>image/gif</mediatype>
|
||||
<mediatype>image/webp</mediatype>
|
||||
<mediatype>image/bmp</mediatype>
|
||||
<mediatype>image/x-bmp</mediatype>
|
||||
<mediatype>image/tiff</mediatype>
|
||||
<mediatype>image/svg+xml</mediatype>
|
||||
<mediatype>application/pdf</mediatype>
|
||||
|
|
|
|||
837
src/main.rs
837
src/main.rs
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue