From 42b03acd62545fe2b2e92546214b1d957dcfbd16 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Thu, 28 May 2026 17:43:36 +0200 Subject: [PATCH] Integrate Noctua with COSMIC image and PDF viewing --- Cargo.toml | 6 +- resources/org.codeberg.wfx.Noctua.desktop | 4 +- .../org.codeberg.wfx.Noctua.metainfo.xml | 4 + src/main.rs | 837 +++++++++++------- 4 files changed, 521 insertions(+), 330 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d771507..986a96f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/resources/org.codeberg.wfx.Noctua.desktop b/resources/org.codeberg.wfx.Noctua.desktop index 4cbb4b8..9bb2d0a 100644 --- a/resources/org.codeberg.wfx.Noctua.desktop +++ b/resources/org.codeberg.wfx.Noctua.desktop @@ -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; diff --git a/resources/org.codeberg.wfx.Noctua.metainfo.xml b/resources/org.codeberg.wfx.Noctua.metainfo.xml index 8abdf03..ae24682 100644 --- a/resources/org.codeberg.wfx.Noctua.metainfo.xml +++ b/resources/org.codeberg.wfx.Noctua.metainfo.xml @@ -25,10 +25,14 @@ noctua 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 diff --git a/src/main.rs b/src/main.rs index f61905a..dfe823f 100644 --- a/src/main.rs +++ b/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) { - 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, + + /// Compatibility with the previous CLI harness. #[arg(long = "open-document", value_name = "PATH")] document: Option, - /// Open a directory + /// Open all supported documents in a directory. #[arg(long = "open-directory", value_name = "PATH")] directory: Option, - - /// Open a session file (.ron) - #[arg(long = "open-session", value_name = "PATH")] - session: Option, } -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, + rendered_page: Option, + error: Option, +} + +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, + 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, + 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::(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) { + 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 { + 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 { + 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> { + 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) -> 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 { + 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, 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 { + 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::>(); + + 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 { + 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 { + 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::().ok()) + }) +} + +fn render_pdf_page(path: &Path, page: usize) -> Result { + 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() }