craft-print, craft-ui: show printers by name, not CUPS queue name (#192)

* print, ui: a printer is shown by its name, not its CUPS queue name

CUPS queue names can't hold dots or spaces, so macOS adds the printer at
192.0.2.15 as the queue `_192_0_2_15`, and the Print dialog showed that
underscored name. The name the user knows is the queue's description
(CUPS `printer-info`).

craft-print now lists queues with `lpstat -l -p -d` (same lines, plus
each queue's indented details) and keeps the first `Description:` as
`Printer::description`, capped at 256 characters. Windows names are
already human, so they leave it empty.

craft-ui's `Printer` gains `label`; the dialog shows it, or the name when
it's empty, and `shown_names` adds the queue name in brackets when two
printers would show the same text. Selection and jobs still use `name`.

Both structs gain a field, so apps that build them by hand add it
(`..Default::default()` works for craft-ui's) when they update the pin.

* print, ui: an empty first description stays first, and shown names never collide

parse_lpstat tracks whether a queue has had its Description line, so an
empty first one isn't replaced by a later one. shown_names checks the
final texts: printers whose bracketed text another label already reads
show their queue names instead.
This commit is contained in:
Bishal De authored and GitHub committed 2026-10-20 09:08:21 -06:00
1 parent 07047b7221
commit 2bbef53f29
5 files changed
+157 -33

No files matched your search

+36 -12
View File
@@ -42,7 +42,12 @@ impl std::error::Error for Error {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Printer {
/// The destination `lp -d` and the spooler know the printer by.
pub name: String,
/// The printer's human name (CUPS `printer-info`), empty when the spooler gives none. A CUPS
/// queue name can't hold dots or spaces, so a printer added as `192.0.2.15` is the queue
/// `_192_0_2_15` described as `192.0.2.15`. Windows names are already human: empty there.
pub description: String,
pub default: bool,
/// Whether the Windows driver reports duplex support; None when unknown (CUPS).
pub two_sided: Option<bool>,
@@ -103,14 +108,30 @@ fn default_destination(out: &str) -> Option<&str> {
out.lines().find_map(|l| l.strip_prefix("system default destination:")).map(str::trim)
}
/// Parse `lpstat -p -d` output.
/// The longest description kept, in characters: a dialog shows a name, not a paragraph.
const MAX_DESCRIPTION: usize = 256;
/// Parse `lpstat -p -d` output, or `lpstat -l -p -d`, whose indented `Description:` line under a
/// queue becomes its [`Printer::description`].
pub fn parse_lpstat(out: &str) -> Vec<Printer> {
let default = default_destination(out);
out.lines()
.filter_map(|l| l.strip_prefix("printer "))
.filter_map(|l| l.split_whitespace().next())
.map(|n| Printer { name: n.to_string(), default: default == Some(n), two_sided: None })
.collect()
let mut printers: Vec<Printer> = Vec::new();
// Whether the last queue has had its Description line, even an empty one.
let mut described = false;
for line in out.lines() {
if let Some(name) = line.strip_prefix("printer ").and_then(|l| l.split_whitespace().next()) {
printers.push(Printer { name: name.to_string(), description: String::new(), default: default == Some(name), two_sided: None });
described = false;
} else if line.starts_with(char::is_whitespace) && !described {
// Only a queue's own detail lines are indented; the first description wins.
let Some(description) = line.trim_start().strip_prefix("Description:") else { continue };
if let Some(printer) = printers.last_mut() {
printer.description = description.trim().chars().take(MAX_DESCRIPTION).collect();
described = true;
}
}
}
printers
}
/// Parse `lpstat -e` output: destination names, one per line.
@@ -345,9 +366,9 @@ fn lpstat_with(args: &[&str]) -> std::process::Command {
c
}
/// `lpstat -p -d`: the spooler's queues and the default destination.
/// `lpstat -l -p -d`: the spooler's queues with their descriptions, and the default destination.
pub fn lpstat_command() -> std::process::Command {
lpstat_with(&["-p", "-d"])
lpstat_with(&["-l", "-p", "-d"])
}
/// `lpstat -e`: every destination CUPS can print to, including driverless network printers no
@@ -394,7 +415,7 @@ pub fn printers_parsed(queues: &str, available: Option<&str>) -> Vec<Printer> {
if let Some(names) = available {
for name in parse_lpstat_e(names) {
if !printers.iter().any(|p| p.name == name) {
printers.push(Printer { default: default == Some(name.as_str()), name, two_sided: None });
printers.push(Printer { default: default == Some(name.as_str()), name, description: String::new(), two_sided: None });
}
}
}
@@ -409,7 +430,10 @@ pub(crate) fn printer_list(queues: impl IntoIterator<Item = (String, Option<bool
let mut queues: Vec<(String, Option<bool>)> = queues.into_iter().filter(|(n, _)| !n.trim().is_empty()).collect();
queues.sort_by(|a, b| a.0.cmp(&b.0));
queues.dedup_by(|a, b| a.0 == b.0);
queues.into_iter().map(|(name, two_sided)| Printer { default: default == Some(name.as_str()), name, two_sided }).collect()
queues
.into_iter()
.map(|(name, two_sided)| Printer { default: default == Some(name.as_str()), name, description: String::new(), two_sided })
.collect()
}
/// A printer list in the text form craft-print's former PowerShell backend printed: one per line,
@@ -428,8 +452,8 @@ pub fn parse_windows_printers(out: &str) -> Vec<Printer> {
_ => (l, None),
};
match l.strip_prefix('*') {
Some(name) => Printer { name: name.to_string(), default: true, two_sided },
None => Printer { name: l.to_string(), default: false, two_sided },
Some(name) => Printer { name: name.to_string(), description: String::new(), default: true, two_sided },
None => Printer { name: l.to_string(), description: String::new(), default: false, two_sided },
}
})
.collect();
+42 -13
View File
@@ -9,8 +9,8 @@ fn spooler_arguments_and_printer_list() {
assert_eq!(
parse_lpstat(out),
[
spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None },
spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None }
spool::Printer { name: "Office_Laser".into(), description: String::new(), default: true, two_sided: None },
spool::Printer { name: "Label_Writer".into(), description: String::new(), default: false, two_sided: None }
]
);
assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty());
@@ -44,6 +44,32 @@ fn lpstat_output_is_untranslated() {
assert!(envs.iter().any(|(k, v)| k == "SOFTWARE" && v.as_deref().is_some_and(|v| !v.is_empty())), "SOFTWARE missing: {envs:?}");
}
#[test]
fn a_queue_keeps_its_human_name() {
// A CUPS queue name can't hold dots, so macOS adds the printer at 192.0.2.15 as the queue
// `_192_0_2_15`. The dialog showed that underscored name; `lpstat -l` also gives the name the
// user knows, as the queue's Description.
let out = "printer _192_0_2_15 is idle. enabled since Thu Oct 1 09:00:00 2026\n\tForm mounted:\n\tDescription: 192.0.2.15\n\tAlerts: none\n\tLocation: Description: not this one\nprinter Label_Writer disabled since Thu Oct 1 09:00:00 2026 -\n\treason unknown\n\tDescription:\nprinter Office_Laser is idle.\r\n\tDescription: Office Laser, 2nd floor\r\n\tDescription: a second one is ignored\nDescription: not indented, not a detail\nsystem default destination: _192_0_2_15\n";
assert_eq!(
parse_lpstat(out),
[
spool::Printer { name: "_192_0_2_15".into(), description: "192.0.2.15".into(), default: true, two_sided: None },
spool::Printer { name: "Label_Writer".into(), description: String::new(), default: false, two_sided: None },
spool::Printer { name: "Office_Laser".into(), description: "Office Laser, 2nd floor".into(), default: false, two_sided: None },
],
"each queue takes its own first Description; an empty one stays empty"
);
// A description before any queue belongs to none, and a huge one is cut to a name's length.
assert!(parse_lpstat("\tDescription: orphan\n").is_empty());
// An empty first description is still the first: a later one doesn't replace it.
assert_eq!(parse_lpstat("printer Q is idle.\n\tDescription:\n\tDescription: Office\n")[0].description, "");
let long = format!("printer Q is idle.\n\tDescription: {}\n", "é".repeat(10_000));
assert_eq!(parse_lpstat(&long)[0].description.chars().count(), 256);
// The queues come from `lpstat -l`, so the descriptions are there to read.
let args: Vec<_> = spool::lpstat_command().get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(args, ["-l", "-p", "-d"]);
}
#[test]
fn driverless_printers_join_the_list_without_a_queue() {
// A driverless network printer (IPP, discovered on the LAN) has no queue, so `lpstat -p`
@@ -53,18 +79,21 @@ fn driverless_printers_join_the_list_without_a_queue() {
assert_eq!(
spool::printers_parsed(queues, Some("Basement_Color\nOffice_Laser\n")),
[
spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None },
spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None },
spool::Printer { name: "Office_Laser".into(), description: String::new(), default: true, two_sided: None },
spool::Printer { name: "Basement_Color".into(), description: String::new(), default: false, two_sided: None },
],
"queues first, then the driverless destination; the queue listed twice is not doubled"
);
// An old spooler without `-e` (CUPS < 1.7) keeps its queues; only the driverless names are lost.
assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }]);
assert_eq!(
spool::printers_parsed(queues, None),
[spool::Printer { name: "Office_Laser".into(), description: String::new(), default: true, two_sided: None }]
);
// The default can itself be driverless (`lpoptions -d` names a destination no queue exists
// for): it keeps its marker, so the dialog preselects the user's default, not the first queue.
assert_eq!(
spool::printers_parsed("system default destination: Basement_Color\n", Some("Basement_Color\n")),
[spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None }],
[spool::Printer { name: "Basement_Color".into(), description: String::new(), default: true, two_sided: None }],
"a default destination without a queue is still the default"
);
assert!(spool::parse_lpstat_e("\n \n").is_empty(), "no destinations is empty, not [\"\"]");
@@ -156,16 +185,16 @@ fn windows_printers_carry_one_default_and_keep_their_names() {
// Windows marks the system default with a `*` no queue name may contain; names hold spaces,
// parentheses and non-ASCII letters, and the lines end with CRLF.
let printers = parse_windows_printers("*Microsoft Print to PDF\r\nHP LaserJet 400 (Copy 2)\r\nКопир Canon iR\r\n\r\n*XPS via USB\r\n");
assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None });
assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None });
assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None });
assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), description: String::new(), default: true, two_sided: None });
assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), description: String::new(), default: false, two_sided: None });
assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), description: String::new(), default: false, two_sided: None });
// A spooler marking two defaults: the first still wins, so the dialog has one to preselect.
assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None });
assert_eq!(printers[3], Printer { name: "XPS via USB".into(), description: String::new(), default: false, two_sided: None });
assert_eq!(parse_windows_printers("\r\n"), Vec::new());
let sided = parse_windows_printers("*Office PCL\t2\r\nOffice PS\t1\r\nTab\tName\t2\r\n");
assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) });
assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false) });
assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) });
assert_eq!(sided[0], Printer { name: "Office PCL".into(), description: String::new(), default: true, two_sided: Some(true) });
assert_eq!(sided[1], Printer { name: "Office PS".into(), description: String::new(), default: false, two_sided: Some(false) });
assert_eq!(sided[2], Printer { name: "Tab\tName".into(), description: String::new(), default: false, two_sided: Some(true) });
}
/// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1
+4 -4
View File
@@ -21,10 +21,10 @@ fn windows_printers_are_sorted_once_with_their_default() {
assert_eq!(
printers,
[
Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: Some(true) },
Printer { name: "Zebra Label".into(), default: false, two_sided: Some(false) },
Printer { name: r"\\print01\Office, 2nd floor".into(), default: true, two_sided: Some(true) },
Printer { name: "Копир Canon iR".into(), default: false, two_sided: None },
Printer { name: "HP LaserJet 400 (Copy 2)".into(), description: String::new(), default: false, two_sided: Some(true) },
Printer { name: "Zebra Label".into(), description: String::new(), default: false, two_sided: Some(false) },
Printer { name: r"\\print01\Office, 2nd floor".into(), description: String::new(), default: true, two_sided: Some(true) },
Printer { name: "Копир Canon iR".into(), description: String::new(), default: false, two_sided: None },
]
);
assert!(spool::printer_list([("Office".to_string(), None)], Some("Office 2")).iter().all(|p| !p.default));
+40 -3
View File
@@ -5,10 +5,45 @@ use std::sync::mpsc::{Receiver, TryRecvError};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Printer {
/// The spooler's destination: what [`Settings::printer`] and jobs name.
pub name: String,
/// What the dialog shows; empty shows `name`. CUPS, for one, describes the queue
/// `_192_0_2_15` as `192.0.2.15`, the name the user gave the printer.
pub label: String,
pub default: bool,
}
/// The text the dialog shows for each of `printers`, in order: its label, or its name when it has
/// none. Text two printers would share is followed by the name in brackets, so neither hides the
/// other; when even that text is shared (another printer's label is `Office (Office_1)`), those
/// printers show their names, which the spooler keeps distinct.
pub fn shown_names(printers: &[Printer]) -> Vec<String> {
let base: Vec<&str> = printers.iter().map(|p| if p.label.trim().is_empty() { p.name.as_str() } else { p.label.as_str() }).collect();
let mut shown: Vec<String> = printers
.iter()
.zip(&base)
.map(|(printer, text)| {
let shared = base.iter().filter(|other| *other == text).count() > 1;
if shared && *text != printer.name { format!("{text} ({})", printer.name) } else { (*text).to_owned() }
})
.collect();
// Each pass turns at least one shared text into its name, or stops: at most one per printer.
for _ in 0..printers.len() {
let shared: Vec<bool> = shown.iter().map(|text| shown.iter().filter(|other| *other == text).count() > 1).collect();
let mut changed = false;
for ((text, printer), shared) in shown.iter_mut().zip(printers).zip(shared) {
if shared && *text != printer.name {
text.clone_from(&printer.name);
changed = true;
}
}
if !changed {
break;
}
}
shown
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Duplex {
Off,
@@ -160,13 +195,15 @@ impl State {
ui.add_enabled_ui(!self.busy(), |ui| {
let width = ui.available_width().clamp(32.0, 230.0);
let printer_label = ui.label(labels.printer);
let shown = shown_names(&self.printers);
let selected = self.printers.iter().zip(&shown).find(|(p, _)| p.name == self.settings.printer).map(|(_, text)| text.as_str());
egui::ComboBox::from_id_salt("printer")
.width(width)
.wrap_mode(egui::TextWrapMode::Truncate)
.selected_text(if self.settings.printer.is_empty() { labels.select_printer } else { &self.settings.printer })
.selected_text(if self.settings.printer.is_empty() { labels.select_printer } else { selected.unwrap_or(&self.settings.printer) })
.show_ui(ui, |ui| {
for printer in &self.printers {
ui.selectable_value(&mut self.settings.printer, printer.name.clone(), &printer.name);
for (printer, text) in self.printers.iter().zip(&shown) {
ui.selectable_value(&mut self.settings.printer, printer.name.clone(), text);
}
})
.response
+35 -1
View File
@@ -1,7 +1,41 @@
use super::*;
fn queue(name: &str, default: bool) -> Printer {
Printer { name: name.into(), default }
Printer { name: name.into(), label: String::new(), default }
}
fn labelled(name: &str, label: &str) -> Printer {
Printer { name: name.into(), label: label.into(), default: false }
}
#[test]
fn the_dialog_shows_a_printers_label_and_keeps_its_queue_name() {
// CUPS can't put dots in a queue name: the printer added as 192.0.2.15 is `_192_0_2_15`.
let printers = [labelled("_192_0_2_15", "192.0.2.15"), queue("Plain_Queue", false), labelled("Blank", " ")];
assert_eq!(shown_names(&printers), ["192.0.2.15", "Plain_Queue", "Blank"], "the label, else the name");
// Two printers described alike stay apart by their queue names; a unique label stays bare.
let printers = [labelled("Office_1", "Office"), labelled("Office_2", "Office"), labelled("Lab", "Lab printer")];
assert_eq!(shown_names(&printers), ["Office (Office_1)", "Office (Office_2)", "Lab printer"]);
// A label that is another queue's name doesn't pass for that queue.
assert_eq!(shown_names(&[queue("Office", false), labelled("Office_2", "Office")]), ["Office", "Office (Office_2)"]);
// A label that is another printer's bracketed text doesn't make two entries read alike: the
// printers whose text is still shared show their queue names.
let printers = [labelled("Office_1", "Office"), labelled("Office_2", "Office"), labelled("Office_3", "Office (Office_1)")];
assert_eq!(shown_names(&printers), ["Office_1", "Office (Office_2)", "Office_3"]);
assert!(shown_names(&[]).is_empty());
// Choosing still selects the queue: jobs go to the name, not the label.
let ctx = egui::Context::default();
let mut state = State::default();
let discover: Discover = Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(vec![Printer { name: "_192_0_2_15".into(), label: "192.0.2.15".into(), default: true }]).unwrap();
receiver
});
assert!(state.refresh(&discover));
state.poll(&ctx);
assert_eq!(state.settings.printer, "_192_0_2_15");
assert!(state.ready());
}
#[test]