diff --git a/cosmic-settings/src/pages/printers/backend.rs b/cosmic-settings/src/pages/printers/backend.rs index d1bb95f..0bc3bfc 100644 --- a/cosmic-settings/src/pages/printers/backend.rs +++ b/cosmic-settings/src/pages/printers/backend.rs @@ -273,6 +273,130 @@ pub async fn set_default_option(printer: &str, option: &str, value: &str) -> Res .await } +/// A consumable reported by the printer through the IPP marker attributes. +#[derive(Clone, Debug)] +pub struct Marker { + pub name: String, + /// 0-100; negative values mean unknown. + pub level: i32, + /// "#RRGGBB" when the printer advertises a color. + pub color: Option, +} + +/// Query ink levels directly from the printer. +/// +/// The scheduler does not relay marker levels for driverless queues, so the +/// printer's IPP endpoint is resolved (dnssd -> ippfind) and queried with +/// ipptool. Returns an empty list when the printer does not report markers. +pub async fn markers(device_uri: &str) -> Vec { + let Some(endpoint) = resolve_endpoint(device_uri).await else { + return Vec::new(); + }; + + let names = query_marker_attribute(&endpoint, "marker-names").await; + let raw_levels = query_marker_attribute(&endpoint, "marker-levels").await; + let colors = query_marker_attribute(&endpoint, "marker-colors").await; + + let levels: Vec = raw_levels + .iter() + .filter_map(|value| value.trim().parse::().ok()) + .collect(); + + if levels.is_empty() { + return Vec::new(); + } + + (0..levels.len()) + .map(|index| Marker { + name: names + .get(index) + .cloned() + .unwrap_or_else(|| format!("marker {}", index + 1)), + level: levels[index], + color: colors.get(index).cloned().filter(|value| value.starts_with('#')), + }) + .collect() +} + +/// Resolve a device URI into a queryable IPP endpoint. +async fn resolve_endpoint(device_uri: &str) -> Option { + if device_uri.starts_with("ipp://") || device_uri.starts_with("ipps://") { + return Some(device_uri.to_string()); + } + + let rest = device_uri.strip_prefix("dnssd://")?; + let name = rest.split(['/', '?']).next()?; + let name = percent_decode(name.split("._").next()?); + + let output = run_cli_capture( + "timeout", + &["8", "ippfind", "_ipp._tcp", "_ipps._tcp", "-N", &name, "-p"], + ) + .await + .ok()?; + + let mut fallback = None; + for line in output.lines() { + let line = line.trim(); + if line.starts_with("ipps://") { + return Some(line.to_string()); + } + if fallback.is_none() && line.starts_with("ipp://") { + fallback = Some(line.to_string()); + } + } + fallback +} + +/// Run one ipptool Get-Printer-Attributes query and return the displayed +/// values of a single marker attribute. +async fn query_marker_attribute(endpoint: &str, attribute: &str) -> Vec { + let test_file = std::env::temp_dir().join(format!("cosmic-settings-printers-{attribute}.test")); + + let content = format!( + "{{\n\ + \x20 NAME \"{attribute}\"\n\ + \x20 OPERATION Get-Printer-Attributes\n\ + \x20 GROUP operation-attributes-tag\n\ + \x20 ATTR charset attributes-charset utf-8\n\ + \x20 ATTR naturalLanguage attributes-natural-language en\n\ + \x20 ATTR uri printer-uri $uri\n\ + \x20 ATTR keyword requested-attributes {attribute}\n\ + \x20 STATUS successful-ok\n\ + \x20 DISPLAY {attribute}\n\ + }}\n" + ); + + if tokio::fs::write(&test_file, content).await.is_err() { + return Vec::new(); + } + + let Some(file_path) = test_file.to_str() else { + return Vec::new(); + }; + + let Ok(output) = + run_cli_capture("timeout", &["8", "ipptool", endpoint, file_path]).await + else { + return Vec::new(); + }; + + parse_display_values(&output).unwrap_or_default() +} + +/// Extract the value cell of a single-DISPLAY ipptool table: the line +/// following the dashes separator, split on commas. +fn parse_display_values(output: &str) -> Option> { + let mut lines = output.lines(); + while let Some(line) = lines.next() { + if !line.trim().is_empty() && line.trim().chars().all(|c| c == '-') { + let values = lines.next()?.trim(); + return Some(values.split(',').map(|value| value.trim().to_string()).collect()); + } + } + None +} + pub async fn set_default(name: &str) -> Result<(), String> { run_cli("lpadmin", &["-d", name]).await } diff --git a/cosmic-settings/src/pages/printers/mod.rs b/cosmic-settings/src/pages/printers/mod.rs index f259400..c15dc58 100644 --- a/cosmic-settings/src/pages/printers/mod.rs +++ b/cosmic-settings/src/pages/printers/mod.rs @@ -9,7 +9,7 @@ mod backend; -use cosmic::iced::{Alignment, Length, Subscription}; +use cosmic::iced::{Alignment, Background, Border, Color, Length, Shadow, Subscription}; use cosmic::widget::{self, text}; use cosmic::{Apply, Element, Task, theme}; use cosmic_settings_page::{self as page, Section, section}; @@ -47,6 +47,7 @@ pub struct Page { jobs: Vec, selected: Option, options: Option<(String, backend::PrinterOptions)>, + markers: Vec, notice: Option, dialog: Option, add: AddFlow, @@ -67,6 +68,7 @@ pub enum Message { CancelJob(i32), OperationDone(Result<(), String>), OptionsLoaded(String, Result), + MarkersLoaded(Vec), SetOption { printer: String, option: String, @@ -173,6 +175,56 @@ fn sides_label(value: &str) -> String { } } +fn ink_label(name: &str) -> String { + match name.to_lowercase().as_str() { + "black cartridge" | "black" => fl!("printers-ink-black"), + "cyan cartridge" | "cyan" => fl!("printers-ink-cyan"), + "magenta cartridge" | "magenta" => fl!("printers-ink-magenta"), + "yellow cartridge" | "yellow" => fl!("printers-ink-yellow"), + _ => name.to_string(), + } +} + +fn parse_hex_color(value: &str) -> Option { + let hex = value.trim().trim_start_matches('#'); + if hex.len() != 6 { + return None; + } + let red = u8::from_str_radix(&hex[0..2], 16).ok()?; + let green = u8::from_str_radix(&hex[2..4], 16).ok()?; + let blue = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Color::from_rgb( + red as f32 / 255.0, + green as f32 / 255.0, + blue as f32 / 255.0, + )) +} + +fn ink_swatch(color: Color) -> Element<'static, Message> { + widget::container(widget::space::horizontal()) + .width(Length::Fixed(14.0)) + .height(Length::Fixed(14.0)) + .class(theme::Container::custom(move |_theme| { + widget::container::Style { + icon_color: None, + text_color: None, + background: Some(Background::Color(color)), + border: Border { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.25), + width: 1.0, + radius: 7.0.into(), + }, + shadow: Shadow { + color: Color::TRANSPARENT, + offset: cosmic::iced::Vector::new(0.0, 0.0), + blur_radius: 0.0, + }, + snap: true, + } + })) + .apply(Element::from) +} + impl page::Page for Page { fn info(&self) -> page::Info { page::Info::new("printers", "printer-symbolic") @@ -199,6 +251,7 @@ impl page::Page for Page { self.add = AddFlow::default(); self.selected = None; self.options = None; + self.markers = Vec::new(); Task::none() } @@ -281,10 +334,23 @@ impl Page { Message::Select(name) => { self.selected = Some(name.clone()); self.options = None; + self.markers = Vec::new(); let open_drawer: cosmic::Task = cosmic::task::message( crate::app::Message::OpenContextDrawer(self.entity), ); - return Task::batch([open_drawer, Self::fetch_options(name).map(Into::into)]); + let mut tasks: Vec> = vec![ + open_drawer, + Self::fetch_options(name.clone()).map(Into::into), + ]; + if let Some(device_uri) = self + .printers + .iter() + .find(|printer| printer.name == name) + .and_then(|printer| printer.device_uri.clone()) + { + tasks.push(Self::fetch_markers(device_uri).map(Into::into)); + } + return Task::batch(tasks); } Message::SetDefault(name) => { @@ -316,6 +382,7 @@ impl Page { }; self.selected = None; self.options = None; + self.markers = Vec::new(); return Self::admin(async move { backend::remove_printer(&name).await }); } @@ -347,6 +414,10 @@ impl Page { self.options = result.ok().map(|options| (name, options)); } + Message::MarkersLoaded(markers) => { + self.markers = markers; + } + Message::SetOption { printer, option, @@ -449,6 +520,7 @@ impl Page { Ok(name) => { self.add = AddFlow::default(); self.options = None; + self.markers = Vec::new(); self.notice = Some(fl!("printers-added", name = name)); let close_drawer: cosmic::Task = cosmic::task::message( @@ -474,6 +546,13 @@ impl Page { .map(crate::pages::Message::Printers) } + fn fetch_markers(device_uri: String) -> Task { + cosmic::task::future(async move { + Message::MarkersLoaded(backend::markers(&device_uri).await) + }) + .map(crate::pages::Message::Printers) + } + fn reload(&self) -> Task { Task::batch([ cosmic::task::future(async { @@ -753,6 +832,49 @@ impl Page { content = content.push(list); } + if !self.markers.is_empty() { + content = content.push(text::body(fl!("printers-ink-levels"))); + + let mut rows = + widget::column::with_children(Vec::>::new()) + .spacing(spacing.space_xs); + + for marker in &self.markers { + let mut children: Vec> = Vec::new(); + + if let Some(color) = marker.color.as_deref().and_then(parse_hex_color) { + children.push(ink_swatch(color)); + } + + children.push( + text::body(ink_label(&marker.name)) + .width(Length::FillPortion(2)) + .apply(Element::from), + ); + + if marker.level >= 0 { + children.push( + widget::progress_bar::determinate_linear(marker.level as f32 / 100.0) + .width(Length::FillPortion(3)) + .apply(Element::from), + ); + children.push( + text::caption(format!("{} %", marker.level)).apply(Element::from), + ); + } else { + children.push(text::caption("—").apply(Element::from)); + } + + rows = rows.push( + widget::row::with_children(children) + .spacing(spacing.space_s) + .align_y(Alignment::Center), + ); + } + + content = content.push(rows); + } + let jobs: Vec<&backend::Job> = self .jobs .iter() diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index c78ba5b..7773c83 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -663,6 +663,11 @@ printers-device = Device printers-device-uri = Device URI printers-driver = Driver printers-error = Operation failed: { $error } +printers-ink-black = Black +printers-ink-cyan = Cyan +printers-ink-levels = Ink levels +printers-ink-magenta = Magenta +printers-ink-yellow = Yellow printers-job-aborted = Aborted printers-job-canceled = Canceled printers-job-completed = Completed diff --git a/i18n/fr/cosmic_settings.ftl b/i18n/fr/cosmic_settings.ftl index e101132..53f740d 100644 --- a/i18n/fr/cosmic_settings.ftl +++ b/i18n/fr/cosmic_settings.ftl @@ -451,6 +451,11 @@ 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-ink-black = Noir +printers-ink-cyan = Cyan +printers-ink-levels = Niveaux d'encre +printers-ink-magenta = Magenta +printers-ink-yellow = Jaune printers-job-aborted = Interrompu printers-job-canceled = Annulé printers-job-completed = Terminé