// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! A print dialog with a preview of the pages, and the system's printers through CUPS. The app renders the //! document to PDF itself: the dialog asks it for a preview whenever the layout changes, and says where to //! print and how once the user accepts. //! //! ```rust,no_run //! # use ltk::{ ChannelSender, Element }; //! # use ltk::print::{ Destination, PrintDialog, PrintDialogMsg, PrintEvent }; //! # #[ derive( Clone ) ] enum Msg { Print( PrintDialogMsg ) } //! # struct App { print: Option>, sender: ChannelSender } //! # impl App { //! fn open( &mut self ) //! { //! self.print = Some( PrintDialog::new( &self.sender, Msg::Print, "Report" ).preview( true ) ); //! } //! //! fn update( &mut self, msg: Msg ) //! { //! let Msg::Print( msg ) = msg; //! let Some( dialog ) = &mut self.print else { return }; //! match dialog.update( msg ) //! { //! Some( PrintEvent::Preview( options ) ) => { /* render, then send PrintDialogMsg::previewed( .. ) */ } //! Some( PrintEvent::Print( job ) ) => { self.print = None; /* render job.options, then ltk::print::send or save */ } //! Some( PrintEvent::Cancelled ) => self.print = None, //! None => {} //! } //! } //! # fn view( &self ) -> Element { self.print.as_ref().unwrap().view() } //! # } //! ``` mod options; mod view; pub mod gtk; #[ cfg( all( feature = "wayland", target_os = "linux" ) ) ] mod portal; #[ cfg( all( unix, not( target_os = "android" ), not( target_arch = "wasm32" ) ) ) ] mod cups; #[ cfg( not( all( unix, not( target_os = "android" ), not( target_arch = "wasm32" ) ) ) ) ] #[ path = "none.rs" ] mod cups; pub use options::{ format_pages, parse_pages, Margins, Orientation, Paper, PrintOptions, Sides, PAPERS }; /// CUPS's calls themselves, for a program without ltk's loop; each blocks. pub mod blocking { pub use super::cups::{ cancel, jobs, print, printers, set_default }; } use std::sync::Arc; use crate::app::ChannelSender; use crate::types::WidgetId; use crate::wallpaper::ImageData; use crate::widget::Element; /// The field with the pages to print. pub const PAGES: WidgetId = WidgetId( "ltk.print.pages" ); /// How wide, in pixels, the pages of a preview are best rendered. pub const PREVIEW_WIDTH: u32 = 480; #[ derive( Clone, Copy, Debug, PartialEq, Eq ) ] pub enum PrinterState { Idle, Printing, Stopped, Unknown, } #[ derive( Clone, Debug, PartialEq ) ] pub struct Printer { /// The queue's name, which CUPS knows it by. pub name: String, pub instance: Option, /// What to call it: its description, or else its name. pub description: String, pub location: String, pub model: String, pub state: PrinterState, /// IPP's `printer-state-reasons`, empty when there are none. pub reasons: String, pub accepting: bool, pub default: bool, } #[ derive( Clone, Copy, Debug, PartialEq, Eq ) ] pub enum JobState { Pending, Held, Printing, Stopped, Cancelled, Aborted, Completed, } impl JobState { pub( crate ) fn from_ipp( state: i32 ) -> Self { match state { 3 => JobState::Pending, 4 => JobState::Held, 5 => JobState::Printing, 6 => JobState::Stopped, 7 => JobState::Cancelled, 8 => JobState::Aborted, _ => JobState::Completed, } } } #[ derive( Clone, Debug, PartialEq ) ] pub struct Job { pub id: i32, pub printer: String, pub title: String, pub state: JobState, pub size_kb: u32, /// Seconds since the epoch. pub created: i64, } /// What the print portal's dialog chose, which it prints with. #[ derive( Clone, Debug, PartialEq, Eq ) ] pub struct PortalToken( pub( crate ) u32 ); #[ derive( Clone, Debug, PartialEq ) ] pub enum Destination { Printer( Printer ), Pdf, /// The desktop prints for an app in a sandbox, where the user chose in its own dialog. Portal( PortalToken ), } /// What to print, where and how, once the user accepted. #[ derive( Clone, Debug, PartialEq ) ] pub struct PrintJob { pub destination: Destination, pub options: PrintOptions, pub title: String, } #[ derive( Clone, Debug ) ] pub struct PreviewPage { /// In the document, from 0. pub index: usize, pub image: ImageData, } /// The pages chosen, rendered with the options the dialog asked for. #[ derive( Clone, Debug ) ] pub struct Preview { /// The pages of the whole document. pub total: usize, pub pages: Vec, } /// What the app has to do for the dialog. #[ derive( Clone, Debug, PartialEq ) ] pub enum PrintEvent { /// Render a preview with these options and answer with [`PrintDialogMsg::previewed`]. Preview( PrintOptions ), Print( PrintJob ), Cancelled, } /// A setting whose choices the dialog shows under it. #[ derive( Clone, Copy, Debug, PartialEq, Eq ) ] pub enum Section { Destination, Paper, Orientation, Margins, Sides, } #[ derive( Clone, Debug ) ] pub enum PrintDialogMsg { Start, Printers( Result, String> ), /// The portal's dialog closed: the options and token, `None` when cancelled. Prepared( Result, String> ), Previewed( Result ), Toggle( Section ), /// A printer by its place in the list, or `None` for a PDF. Destination( Option ), Copies( i32 ), AllPages, SomePages, Pages( String ), Paper( usize ), Orientation( Orientation ), Margins( Margins ), Scale( i32 ), Color, Sides( Sides ), Backgrounds, Page( i32 ), Print, Cancel, } impl PrintDialogMsg { /// The app's answer to [`PrintEvent::Preview`]. pub fn previewed( result: Result ) -> Self { PrintDialogMsg::Previewed( result ) } } const SCALE_STEP: i32 = 10; pub struct PrintDialog { pub( crate ) title: String, pub( crate ) printers: Vec, pub( crate ) listing: bool, pub( crate ) list_error: Option, /// A printer of `printers`, or `None` for a PDF. pub( crate ) destination: Option, pub( crate ) wanted: Option, pub( crate ) prefer_pdf: bool, pub( crate ) options: PrintOptions, pub( crate ) some_pages: bool, pub( crate ) pages_text: String, pub( crate ) pages_bad: bool, pub( crate ) open: Option
, pub( crate ) previews: bool, pub( crate ) preview: Option, /// The preview's images as shown, in grey when printing without colour. pub( crate ) images: Vec, pub( crate ) preview_error: Option, /// The options a preview is being rendered with. pub( crate ) rendering: Option, pub( crate ) shown: usize, pub( crate ) compact: bool, /// The portal shows the dialog instead. pub( crate ) portal: bool, focus: Option, sender: ChannelSender, pub( crate ) wrap: Arc Msg + Send + Sync>, } impl PrintDialog { /// A dialog for printing the document called `title`. `wrap` makes the app's message of the dialog's own. pub fn new( sender: &ChannelSender, wrap: impl Fn( PrintDialogMsg ) -> Msg + Send + Sync + 'static, title: impl Into ) -> Self { let wrap: Arc Msg + Send + Sync> = Arc::new( wrap ); let _ = sender.send( wrap( PrintDialogMsg::Start ) ); let portal = sandboxed(); if !portal { let reply = Arc::clone( &wrap ); list_printers( sender, move |result| reply( PrintDialogMsg::Printers( result ) ) ); } Self { title: title.into(), printers: Vec::new(), listing: !portal, list_error: None, destination: None, wanted: None, prefer_pdf: false, options: PrintOptions::default(), some_pages: false, pages_text: String::new(), pages_bad: false, open: None, previews: false, preview: None, images: Vec::new(), preview_error: None, rendering: None, shown: 0, compact: false, portal, focus: None, sender: sender.clone(), wrap, } } /// Ask the app for a preview of the pages, which it renders on [`PrintEvent::Preview`]. pub fn preview( mut self, on: bool ) -> Self { self.previews = on; self } /// Start with these options, as the app kept them from a [`PrintJob`]. pub fn options( mut self, options: PrintOptions ) -> Self { self.some_pages = !options.pages.is_empty(); self.pages_text = format_pages( &options.pages ); self.options = options; self } /// Choose this printer, by its queue's name, rather than the default one once the printers are known. pub fn printer( mut self, name: impl Into ) -> Self { self.wanted = Some( name.into() ); self } /// Start with saving to PDF chosen, even when there are printers. pub fn save_as_pdf( mut self ) -> Self { self.prefer_pdf = true; self } /// Laid out for a narrow screen, on the whole of it. pub fn compact( mut self, on: bool ) -> Self { self.compact = on; self } /// The field to focus, once after it changed; an app hands it on from [`crate::App::take_focus_request`]. pub fn take_focus_request( &mut self ) -> Option { self.focus.take() } /// Nothing while the portal shows its own dialog. pub fn view( &self ) -> Element { if self.portal { return crate::spacer().into(); } view::view( self ) } /// Take one of the dialog's messages; `Some` when the app has something to do. pub fn update( &mut self, msg: PrintDialogMsg ) -> Option { match msg { PrintDialogMsg::Start if self.portal => { let ( title, options, wrap ) = ( self.title.clone(), self.options.clone(), Arc::clone( &self.wrap ) ); run( &self.sender, move || prepare( &title, &options ), move |result| wrap( PrintDialogMsg::Prepared( result ) ) ); } PrintDialogMsg::Start => return self.request_preview(), PrintDialogMsg::Printers( result ) => self.listed( result ), PrintDialogMsg::Prepared( result ) => match result { Ok( Some( ( options, token ) ) ) => { return Some( PrintEvent::Print( PrintJob { destination: Destination::Portal( PortalToken( token ) ), options, title: self.title.clone() } ) ); } Ok( None ) => return Some( PrintEvent::Cancelled ), Err( error ) => { self.portal = false; self.list_error = Some( error ); return self.request_preview(); } }, PrintDialogMsg::Previewed( result ) => return self.previewed( result ), PrintDialogMsg::Toggle( section ) => self.open = if self.open == Some( section ) { None } else { Some( section ) }, PrintDialogMsg::Destination( index ) => { self.destination = index.filter( |i| *i < self.printers.len() ); self.open = None; } PrintDialogMsg::Copies( delta ) => { self.options.copies = ( self.options.copies as i64 + delta as i64 ).clamp( 1, PrintOptions::MAX_COPIES as i64 ) as u32; } PrintDialogMsg::AllPages => { self.some_pages = false; self.pages_bad = false; return self.set_pages( Vec::new() ); } PrintDialogMsg::SomePages => { self.some_pages = true; self.focus = Some( PAGES ); if let Some( pages ) = parse_pages( &self.pages_text ) { return self.set_pages( pages ); } } PrintDialogMsg::Pages( text ) => { self.pages_text = text; match parse_pages( &self.pages_text ) { Some( pages ) => { self.pages_bad = false; return self.set_pages( pages ); } None => self.pages_bad = !self.pages_text.trim().is_empty(), } } PrintDialogMsg::Paper( index ) => { self.open = None; if let Some( paper ) = PAPERS.get( index ) { return self.change( |o| o.paper = *paper ); } } PrintDialogMsg::Orientation( orientation ) => { self.open = None; return self.change( |o| o.orientation = orientation ); } PrintDialogMsg::Margins( margins ) => { self.open = None; return self.change( |o| o.margins = margins ); } PrintDialogMsg::Scale( steps ) => { let percent = ( self.options.scale * 100.0 ).round() as i32 + steps * SCALE_STEP; let scale = ( percent as f32 / 100.0 ).clamp( PrintOptions::MIN_SCALE, PrintOptions::MAX_SCALE ); return self.change( |o| o.scale = scale ); } PrintDialogMsg::Color => { self.options.color = !self.options.color; self.refresh_images(); } PrintDialogMsg::Sides( sides ) => { self.open = None; self.options.sides = sides; } PrintDialogMsg::Backgrounds => return self.change( |o| o.backgrounds = !o.backgrounds ), PrintDialogMsg::Page( delta ) => { let last = self.images.len().saturating_sub( 1 ) as i64; self.shown = ( self.shown as i64 + delta as i64 ).clamp( 0, last ) as usize; } PrintDialogMsg::Print => return self.job().map( PrintEvent::Print ), PrintDialogMsg::Cancel => return Some( PrintEvent::Cancelled ), } None } /// Where it prints to now. pub fn destination( &self ) -> Destination { match self.destination.and_then( |i| self.printers.get( i ) ) { Some( printer ) => Destination::Printer( printer.clone() ), None => Destination::Pdf, } } pub( crate ) fn msg( &self, msg: PrintDialogMsg ) -> Msg { ( self.wrap )( msg ) } pub( crate ) fn can_print( &self ) -> bool { !( self.some_pages && ( self.pages_bad || parse_pages( &self.pages_text ).is_none() ) ) } fn job( &self ) -> Option { if !self.can_print() { return None; } let mut options = self.options.clone(); if !self.some_pages { options.pages.clear(); } Some( PrintJob { destination: self.destination(), options, title: self.title.clone() } ) } fn listed( &mut self, result: Result, String> ) { self.listing = false; match result { Ok( printers ) => { let wanted = self.wanted.as_deref(); self.destination = if self.prefer_pdf { None } else { printers.iter().position( |p| Some( p.name.as_str() ) == wanted ) .or_else( || printers.iter().position( |p| p.default ) ) .or( ( !printers.is_empty() ).then_some( 0 ) ) }; self.printers = printers; } Err( error ) => self.list_error = Some( error ), } } fn set_pages( &mut self, pages: Vec<( u32, u32 )> ) -> Option { self.change( |o| o.pages = pages ) } fn change( &mut self, apply: impl FnOnce( &mut PrintOptions ) ) -> Option { let before = self.options.clone(); apply( &mut self.options ); if before.same_layout( &self.options ) { return None; } self.request_preview() } fn request_preview( &mut self ) -> Option { if !self.previews || self.rendering.is_some() { return None; } self.rendering = Some( self.options.clone() ); Some( PrintEvent::Preview( self.options.clone() ) ) } fn previewed( &mut self, result: Result ) -> Option { let rendered = self.rendering.take(); match result { Ok( preview ) => { self.preview_error = None; self.shown = self.shown.min( preview.pages.len().saturating_sub( 1 ) ); self.preview = Some( preview ); self.refresh_images(); } Err( error ) => self.preview_error = Some( error ), } match rendered { Some( rendered ) if !rendered.same_layout( &self.options ) => self.request_preview(), _ => None, } } fn refresh_images( &mut self ) { let color = self.options.color; self.images = self.preview.iter() .flat_map( |preview| preview.pages.iter() ) .map( |page| if color { page.image.clone() } else { grey( &page.image ) } ) .collect(); } } fn grey( ( rgba, width, height ): &ImageData ) -> ImageData { let mut out = rgba.as_ref().clone(); for px in out.chunks_exact_mut( 4 ) { let y = ( 0.299 * px[ 0 ] as f32 + 0.587 * px[ 1 ] as f32 + 0.114 * px[ 2 ] as f32 ).round() as u8; px[ 0 ] = y; px[ 1 ] = y; px[ 2 ] = y; } ( Arc::new( out ), *width, *height ) } fn run( sender: &ChannelSender, work: impl FnOnce() -> T + Send + 'static, done: impl FnOnce( T ) -> M + Send + 'static ) { let sender = sender.clone(); #[ cfg( target_arch = "wasm32" ) ] let _ = sender.send( done( work() ) ); #[ cfg( not( target_arch = "wasm32" ) ) ] std::thread::spawn( move || { let _ = sender.send( done( work() ) ); } ); } /// The system's printers, the default first; empty without CUPS's server, an error without CUPS. pub fn list_printers( sender: &ChannelSender, done: impl FnOnce( Result, String> ) -> M + Send + 'static ) { run( sender, cups::printers, done ); } /// Print `pdf` at `destination` with `options`; done once the printer's queue, or the desktop, took it. pub fn send( destination: &Destination, title: &str, options: &PrintOptions, pdf: Vec, sender: &ChannelSender, done: impl FnOnce( Result<(), String> ) -> M + Send + 'static ) { let ( destination, title, options ) = ( destination.clone(), title.to_string(), options.clone() ); run( sender, move || match destination { Destination::Printer( printer ) => cups::print( &printer, &title, &options, &pdf ).map( |_| () ), Destination::Portal( token ) => portal_print( token.0, &title, &pdf ), Destination::Pdf => Err( rust_i18n::t!( "print.failed" ).to_string() ), }, done ); } #[ cfg( all( feature = "wayland", target_os = "linux" ) ) ] use portal::{ prepare, print as portal_print, sandboxed }; #[ cfg( not( all( feature = "wayland", target_os = "linux" ) ) ) ] fn sandboxed() -> bool { false } #[ cfg( not( all( feature = "wayland", target_os = "linux" ) ) ) ] fn prepare( _title: &str, _options: &PrintOptions ) -> Result, String> { Err( rust_i18n::t!( "print.no_cups" ).to_string() ) } #[ cfg( not( all( feature = "wayland", target_os = "linux" ) ) ) ] fn portal_print( _token: u32, _title: &str, _pdf: &[ u8 ] ) -> Result<(), String> { Err( rust_i18n::t!( "print.no_cups" ).to_string() ) } /// The user's jobs not yet finished, on every printer or on one of them. pub fn jobs( printer: Option, sender: &ChannelSender, done: impl FnOnce( Result, String> ) -> M + Send + 'static ) { run( sender, move || cups::jobs( printer.as_deref() ), done ); } pub fn cancel_job( printer: &str, job: i32, sender: &ChannelSender, done: impl FnOnce( Result<(), String> ) -> M + Send + 'static ) { let printer = printer.to_string(); run( sender, move || cups::cancel( &printer, job ), done ); } /// Make `printer` the user's default. pub fn set_default( printer: &Printer, sender: &ChannelSender, done: impl FnOnce( Result<(), String> ) -> M + Send + 'static ) { let ( name, instance ) = ( printer.name.clone(), printer.instance.clone() ); run( sender, move || cups::set_default( &name, instance.as_deref() ), done ); }