diff --git a/.gitignore b/.gitignore index 0e76d10..f0973f6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ debian/* !debian/install !debian/rules !debian/source +CLAUDE.md diff --git a/src/document/command.rs b/src/document/command.rs index 7fe0ee1..7f0ab61 100644 --- a/src/document/command.rs +++ b/src/document/command.rs @@ -3,11 +3,13 @@ // // Commands that operate on the active document itself. -/// Commands operating on the active document (zoom, export, annotate, …). +/// Commands operating on the active document (zoom, export, print, …). /// /// 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 { + /// Print the active document to the system printer. + Print, // Reserved for future use: Rotate, Flip, Zoom, Export, Annotate, … } diff --git a/src/main.rs b/src/main.rs index 0478017..375c042 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use std::ffi::OsStr; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::Duration; #[derive(Parser, Debug)] #[command( @@ -28,7 +29,7 @@ struct Args { #[arg(value_name = "FILE")] files: Vec, - /// Compatibility with the previous CLI harness. + /// Compatibility with the previous harness. #[arg(long = "open-document", value_name = "PATH")] document: Option, @@ -37,7 +38,7 @@ struct Args { directory: Option, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Copy, Eq, PartialEq)] enum DocumentKind { Raster, Svg, @@ -110,6 +111,20 @@ enum Message { page: usize, result: Result, }, + // Print messages + Print, + PrintTask { + result: Result<(), String>, + }, + ClearPrintStatus, + KeyChar(Key), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum PrintStatus { + Sending, + Sent, + Failed(String), } struct NoctuaApp { @@ -118,6 +133,8 @@ struct NoctuaApp { current: usize, zoom: f32, modifiers: keyboard::Modifiers, + printing: bool, + print_status: Option, } fn main() -> cosmic::iced::Result { @@ -180,6 +197,8 @@ impl Application for NoctuaApp { current, zoom: 1.0, modifiers: keyboard::Modifiers::default(), + printing: false, + print_status: None, }; app.refresh_title(); @@ -275,6 +294,36 @@ impl Application for NoctuaApp { } } } + // Print handling + Message::Print => return self.start_print(), + Message::KeyChar(key) => { + // Handle Ctrl+P shortcut for printing + if self.modifiers.control() && key == Key::Character("p".into()) { + return self.start_print(); + } + } + Message::PrintTask { result } => { + self.printing = false; + match result { + Ok(()) => { + self.print_status = Some(PrintStatus::Sent); + return Task::perform( + tokio::task::spawn_blocking(|| { + std::thread::sleep(Duration::from_secs(4)); + }), + |_| cosmic::Action::App(Message::ClearPrintStatus), + ); + } + Err(err) => { + self.print_status = Some(PrintStatus::Failed(err)); + } + } + } + Message::ClearPrintStatus => { + if matches!(self.print_status, Some(PrintStatus::Sent)) { + self.print_status = None; + } + } } self.refresh_title(); @@ -287,6 +336,10 @@ impl Application for NoctuaApp { Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { Some(Message::ModifiersChanged(modifiers)) } + Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Character(c), + .. + }) if c == "p" => Some(Message::KeyChar(Key::Character(c))), Event::Keyboard(keyboard::Event::KeyPressed { key: Key::Named(Named::PageUp | Named::ArrowUp | Named::ArrowLeft), .. @@ -320,6 +373,7 @@ impl Application for NoctuaApp { } fn footer(&self) -> Option> { + #[allow(clippy::question_mark)] let Some(document) = self.current_document() else { return None; }; @@ -341,7 +395,7 @@ impl Application for NoctuaApp { format!("{} / {}", self.current + 1, self.documents.len()) }; - let row = widget::row::with_capacity(9) + let row = widget::row::with_capacity(10) .align_y(Alignment::Center) .spacing(8) .padding(8) @@ -366,6 +420,12 @@ impl Application for NoctuaApp { .width(Length::Fixed(36.0)), ) .push(widget::space::horizontal().width(Length::Fill)) + .push( + widget::button::standard("Print") + .leading_icon(widget::icon::from_name("printer-symbolic").size(16).handle()) + .on_press_maybe((!self.printing).then_some(Message::Print)), + ) + .push(print_status_view(self.print_status.as_ref())) .push(widget::text::body(page_label)) .push( widget::button::standard("Prev page") @@ -376,7 +436,33 @@ impl Application for NoctuaApp { .on_press_maybe(has_next_page.then_some(Message::NextPage)), ); - Some(row.into()) + Some( + widget::container(row) + .width(Length::Fill) + .class(theme::Container::custom(|t| { + let cosmic = t.cosmic(); + let background = cosmic.background(false); + cosmic::iced_widget::container::Style { + icon_color: Some(background.on.into()), + text_color: Some(background.on.into()), + background: Some(cosmic::iced::Background::Color( + background.base.into(), + )), + border: cosmic::iced::Border { + radius: [ + 0.0, + 0.0, + cosmic.corner_radii.radius_s[2], + cosmic.corner_radii.radius_s[3], + ] + .into(), + ..Default::default() + }, + ..Default::default() + } + })) + .into(), + ) } } @@ -428,6 +514,32 @@ impl NoctuaApp { self.core.set_header_title(title); } + fn start_print(&mut self) -> Task { + if self.printing { + return Task::none(); + } + + let document = match self.current_document() { + Some(doc) => doc, + None => return Task::none(), + }; + let path = document.path.clone(); + let kind = document.kind; + + self.printing = true; + self.print_status = Some(PrintStatus::Sending); + + Task::perform( + tokio::task::spawn_blocking(move || print_document_sync(&path, kind)), + |result| { + let inner = result + .map_err(|e| format!("Spawn error: {e}")) + .and_then(|r| r); + cosmic::Action::App(Message::PrintTask { result: inner }) + }, + ) + } + fn ensure_current_rendered(&mut self) -> Task { let render_task = { let Some(document) = self.current_document_mut() else { @@ -551,6 +663,25 @@ fn empty_view<'a>(message: impl Into) -> Element<'a, Message> { .into() } +fn print_status_view(status: Option<&PrintStatus>) -> Element<'_, Message> { + match status { + Some(PrintStatus::Sending) => widget::progress_bar::indeterminate_circular() + .size(16.0) + .bar_height(2.0) + .into(), + Some(PrintStatus::Sent) => widget::text::body("Sent to printer") + .class(theme::Text::Accent) + .into(), + Some(PrintStatus::Failed(err)) => widget::text::body(format!("Print failed: {err}")) + .class(theme::Text::Custom(|t| cosmic::iced_widget::text::Style { + color: Some(t.cosmic().destructive_color().into()), + ..Default::default() + })) + .into(), + None => widget::space::horizontal().width(Length::Fixed(0.0)).into(), + } +} + fn scroll_delta_y(delta: mouse::ScrollDelta) -> Option { let y = match delta { mouse::ScrollDelta::Lines { y, .. } => y, @@ -732,3 +863,151 @@ fn path_hash(path: &Path) -> u64 { hasher.finish() } + +// ─── Print service ────────────────────────────────────────────────────────── + +/// Print the currently active document to the system printer via CUPS (lp). +/// +/// - **PDF** → sent directly to CUPS via `lp` +/// - **Raster images** → converted to PDF via `img2pdf` (normalized to +/// opaque 8-bit when needed, or `convert` as fallback) +/// - **SVG** → converted to PDF via `pdftocairo` → `img2pdf` +fn print_document_sync(path: &Path, kind: DocumentKind) -> Result<(), String> { + let pdf_path = match kind { + DocumentKind::Pdf => path.to_path_buf(), + DocumentKind::Raster => convert_raster_to_pdf(path)?, + DocumentKind::Svg => convert_svg_to_pdf(path)?, + }; + + send_to_printer(&pdf_path) +} + +fn convert_raster_to_pdf(path: &Path) -> Result { + // Try img2pdf first (cleaner, no quality loss) + if let Ok(result) = convert_with_cmd("img2pdf", vec![path.to_string_lossy().into_owned()], path) + { + return Ok(result); + } + + // img2pdf refuses alpha channels and >8 bit channels: normalize to + // opaque 8-bit and retry before falling back to ImageMagick. + if let Ok(normalized) = normalize_raster(path) { + let result = convert_with_cmd( + "img2pdf", + vec![normalized.to_string_lossy().into_owned()], + path, + ); + let _ = std::fs::remove_file(&normalized); + if let Ok(result) = result { + return Ok(result); + } + } + + // Fallback: ImageMagick convert + convert_with_cmd("convert", vec![path.to_string_lossy().into_owned()], path) +} + +fn normalize_raster(path: &Path) -> Result { + let extension = path.extension().and_then(OsStr::to_str).unwrap_or("png"); + let output_path = temp_prefix(path, "normalized").with_extension(extension); + + let status = Command::new("convert") + .arg(path) + .args(["-depth", "8", "-alpha", "off"]) + .arg(&output_path) + .status() + .map_err(|e| format!("Failed to run convert: {e}"))?; + if !status.success() { + return Err("convert failed".to_string()); + } + + if output_path.exists() { + Ok(output_path) + } else { + Err("convert did not produce output".to_string()) + } +} + +fn convert_svg_to_pdf(path: &Path) -> Result { + // Render SVG to PNG with pdftocairo, then convert PNG to PDF + let prefix = temp_prefix(path, "svg"); + let status = Command::new("pdftocairo") + .arg("-png") + .arg("-singlefile") + .arg("-f") + .arg("1") + .arg("-l") + .arg("1") + .arg("-scale-to") + .arg("2400") + .arg(path) + .arg(&prefix) + .status() + .map_err(|e| format!("Failed to run pdftocairo: {e}"))?; + + if !status.success() { + return Err("Failed to render SVG with pdftocairo".to_string()); + } + + let png_path = PathBuf::from(format!("{}.png", prefix.display())); + if !png_path.exists() { + return Err("pdftocairo did not produce output".to_string()); + } + + convert_raster_to_pdf(&png_path) +} + +fn convert_with_cmd( + cmd_name: &str, + args: Vec, + source_path: &Path, +) -> Result { + let output_path = temp_prefix(source_path, "print"); + + let mut cmd = Command::new(cmd_name); + if cmd_name == "img2pdf" { + cmd.args(&args).arg("-o").arg(&output_path); + } else { + // For convert, the last arg is the output + if !args.is_empty() { + cmd.args(&args[..args.len().saturating_sub(1)]) + .arg(&output_path); + } + } + + let status = cmd + .status() + .map_err(|e| format!("Failed to run {}: {e}", cmd_name))?; + if !status.success() { + return Err(format!("{} failed", cmd_name)); + } + + if output_path.exists() { + Ok(output_path) + } else { + Err(format!("{} did not produce output", cmd_name)) + } +} + +fn send_to_printer(pdf_path: &Path) -> Result<(), String> { + let status = Command::new("lp") + .arg(pdf_path) + .status() + .map_err(|e| format!("Failed to run lp: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to send document to printer".to_string()) + } +} + +fn temp_prefix(source_path: &Path, prefix: &str) -> PathBuf { + std::env::temp_dir() + .join(format!( + "noctua-print-{}-{}", + path_hash(source_path), + prefix + )) + .with_extension("pdf") +}