Compare commits
No commits in common. "be818269bb92c91763c08a7e9b9242b15ed99d63" and "a76993f69b1996fa79ef68123ed050c5845fe5b1" have entirely different histories.
be818269bb
...
a76993f69b
3 changed files with 5 additions and 287 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -13,4 +13,3 @@ debian/*
|
||||||
!debian/install
|
!debian/install
|
||||||
!debian/rules
|
!debian/rules
|
||||||
!debian/source
|
!debian/source
|
||||||
CLAUDE.md
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,11 @@
|
||||||
//
|
//
|
||||||
// Commands that operate on the active document itself.
|
// Commands that operate on the active document itself.
|
||||||
|
|
||||||
/// Commands operating on the active document (zoom, export, print, …).
|
/// Commands operating on the active document (zoom, export, annotate, …).
|
||||||
///
|
///
|
||||||
/// The `DocumentManager` dispatches these after resolving which document is
|
/// The `DocumentManager` dispatches these after resolving which document is
|
||||||
/// active – the UI never needs to know the underlying format.
|
/// active – the UI never needs to know the underlying format.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum DocumentCommand {
|
pub enum DocumentCommand {
|
||||||
/// Print the active document to the system printer.
|
|
||||||
Print,
|
|
||||||
// Reserved for future use: Rotate, Flip, Zoom, Export, Annotate, …
|
// Reserved for future use: Rotate, Flip, Zoom, Export, Annotate, …
|
||||||
}
|
}
|
||||||
|
|
|
||||||
287
src/main.rs
287
src/main.rs
|
|
@ -16,7 +16,6 @@ use std::ffi::OsStr;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(
|
||||||
|
|
@ -29,7 +28,7 @@ struct Args {
|
||||||
#[arg(value_name = "FILE")]
|
#[arg(value_name = "FILE")]
|
||||||
files: Vec<PathBuf>,
|
files: Vec<PathBuf>,
|
||||||
|
|
||||||
/// Compatibility with the previous harness.
|
/// Compatibility with the previous CLI harness.
|
||||||
#[arg(long = "open-document", value_name = "PATH")]
|
#[arg(long = "open-document", value_name = "PATH")]
|
||||||
document: Option<PathBuf>,
|
document: Option<PathBuf>,
|
||||||
|
|
||||||
|
|
@ -38,7 +37,7 @@ struct Args {
|
||||||
directory: Option<PathBuf>,
|
directory: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Copy, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
enum DocumentKind {
|
enum DocumentKind {
|
||||||
Raster,
|
Raster,
|
||||||
Svg,
|
Svg,
|
||||||
|
|
@ -111,20 +110,6 @@ enum Message {
|
||||||
page: usize,
|
page: usize,
|
||||||
result: Result<PathBuf, String>,
|
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 {
|
struct NoctuaApp {
|
||||||
|
|
@ -133,8 +118,6 @@ struct NoctuaApp {
|
||||||
current: usize,
|
current: usize,
|
||||||
zoom: f32,
|
zoom: f32,
|
||||||
modifiers: keyboard::Modifiers,
|
modifiers: keyboard::Modifiers,
|
||||||
printing: bool,
|
|
||||||
print_status: Option<PrintStatus>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> cosmic::iced::Result {
|
fn main() -> cosmic::iced::Result {
|
||||||
|
|
@ -197,8 +180,6 @@ impl Application for NoctuaApp {
|
||||||
current,
|
current,
|
||||||
zoom: 1.0,
|
zoom: 1.0,
|
||||||
modifiers: keyboard::Modifiers::default(),
|
modifiers: keyboard::Modifiers::default(),
|
||||||
printing: false,
|
|
||||||
print_status: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
app.refresh_title();
|
app.refresh_title();
|
||||||
|
|
@ -294,36 +275,6 @@ 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();
|
self.refresh_title();
|
||||||
|
|
@ -336,10 +287,6 @@ impl Application for NoctuaApp {
|
||||||
Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
|
Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
|
||||||
Some(Message::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 {
|
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
key: Key::Named(Named::PageUp | Named::ArrowUp | Named::ArrowLeft),
|
key: Key::Named(Named::PageUp | Named::ArrowUp | Named::ArrowLeft),
|
||||||
..
|
..
|
||||||
|
|
@ -373,7 +320,6 @@ impl Application for NoctuaApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn footer(&self) -> Option<Element<'_, Self::Message>> {
|
fn footer(&self) -> Option<Element<'_, Self::Message>> {
|
||||||
#[allow(clippy::question_mark)]
|
|
||||||
let Some(document) = self.current_document() else {
|
let Some(document) = self.current_document() else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
|
@ -395,7 +341,7 @@ impl Application for NoctuaApp {
|
||||||
format!("{} / {}", self.current + 1, self.documents.len())
|
format!("{} / {}", self.current + 1, self.documents.len())
|
||||||
};
|
};
|
||||||
|
|
||||||
let row = widget::row::with_capacity(10)
|
let row = widget::row::with_capacity(9)
|
||||||
.align_y(Alignment::Center)
|
.align_y(Alignment::Center)
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
|
|
@ -420,12 +366,6 @@ impl Application for NoctuaApp {
|
||||||
.width(Length::Fixed(36.0)),
|
.width(Length::Fixed(36.0)),
|
||||||
)
|
)
|
||||||
.push(widget::space::horizontal().width(Length::Fill))
|
.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::text::body(page_label))
|
||||||
.push(
|
.push(
|
||||||
widget::button::standard("Prev page")
|
widget::button::standard("Prev page")
|
||||||
|
|
@ -436,33 +376,7 @@ impl Application for NoctuaApp {
|
||||||
.on_press_maybe(has_next_page.then_some(Message::NextPage)),
|
.on_press_maybe(has_next_page.then_some(Message::NextPage)),
|
||||||
);
|
);
|
||||||
|
|
||||||
Some(
|
Some(row.into())
|
||||||
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(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -514,32 +428,6 @@ impl NoctuaApp {
|
||||||
self.core.set_header_title(title);
|
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> {
|
fn ensure_current_rendered(&mut self) -> Task<Message> {
|
||||||
let render_task = {
|
let render_task = {
|
||||||
let Some(document) = self.current_document_mut() else {
|
let Some(document) = self.current_document_mut() else {
|
||||||
|
|
@ -663,25 +551,6 @@ fn empty_view<'a>(message: impl Into<String>) -> Element<'a, Message> {
|
||||||
.into()
|
.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> {
|
fn scroll_delta_y(delta: mouse::ScrollDelta) -> Option<f32> {
|
||||||
let y = match delta {
|
let y = match delta {
|
||||||
mouse::ScrollDelta::Lines { y, .. } => y,
|
mouse::ScrollDelta::Lines { y, .. } => y,
|
||||||
|
|
@ -863,151 +732,3 @@ fn path_hash(path: &Path) -> u64 {
|
||||||
|
|
||||||
hasher.finish()
|
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")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue