diff --git a/Cargo.lock b/Cargo.lock index 801dc3c..5e6580b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,7 +278,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -289,7 +289,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -648,7 +648,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -692,6 +692,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.118", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -1008,6 +1028,15 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfb" version = "0.7.3" @@ -1080,6 +1109,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.1" @@ -1511,6 +1551,7 @@ dependencies = [ "cosmic-settings-sound", "cosmic-settings-upower-subscription", "cosmic-settings-wallpaper", + "cups_rs", "derive_setters", "dirs", "eyre", @@ -1527,7 +1568,7 @@ dependencies = [ "image", "indexmap 2.14.0", "intmap", - "itertools", + "itertools 0.14.0", "itoa", "jiff", "libcosmic-yoda", @@ -1907,6 +1948,19 @@ dependencies = [ "uncased", ] +[[package]] +name = "cups_rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ad179868ddceb8948487316c7094aa251e3efb0ecf15cab88b08f22eabf724" +dependencies = [ + "bindgen", + "chrono", + "libc", + "pkg-config", + "thiserror 1.0.69", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -2122,7 +2176,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2265,7 +2319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2595,7 +2649,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6d3a3635983a889f065aa9ce760384713f23a9b4a04f696f86c39a5d7a6a5a" dependencies = [ "indexmap 2.14.0", - "nom", + "nom 8.0.0", ] [[package]] @@ -2821,6 +2875,12 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "glow" version = "0.16.0" @@ -3988,6 +4048,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -4363,7 +4432,7 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4801,6 +4870,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4944,6 +5019,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -5001,7 +5086,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5749,6 +5834,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -6003,7 +6098,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -6361,7 +6456,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6830,7 +6925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7064,10 +7159,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7453,7 +7548,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8123,7 +8218,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/cosmic-settings/Cargo.toml b/cosmic-settings/Cargo.toml index 395fdec..688115d 100644 --- a/cosmic-settings/Cargo.toml +++ b/cosmic-settings/Cargo.toml @@ -36,6 +36,7 @@ cosmic-settings-sound = { path = "../pages/sound", optional = true } cosmic-settings-upower-subscription = { path = "../subscriptions/upower", optional = true } cosmic-settings-wallpaper = { path = "../pages/wallpapers" } cosmic-settings-daemon-config = { git = "https://github.com/pop-os/cosmic-settings-daemon", optional = true } +cups_rs = { version = "0.3.0", optional = true } derive_setters = "0.1.9" dirs = "6.0.0" eyre = "0.6.12" @@ -130,6 +131,7 @@ linux = [ "page-legacy-applications", "page-networking", "page-power", + "page-printers", "page-region", "page-sound", "page-users", @@ -174,6 +176,7 @@ page-networking = [ "dep:zbus", ] page-power = ["dep:upower_dbus", "dep:zbus"] +page-printers = ["dep:cups_rs"] page-region = [ "gettext", "dep:locales-rs", diff --git a/cosmic-settings/src/app.rs b/cosmic-settings/src/app.rs index bbeec4a..557a873 100644 --- a/cosmic-settings/src/app.rs +++ b/cosmic-settings/src/app.rs @@ -16,6 +16,8 @@ use crate::pages::input; use crate::pages::networking; #[cfg(feature = "page-power")] use crate::pages::power; +#[cfg(feature = "page-printers")] +use crate::pages::printers; #[cfg(feature = "page-sound")] use crate::pages::sound; use crate::pages::{self, applications, system, time}; @@ -116,6 +118,8 @@ impl SettingsApp { } #[cfg(feature = "page-power")] PageCommands::Power => self.pages.page_id::(), + #[cfg(feature = "page-printers")] + PageCommands::Printers => self.pages.page_id::(), #[cfg(feature = "page-region")] PageCommands::RegionLanguage => self.pages.page_id::(), #[cfg(feature = "page-sound")] @@ -225,6 +229,8 @@ impl cosmic::Application for SettingsApp { app.insert_page::(); #[cfg(feature = "page-input")] app.insert_page::(); + #[cfg(feature = "page-printers")] + app.insert_page::(); app.insert_page::(); app.insert_page::(); app.insert_page::(); @@ -654,6 +660,13 @@ impl cosmic::Application for SettingsApp { } } + #[cfg(feature = "page-printers")] + crate::pages::Message::Printers(message) => { + if let Some(page) = self.pages.page_mut::() { + return page.update(message).map(Into::into); + } + } + #[cfg(feature = "page-networking")] crate::pages::Message::Vpn(message) => { if let Some(page) = self.pages.page_mut::() { diff --git a/cosmic-settings/src/main.rs b/cosmic-settings/src/main.rs index 7a39baf..8b418d4 100644 --- a/cosmic-settings/src/main.rs +++ b/cosmic-settings/src/main.rs @@ -122,6 +122,9 @@ pub enum PageCommands { /// Power settings page #[cfg(feature = "page-power")] Power, + /// Printers settings page + #[cfg(feature = "page-printers")] + Printers, /// Region & Language settings page #[cfg(feature = "page-region")] RegionLanguage, diff --git a/cosmic-settings/src/pages/mod.rs b/cosmic-settings/src/pages/mod.rs index 3a148af..b7acc2d 100644 --- a/cosmic-settings/src/pages/mod.rs +++ b/cosmic-settings/src/pages/mod.rs @@ -17,6 +17,8 @@ pub mod input; pub mod networking; #[cfg(feature = "page-power")] pub mod power; +#[cfg(feature = "page-printers")] +pub mod printers; #[cfg(feature = "page-sound")] pub mod sound; pub mod system; @@ -82,6 +84,8 @@ pub enum Message { PanelApplet(desktop::panel::applets_inner::Message), #[cfg(feature = "page-power")] Power(power::Message), + #[cfg(feature = "page-printers")] + Printers(printers::Message), #[cfg(feature = "page-region")] Region(time::region::Message), #[cfg(feature = "page-sound")] diff --git a/cosmic-settings/src/pages/printers/backend.rs b/cosmic-settings/src/pages/printers/backend.rs new file mode 100644 index 0000000..36a7a1a --- /dev/null +++ b/cosmic-settings/src/pages/printers/backend.rs @@ -0,0 +1,285 @@ +// Copyright 2026 Lionel DARNIS +// SPDX-License-Identifier: GPL-3.0-only + +//! CUPS backend for the printers page. +//! +//! Read-only operations (destinations, jobs) use the `cups_rs` bindings to +//! libcups. Mutating operations (add, remove, default, pause) go through the +//! CUPS command line tools, which authenticate transparently against the +//! local scheduler for users in the CUPS system group. + +use tokio::process::Command; + +pub use cups_rs::{JobStatus, PrinterState}; + +const TEST_PAGE: &str = "/usr/share/cups/data/testprint"; + +/// libcups keeps process-wide global state; serialize every FFI call. +static CUPS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn cups_lock() -> std::sync::MutexGuard<'static, ()> { + CUPS_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// A printer queue known to the CUPS scheduler. +#[derive(Clone, Debug)] +pub struct Printer { + pub name: String, + pub info: String, + pub make_and_model: Option, + pub location: Option, + pub device_uri: Option, + pub state: PrinterState, + pub state_reasons: Vec, + pub accepting_jobs: bool, + pub is_default: bool, +} + +/// An active print job. +#[derive(Clone, Debug)] +pub struct Job { + pub id: i32, + pub title: String, + pub dest: String, + pub status: JobStatus, +} + +/// A device that a printer queue can be created for. +#[derive(Clone, Debug)] +pub struct Device { + pub uri: String, + pub info: String, +} + +/// A printer driver (PPD or driverless profile). +#[derive(Clone, Debug)] +pub struct Driver { + pub id: String, + pub description: String, +} + +/// Read operations (blocking FFI; call from a blocking task). + +pub fn printers_sync() -> Result, String> { + let _guard = cups_lock(); + let destinations = cups_rs::get_all_destinations().map_err(|err| err.to_string())?; + + Ok(destinations + .iter() + .map(|dest| Printer { + name: dest.name.clone(), + info: dest + .info() + .filter(|info| !info.is_empty()) + .cloned() + .unwrap_or_else(|| dest.name.clone()), + make_and_model: dest.make_and_model().cloned(), + location: dest.location().cloned(), + device_uri: dest.device_uri().cloned(), + state: dest.state(), + state_reasons: dest + .state_reasons() + .into_iter() + .filter(|reason| reason != "none") + .collect(), + accepting_jobs: dest.is_accepting_jobs(), + is_default: dest.is_default, + }) + .collect()) +} + +pub fn jobs_sync() -> Result, String> { + let _guard = cups_lock(); + let jobs = cups_rs::get_active_jobs(None).map_err(|err| err.to_string())?; + + Ok(jobs + .into_iter() + .map(|job| Job { + id: job.id, + title: job.title, + dest: job.dest, + status: job.status, + }) + .collect()) +} + +pub fn cancel_job_sync(job_id: i32) -> Result<(), String> { + let _guard = cups_lock(); + cups_rs::job::cancel_job(job_id).map_err(|err| err.to_string()) +} + +pub fn print_test_page_sync(printer: &str) -> Result<(), String> { + let _guard = cups_lock(); + let dest = cups_rs::get_destination(printer).map_err(|err| err.to_string())?; + let job = cups_rs::create_job(&dest, "COSMIC Settings test page") + .map_err(|err| err.to_string())?; + job.submit_file(TEST_PAGE, "application/postscript") + .map_err(|err| err.to_string()) +} + +/// Discovery and administration (CUPS command line tools). + +pub async fn devices() -> Result, String> { + let output = run_cli_capture("lpinfo", &["-v"]).await?; + + Ok(output + .lines() + .filter_map(|line| { + let (_, uri) = line.split_once(char::is_whitespace)?; + let uri = uri.trim(); + uri.contains("://").then(|| Device { + info: device_info(uri), + uri: uri.to_string(), + }) + }) + .collect()) +} + +pub async fn drivers() -> Result, String> { + let output = run_cli_capture("lpinfo", &["-m"]).await?; + + let mut drivers: Vec = output + .lines() + .filter_map(|line| { + let (id, description) = line.split_once(char::is_whitespace)?; + Some(Driver { + id: id.trim().to_string(), + description: description.trim().to_string(), + }) + }) + .collect(); + + // Offer the driverless IPP Everywhere profile first. + drivers.sort_by_key(|driver| driver.id != "everywhere"); + Ok(drivers) +} + +pub async fn add_printer(name: &str, uri: &str, driver: &str) -> Result<(), String> { + run_cli("lpadmin", &["-p", name, "-E", "-v", uri, "-m", driver]).await +} + +pub async fn remove_printer(name: &str) -> Result<(), String> { + run_cli("lpadmin", &["-x", name]).await +} + +pub async fn set_default(name: &str) -> Result<(), String> { + run_cli("lpadmin", &["-d", name]).await +} + +pub async fn set_enabled(name: &str, enabled: bool) -> Result<(), String> { + if enabled { + run_cli("cupsenable", &[name]).await + } else { + run_cli("cupsdisable", &[name]).await + } +} + +/// Sanitize a display name into a valid CUPS queue name and make it unique +/// against the current printer list. +pub fn unique_name(raw: &str, existing: &[Printer]) -> String { + let sanitized: String = raw + .chars() + .map(|c| { + if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); + + let base = if sanitized.is_empty() { + "Printer".to_string() + } else { + sanitized + }; + + let mut candidate = base.clone(); + let mut index = 2; + while existing.iter().any(|printer| printer.name == candidate) { + candidate = format!("{base}_{index}"); + index += 1; + } + candidate +} + +async fn run_cli(program: &str, args: &[&str]) -> Result<(), String> { + let output = run_cli_capture(program, args).await?; + drop(output); + Ok(()) +} + +async fn run_cli_capture(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .stdin(std::process::Stdio::null()) + .output() + .await + .map_err(|err| format!("{program}: {err}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr); + + if output.status.success() { + return Ok(stdout); + } + + // Ignore deprecation notices lpadmin prints on stderr for PPD drivers. + let message = stderr + .lines() + .filter(|line| !line.contains("deprecated")) + .collect::>() + .join(" "); + + Err(if message.trim().is_empty() { + format!("{program} {}", args.join(" ")) + } else { + message + }) +} + +fn device_info(uri: &str) -> String { + if let Some(rest) = uri.strip_prefix("dnssd://") { + let name = rest.split(['/', '?']).next().unwrap_or(rest); + let name = name.split("._").next().unwrap_or(name); + let decoded = percent_decode(name); + if !decoded.is_empty() { + return decoded; + } + } + + if let Some(rest) = uri.strip_prefix("usb://") { + let path = rest.split('?').next().unwrap_or(rest); + let decoded = percent_decode(path).replace('/', " "); + if !decoded.trim().is_empty() { + return decoded; + } + } + + if uri.starts_with("cups-pdf") { + return "PDF".to_string(); + } + + percent_decode(uri) +} + +fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = &input[index + 1..index + 3]; + if let Ok(value) = u8::from_str_radix(hex, 16) { + decoded.push(value); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + + String::from_utf8_lossy(&decoded).into_owned() +} diff --git a/cosmic-settings/src/pages/printers/mod.rs b/cosmic-settings/src/pages/printers/mod.rs new file mode 100644 index 0000000..ea25b86 --- /dev/null +++ b/cosmic-settings/src/pages/printers/mod.rs @@ -0,0 +1,772 @@ +// Copyright 2026 Lionel DARNIS +// SPDX-License-Identifier: GPL-3.0-only + +//! Printers page: manage CUPS queues, the default printer, and print jobs. + +mod backend; + +use cosmic::iced::{Alignment, Length, Subscription}; +use cosmic::widget::{self, text}; +use cosmic::{Apply, Element, Task, theme}; +use cosmic_settings_page::{self as page, Section, section}; +use slotmap::SlotMap; +use std::time::Duration; + +use backend::{JobStatus, PrinterState}; + +const DRIVERLESS: &str = "everywhere"; +const DRIVER_LIST_LIMIT: usize = 200; +const REFRESH_EVERY: Duration = Duration::from_secs(8); + +#[derive(Default)] +struct AddFlow { + active: bool, + loading: bool, + submitting: bool, + devices: Vec, + selected_device: Option, + name: String, + drivers: Vec, + driver_filter: String, + selected_driver: String, + error: Option, +} + +pub enum Dialog { + RemovePrinter { name: String }, +} + +#[derive(Default)] +pub struct Page { + entity: page::Entity, + printers: Vec, + jobs: Vec, + selected: Option, + notice: Option, + dialog: Option, + add: AddFlow, +} + +#[derive(Clone, Debug)] +pub enum Message { + Reload, + Printers(Result, String>), + Jobs(Result, String>), + Select(Option), + SetDefault(String), + ToggleEnabled(String, bool), + PrintTestPage(String), + Remove(String), + ConfirmRemove, + CancelDialog, + CancelJob(i32), + OperationDone(Result<(), String>), + OpenAdd, + Devices(Result, String>), + Drivers(Result, String>), + SelectDevice(usize), + NameInput(String), + DriverFilter(String), + SelectDriver(String), + SubmitAdd, + AddDone(Result), +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::Printers(message) + } +} + +impl page::Page for Page { + fn info(&self) -> page::Info { + page::Info::new("printers", "printer-symbolic") + .title(fl!("printers")) + .description(fl!("xdg-entry-printers-comment")) + } + + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![ + sections.insert(printers_section()), + sections.insert(selected_printer_section()), + sections.insert(jobs_section()), + ]) + } + + fn set_id(&mut self, entity: page::Entity) { + self.entity = entity; + } + + fn on_enter(&mut self) -> Task { + self.reload() + } + + fn on_context_drawer_close(&mut self) -> Task { + self.add = AddFlow::default(); + Task::none() + } + + fn dialog(&self) -> Option> { + let Dialog::RemovePrinter { name } = self.dialog.as_ref()?; + + let confirm = widget::button::destructive(fl!("printers-remove")) + .on_press(crate::pages::Message::from(Message::ConfirmRemove)); + + let cancel = widget::button::standard(fl!("cancel")) + .on_press(crate::pages::Message::from(Message::CancelDialog)); + + Some( + widget::dialog() + .title(fl!("printers-remove-confirm")) + .body(fl!( + "printers-remove-confirm-description", + name = name.to_string() + )) + .primary_action(confirm) + .secondary_action(cancel) + .apply(Element::from), + ) + } + + fn context_drawer(&self) -> Option> { + if !self.add.active { + return None; + } + + Some( + cosmic::app::context_drawer( + self.add_view().map(crate::pages::Message::Printers), + crate::pages::Message::CloseContextDrawer, + ) + .title(fl!("printers-add")), + ) + } + + fn subscription(&self, _core: &cosmic::Core) -> Subscription { + cosmic::iced::time::every(REFRESH_EVERY) + .map(|_| crate::pages::Message::Printers(Message::Reload)) + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> cosmic::Task { + match message { + Message::Reload => return self.reload().map(Into::into), + + Message::Printers(Ok(printers)) => { + if let Some(selected) = self.selected.as_deref() + && !printers.iter().any(|printer| printer.name == selected) + { + self.selected = None; + } + self.printers = printers; + } + + Message::Printers(Err(err)) | Message::Jobs(Err(err)) => { + self.notice = Some(fl!("printers-error", error = err)); + } + + Message::Jobs(Ok(jobs)) => { + self.jobs = jobs; + } + + Message::Select(name) => { + self.selected = name; + } + + Message::SetDefault(name) => { + self.notice = None; + return Self::admin(async move { backend::set_default(&name).await }); + } + + Message::ToggleEnabled(name, enabled) => { + self.notice = None; + return Self::admin(async move { backend::set_enabled(&name, enabled).await }); + } + + Message::PrintTestPage(name) => { + self.notice = None; + return cosmic::task::future(async move { + Message::OperationDone(tokio::task::spawn_blocking(move || { + backend::print_test_page_sync(&name) + }) + .await + .unwrap_or_else(|err| Err(err.to_string()))) + }) + .map(crate::pages::Message::Printers) + .map(Into::into); + } + + Message::Remove(name) => { + self.dialog = Some(Dialog::RemovePrinter { name }); + } + + Message::CancelDialog => { + self.dialog = None; + } + + Message::ConfirmRemove => { + self.dialog = None; + if let Some(name) = self.selected_printer_name() { + return Self::admin(async move { backend::remove_printer(&name).await }); + } + } + + Message::CancelJob(id) => { + return cosmic::task::future(async move { + Message::OperationDone(tokio::task::spawn_blocking(move || { + backend::cancel_job_sync(id) + }) + .await + .unwrap_or_else(|err| Err(err.to_string()))) + }) + .map(crate::pages::Message::Printers) + .map(Into::into); + } + + Message::OperationDone(result) => { + if let Err(err) = result { + self.notice = Some(fl!("printers-error", error = err)); + } + return self.reload().map(Into::into); + } + + Message::OpenAdd => { + self.add = AddFlow { + active: true, + loading: true, + selected_driver: DRIVERLESS.to_string(), + ..Default::default() + }; + + let open_drawer: cosmic::Task = cosmic::task::message( + crate::app::Message::OpenContextDrawer(self.entity), + ); + + return Task::batch([ + cosmic::task::future(async { Message::Devices(backend::devices().await) }) + .map(crate::pages::Message::Printers) + .map(Into::into), + cosmic::task::future(async { Message::Drivers(backend::drivers().await) }) + .map(crate::pages::Message::Printers) + .map(Into::into), + open_drawer, + ]); + } + + Message::Devices(Ok(devices)) => { + self.add.devices = devices; + self.add.loading = false; + } + + Message::Devices(Err(err)) => { + self.add.loading = false; + self.add.error = Some(err); + } + + Message::Drivers(Ok(drivers)) => { + self.add.drivers = drivers; + } + + Message::Drivers(Err(err)) => { + self.add.error = Some(err); + } + + Message::SelectDevice(index) => { + if let Some(device) = self.add.devices.get(index) { + self.add.name = backend::unique_name(&device.info, &self.printers); + self.add.selected_device = Some(index); + } + } + + Message::NameInput(value) => { + self.add.name = value; + } + + Message::DriverFilter(value) => { + self.add.driver_filter = value; + } + + Message::SelectDriver(id) => { + self.add.selected_driver = id; + } + + Message::SubmitAdd => { + let Some(device) = self + .add + .selected_device + .and_then(|index| self.add.devices.get(index)) + else { + return Task::none(); + }; + + let name = backend::unique_name(self.add.name.trim(), &self.printers); + let uri = device.uri.clone(); + let driver = self.add.selected_driver.clone(); + self.add.submitting = true; + self.add.error = None; + + return cosmic::task::future(async move { + Message::AddDone(backend::add_printer(&name, &uri, &driver).await.map(|_| name)) + }) + .map(crate::pages::Message::Printers) + .map(Into::into); + } + + Message::AddDone(result) => match result { + Ok(name) => { + self.add = AddFlow::default(); + self.selected = Some(name.clone()); + self.notice = Some(fl!("printers-added", name = name)); + + let close_drawer: cosmic::Task = cosmic::task::message( + crate::Message::from(crate::pages::Message::CloseContextDrawer), + ); + + return Task::batch([self.reload().map(Into::into), close_drawer]); + } + Err(err) => { + self.add.submitting = false; + self.add.error = Some(err); + } + }, + } + + Task::none() + } + + fn selected_printer_name(&self) -> Option { + // Removal is confirmed from the dialog; operate on the selected printer. + match self.dialog.as_ref() { + Some(Dialog::RemovePrinter { name }) => Some(name.clone()), + None => self.selected.clone(), + } + } + + fn reload(&self) -> Task { + Task::batch([ + cosmic::task::future(async { + Message::Printers( + tokio::task::spawn_blocking(backend::printers_sync) + .await + .unwrap_or_else(|err| Err(err.to_string())), + ) + }) + .map(crate::pages::Message::Printers), + cosmic::task::future(async { + Message::Jobs( + tokio::task::spawn_blocking(backend::jobs_sync) + .await + .unwrap_or_else(|err| Err(err.to_string())), + ) + }) + .map(crate::pages::Message::Printers), + ]) + } + + fn admin( + operation: impl std::future::Future> + Send + 'static, + ) -> cosmic::Task { + cosmic::task::future(async move { Message::OperationDone(operation.await) }) + .map(crate::pages::Message::Printers) + .map(Into::into) + } + + fn add_view(&self) -> Element<'_, Message> { + let spacing = theme::spacing(); + let mut content = widget::column::with_children(Vec::new()).spacing(spacing.space_s); + + content = content.push(text::body(fl!("printers-device"))); + + let mut devices = widget::list_column(); + if self.add.loading { + devices = devices.add(text::caption(fl!("printers-loading"))); + } else if self.add.devices.is_empty() { + devices = devices.add(text::caption(fl!("printers-no-devices"))); + } else { + for (index, device) in self.add.devices.iter().enumerate() { + let selected = self.add.selected_device == Some(index); + let control: Element<'_, Message> = if selected { + widget::icon::from_name("object-select-symbolic") + .size(16) + .into() + } else { + widget::space::horizontal() + .width(Length::Fixed(16.0)) + .into() + }; + + devices = devices.add( + widget::list::button( + widget::settings::item::builder(device.info.to_string()) + .description(device.uri.to_string()) + .control(control), + ) + .on_press(Message::SelectDevice(index)), + ); + } + } + + content = content.push(widget::scrollable(devices).height(Length::Fixed(220.0))); + + content = content.push( + widget::text_input("", &self.add.name) + .label(fl!("printers-name")) + .on_input(Message::NameInput), + ); + + content = content.push(text::body(fl!("printers-driver"))); + + content = content.push( + widget::search_input("", &self.add.driver_filter) + .on_input(Message::DriverFilter) + .on_clear(Message::DriverFilter(String::new())), + ); + + let filter = self.add.driver_filter.to_lowercase(); + let mut drivers = widget::list_column(); + let mut shown = 0usize; + for driver in self.add.drivers.iter().filter(|driver| { + filter.is_empty() + || driver.description.to_lowercase().contains(&filter) + || driver.id.to_lowercase().contains(&filter) + }) { + let selected = self.add.selected_driver == driver.id; + let control: Element<'_, Message> = if selected { + widget::icon::from_name("object-select-symbolic").size(16).into() + } else { + widget::space::horizontal() + .width(Length::Fixed(16.0)) + .into() + }; + + drivers = drivers.add( + widget::list::button( + widget::settings::item::builder(driver.description.to_string()) + .description(driver.id.to_string()) + .control(control), + ) + .on_press(Message::SelectDriver(driver.id.clone())), + ); + + shown += 1; + if shown == DRIVER_LIST_LIMIT { + break; + } + } + + if shown == 0 { + drivers = drivers.add(text::caption(fl!("printers-no-matching-drivers"))); + } + + content = content.push(widget::scrollable(drivers).height(Length::Fixed(260.0))); + + if let Some(error) = self.add.error.as_deref() { + content = content.push(text::caption(error.to_string())); + } + + let can_submit = self.add.selected_device.is_some() + && !self.add.name.trim().is_empty() + && !self.add.selected_driver.is_empty() + && !self.add.submitting; + + let label = if self.add.submitting { + fl!("printers-add-in-progress") + } else { + fl!("printers-submit") + }; + + content = content.push( + widget::row::with_children(vec![ + widget::space::horizontal().apply(Element::from), + widget::button::suggested(label) + .on_press_maybe(can_submit.then_some(Message::SubmitAdd)) + .apply(Element::from), + ]) + .spacing(spacing.space_s), + ); + + content.into() + } +} + +fn state_label(state: PrinterState, descriptions: &slab::Slab, keys: &StateKeys) -> String { + match state { + PrinterState::Idle => descriptions[keys.idle].clone(), + PrinterState::Processing => descriptions[keys.processing].clone(), + PrinterState::Stopped => descriptions[keys.stopped].clone(), + PrinterState::Unknown => descriptions[keys.unknown].clone(), + } +} + +struct StateKeys { + idle: usize, + processing: usize, + stopped: usize, + unknown: usize, +} + +fn printer_icon(printer: &backend::Printer) -> &'static str { + match printer.state { + PrinterState::Processing => "printer-printing-symbolic", + PrinterState::Stopped => "printer-error-symbolic", + _ => "printer-symbolic", + } +} + +fn printers_section() -> Section { + crate::slab!(descriptions { + add = fl!("printers-add"); + default = fl!("printers-default"); + make_default = fl!("printers-make-default"); + no_printers = fl!("printers-no-printers"); + not_accepting = fl!("printers-not-accepting"); + idle = fl!("printers-state-idle"); + processing = fl!("printers-state-processing"); + stopped = fl!("printers-state-stopped"); + unknown = fl!("printers-state-unknown"); + }); + + let keys = StateKeys { + idle, + processing, + stopped, + unknown, + }; + + Section::default() + .title(fl!("printers")) + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let descriptions = §ion.descriptions; + let mut list = widget::list_column(); + + if let Some(notice) = page.notice.as_deref() { + list = list.add(text::caption(notice.to_string())); + } + + if page.printers.is_empty() { + list = list.add(text::body(descriptions[no_printers].clone())); + } + + for printer in &page.printers { + let is_selected = page.selected.as_deref() == Some(printer.name.as_str()); + + let mut parts: Vec = Vec::new(); + if let Some(model) = printer.make_and_model.as_deref() + && !model.is_empty() + { + parts.push(model.to_string()); + } + parts.push(state_label(printer.state, descriptions, &keys)); + if !printer.accepting_jobs { + parts.push(descriptions[not_accepting].clone()); + } + + let control: Element<'_, crate::pages::Message> = if printer.is_default { + widget::button::suggested(descriptions[default].clone()) + .apply(Element::from) + } else { + widget::button::link(descriptions[make_default].clone()) + .on_press(Message::SetDefault(printer.name.clone()).into()) + .apply(Element::from) + }; + + let row = widget::settings::item::builder(printer.info.to_string()) + .description(parts.join(" · ")) + .icon(widget::icon::from_name(printer_icon(printer)).size(24)) + .control(control); + + let message = if is_selected { + Message::Select(None) + } else { + Message::Select(Some(printer.name.clone())) + }; + + list = list.add(widget::list::button(row).on_press(message.into())); + } + + list = list.add( + widget::row::with_children(vec![ + widget::space::horizontal().apply(Element::from), + widget::button::suggested(descriptions[add].clone()) + .on_press(Message::OpenAdd.into()) + .apply(Element::from), + ]) + .spacing(theme::spacing().space_s), + ); + + list.apply(Element::from) + }) +} + +fn selected_printer_section() -> Section { + crate::slab!(descriptions { + make_default = fl!("printers-make-default"); + pause = fl!("printers-pause"); + resume = fl!("printers-resume"); + remove = fl!("printers-remove"); + test_page = fl!("printers-test-page"); + idle = fl!("printers-state-idle"); + processing = fl!("printers-state-processing"); + stopped = fl!("printers-state-stopped"); + unknown = fl!("printers-state-unknown"); + }); + + let keys = StateKeys { + idle, + processing, + stopped, + unknown, + }; + + Section::default() + .title(fl!("printers-details")) + .descriptions(descriptions) + .show_while::(|page| page.selected.is_some()) + .view::(move |_binder, page, section| { + let descriptions = §ion.descriptions; + + let Some(printer) = page + .selected + .as_deref() + .and_then(|name| page.printers.iter().find(|p| p.name == name)) + else { + return Element::from(widget::column::with_children(Vec::new())); + }; + + let spacing = theme::spacing(); + let mut column = widget::list_column(); + + column = column.add( + widget::settings::flex_item( + fl!("printers-state"), + text::body(state_label(printer.state, descriptions, &keys)), + ) + .align_items(Alignment::Center), + ); + + if let Some(location) = printer.location.as_deref() + && !location.is_empty() + { + column = column.add( + widget::settings::flex_item(fl!("printers-location"), text::body(location.to_string())) + .align_items(Alignment::Center), + ); + } + + if let Some(uri) = printer.device_uri.as_deref() { + column = column.add( + widget::settings::flex_item(fl!("printers-device-uri"), text::body(uri.to_string())) + .align_items(Alignment::Center), + ); + } + + if !printer.state_reasons.is_empty() { + column = column.add( + widget::settings::flex_item( + fl!("printers-state-reasons"), + text::body(printer.state_reasons.join(", ")), + ) + .align_items(Alignment::Center), + ); + } + + let name = printer.name.clone(); + let mut actions: Vec> = Vec::new(); + + if !printer.is_default { + actions.push( + widget::button::standard(descriptions[make_default].clone()) + .on_press(Message::SetDefault(name.clone()).into()) + .apply(Element::from), + ); + } + + let paused = printer.state == PrinterState::Stopped; + actions.push(if paused { + widget::button::standard(descriptions[resume].clone()) + .on_press(Message::ToggleEnabled(name.clone(), true).into()) + .apply(Element::from) + } else { + widget::button::standard(descriptions[pause].clone()) + .on_press(Message::ToggleEnabled(name.clone(), false).into()) + .apply(Element::from) + }); + + actions.push( + widget::button::standard(descriptions[test_page].clone()) + .on_press(Message::PrintTestPage(name.clone()).into()) + .apply(Element::from), + ); + + actions.push(widget::space::horizontal().apply(Element::from)); + + actions.push( + widget::button::destructive(descriptions[remove].clone()) + .on_press(Message::Remove(name).into()) + .apply(Element::from), + ); + + column = column.add(widget::row::with_children(actions).spacing(spacing.space_s)); + + column.apply(Element::from) + }) +} + +fn job_status_label(status: JobStatus) -> String { + match status { + JobStatus::Pending => fl!("printers-job-pending"), + JobStatus::Processing => fl!("printers-job-processing"), + JobStatus::Held => fl!("printers-job-held"), + JobStatus::Stopped => fl!("printers-job-stopped"), + JobStatus::Completed => fl!("printers-job-completed"), + JobStatus::Canceled => fl!("printers-job-canceled"), + JobStatus::Aborted => fl!("printers-job-aborted"), + JobStatus::Unknown => fl!("printers-job-unknown"), + } +} + +fn jobs_section() -> Section { + crate::slab!(descriptions { + cancel_job = fl!("printers-cancel-job"); + }); + + Section::default() + .title(fl!("printers-jobs")) + .descriptions(descriptions) + .show_while::(|page| !page.jobs.is_empty()) + .view::(move |_binder, page, section| { + let descriptions = §ion.descriptions; + let mut list = widget::list_column(); + + for job in &page.jobs { + let cancel_button = widget::icon::from_name("window-close-symbolic") + .apply(widget::button::icon) + .on_press(Message::CancelJob(job.id).into()); + + let title = if job.title.is_empty() { + format!("#{}", job.id) + } else { + job.title.clone() + }; + + list = list.add( + widget::settings::item::builder(title) + .description(format!("{} · {}", job.dest, job_status_label(job.status))) + .control(widget::tooltip::tooltip( + cancel_button, + text::caption(descriptions[cancel_job].clone()), + widget::tooltip::Position::Top, + )), + ); + } + + list.apply(Element::from) + }) +} + +impl page::AutoBind for Page {} diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index 5223482..dcc0b7e 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -642,6 +642,50 @@ sound-usb-audio = USB Audio # Profiles for sound card devices sound-device-profiles = Device profiles +# Printers settings page +printers = Printers +xdg-entry-printers-comment = Manage printers and print jobs +printers-add = Add printer +printers-add-in-progress = Adding printer… +printers-added = Printer “{ $name }” added +printers-cancel-job = Cancel job +printers-default = Default +printers-details = Printer details +printers-device = Device +printers-device-uri = Device URI +printers-driver = Driver +printers-error = Operation failed: { $error } +printers-job-aborted = Aborted +printers-job-canceled = Canceled +printers-job-completed = Completed +printers-job-held = Held +printers-job-pending = Pending +printers-job-processing = Printing +printers-job-stopped = Stopped +printers-job-unknown = Unknown +printers-jobs = Print jobs +printers-loading = Searching for devices… +printers-location = Location +printers-make-default = Set as default +printers-name = Name +printers-no-devices = No devices found +printers-no-matching-drivers = No matching drivers +printers-no-printers = No printers configured +printers-not-accepting = Not accepting jobs +printers-pause = Pause +printers-remove = Remove printer +printers-remove-confirm = Remove this printer? +printers-remove-confirm-description = “{ $name }” will be removed from this system. Print jobs queued on it will be discarded. +printers-resume = Resume +printers-state = State +printers-state-idle = Idle +printers-state-processing = Printing +printers-state-reasons = State reasons +printers-state-stopped = Paused +printers-state-unknown = Unknown +printers-submit = Add +printers-test-page = Print test page + # Power & Battery settings page power = Power & battery diff --git a/i18n/fr/cosmic_settings.ftl b/i18n/fr/cosmic_settings.ftl index 8dad49d..bc8bdf9 100644 --- a/i18n/fr/cosmic_settings.ftl +++ b/i18n/fr/cosmic_settings.ftl @@ -429,6 +429,51 @@ sound-alerts = Alertes sound-applications = Applications .desc = Volumes et paramètres d'application +## Printers + +printers = Imprimantes +xdg-entry-printers-comment = Gérer les imprimantes et les travaux d'impression +printers-add = Ajouter une imprimante +printers-add-in-progress = Ajout de l'imprimante… +printers-added = Imprimante « { $name } » ajoutée +printers-cancel-job = Annuler le travail +printers-default = Par défaut +printers-details = Détails de l'imprimante +printers-device = Périphérique +printers-device-uri = URI du périphérique +printers-driver = Pilote +printers-error = Échec de l'opération : { $error } +printers-job-aborted = Interrompu +printers-job-canceled = Annulé +printers-job-completed = Terminé +printers-job-held = Retenu +printers-job-pending = En attente +printers-job-processing = Impression en cours +printers-job-stopped = Arrêté +printers-job-unknown = Inconnu +printers-jobs = Travaux d'impression +printers-loading = Recherche des périphériques… +printers-location = Emplacement +printers-make-default = Définir par défaut +printers-name = Nom +printers-no-devices = Aucun périphérique trouvé +printers-no-matching-drivers = Aucun pilote ne correspond +printers-no-printers = Aucune imprimante configurée +printers-not-accepting = N'accepte pas les travaux +printers-pause = Suspendre +printers-remove = Supprimer l'imprimante +printers-remove-confirm = Supprimer cette imprimante ? +printers-remove-confirm-description = « { $name } » sera supprimée du système. Les travaux en attente sur cette imprimante seront abandonnés. +printers-resume = Reprendre +printers-state = État +printers-state-idle = Disponible +printers-state-processing = Impression en cours +printers-state-reasons = Causes de l'état +printers-state-stopped = En pause +printers-state-unknown = Inconnu +printers-submit = Ajouter +printers-test-page = Imprimer une page de test + ## Power power = Alimentation