Compare commits

...

7 commits

Author SHA1 Message Date
be818269bb ui: opaque themed footer bar and print icon
The footer bar inherited the window transparency, making buttons and
page info unreadable over document content. Wrap it in a container
with the opaque theme background color, matching the header bar.
Add a leading printer icon to the Print button.
2026-08-09 15:37:25 +02:00
a3c87fcde4 fix: normalize raster images before img2pdf retry
img2pdf refuses images with alpha channels or more than 8 bits per
channel (common for screenshots). Normalize such images to opaque
8-bit with ImageMagick and retry img2pdf before falling back to a
full ImageMagick PDF conversion.
2026-08-09 11:34:07 +02:00
481f1ed702 feat: add visual feedback for printing
Print button is disabled while a job is being sent and a spinner shows
in the footer. On success a transient 'Sent to printer' confirmation is
displayed for a few seconds; on failure the error message stays in the
footer instead of replacing the document view.
2026-08-09 11:10:29 +02:00
fe1d7c0ebb feat: add Ctrl+P keyboard shortcut for printing
- Add KeyChar message and listener for character key presses
- Handle Ctrl+P in update to trigger print task
- Rebuild and install binary
2026-07-10 12:01:08 +02:00
2bc2abf070 fix: move Print button to footer to fix mouse scroll
- Remove menu bar from header_start (it consumed mouse wheel events)
- Add Print button in footer alongside page info
- Remove unused About message and menu code
- Scroll of mouse wheel now works correctly for page navigation
2026-07-10 11:55:10 +02:00
03fd8306a6 feat: add print support via CUPS (lp)
- Add Print command to DocumentCommand enum
- Add File menu bar with Print and About items
- Implement print_document_sync() using CUPS lp command
- Raster images converted to PDF via img2pdf (fallback: ImageMagick)
- SVG converted to PDF via pdftocairo → img2pdf
- PDF sent directly to printer via lp
- Ctrl+P keyboard shortcut (via ModifiersChanged subscription)
- Print status tracking with printing flag to prevent duplicates
2026-07-10 10:56:34 +02:00
3fd859dbdd chore: ignore local CLAUDE.md 2026-07-09 14:17:50 +02:00
3 changed files with 287 additions and 5 deletions

1
.gitignore vendored
View file

@ -13,3 +13,4 @@ debian/*
!debian/install
!debian/rules
!debian/source
CLAUDE.md

View file

@ -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, …
}

View file

@ -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<PathBuf>,
/// Compatibility with the previous CLI harness.
/// Compatibility with the previous harness.
#[arg(long = "open-document", value_name = "PATH")]
document: Option<PathBuf>,
@ -37,7 +38,7 @@ struct Args {
directory: Option<PathBuf>,
}
#[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<PathBuf, String>,
},
// 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<PrintStatus>,
}
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<Element<'_, Self::Message>> {
#[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<Message> {
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<Message> {
let render_task = {
let Some(document) = self.current_document_mut() else {
@ -551,6 +663,25 @@ fn empty_view<'a>(message: impl Into<String>) -> 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<f32> {
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<PathBuf, String> {
// 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<PathBuf, String> {
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<PathBuf, String> {
// 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<String>,
source_path: &Path,
) -> Result<PathBuf, String> {
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")
}