feat(printers): options d'impression par defaut (format, qualite, recto-verso)

Le tiroir de details charge les capacites reelles de la file (lpoptions -l) et expose des menus deroulants PageSize, cupsPrintQuality et Duplex. Chaque selection est persistee comme defaut systeme de la file via lpadmin -o puis relue. Les options non supportees par un pilote sont masquees.
This commit is contained in:
Lionel DARNIS 2026-08-08 13:34:01 +02:00
parent aec716fa6a
commit 8d528f9e45
4 changed files with 256 additions and 3 deletions

View file

@ -200,6 +200,72 @@ pub async fn remove_printer(name: &str) -> Result<(), String> {
}
}
/// Default job options supported by a queue, with their current values.
///
/// Values are taken from the queue's PPD view (`lpoptions -l`): `PageSize`,
/// `cupsPrintQuality` and `Duplex`, which `lpadmin -o` can persist.
#[derive(Clone, Debug, Default)]
pub struct PrinterOptions {
pub media: Vec<String>,
pub media_default: Option<String>,
pub qualities: Vec<String>,
pub quality_default: Option<String>,
pub sides: Vec<String>,
pub sides_default: Option<String>,
}
pub async fn printer_options(name: &str) -> Result<PrinterOptions, String> {
let output = run_cli_capture("lpoptions", &["-p", name, "-l"]).await?;
let mut options = PrinterOptions::default();
for line in output.lines() {
let Some((key, values)) = line.split_once(':') else {
continue;
};
let key = key.split('/').next().unwrap_or(key).trim();
let parsed: Vec<(String, bool)> = values
.split_whitespace()
.map(|value| {
(
value.trim_start_matches('*').to_string(),
value.starts_with('*'),
)
})
.collect();
let (list, default) = match key {
"PageSize" => (&mut options.media, &mut options.media_default),
"cupsPrintQuality" => (&mut options.qualities, &mut options.quality_default),
"Duplex" => (&mut options.sides, &mut options.sides_default),
_ => continue,
};
for (value, selected) in parsed {
// Custom.WIDTHxHEIGHT placeholders are not selectable values.
if value.starts_with("Custom.") {
continue;
}
if selected {
*default = Some(value.clone());
}
list.push(value);
}
}
Ok(options)
}
/// Set a queue-wide default job option (PageSize, cupsPrintQuality, Duplex…).
pub async fn set_default_option(printer: &str, option: &str, value: &str) -> Result<(), String> {
run_cli(
"lpadmin",
&["-p", printer, "-o", &format!("{option}={value}")],
)
.await
}
pub async fn set_default(name: &str) -> Result<(), String> {
run_cli("lpadmin", &["-d", name]).await
}

View file

@ -46,6 +46,7 @@ pub struct Page {
printers: Vec<backend::Printer>,
jobs: Vec<backend::Job>,
selected: Option<String>,
options: Option<(String, backend::PrinterOptions)>,
notice: Option<String>,
dialog: Option<Dialog>,
add: AddFlow,
@ -65,6 +66,13 @@ pub enum Message {
CancelDialog,
CancelJob(i32),
OperationDone(Result<(), String>),
OptionsLoaded(String, Result<backend::PrinterOptions, String>),
SetOption {
printer: String,
option: String,
value: String,
},
Surface(cosmic::surface::Action),
OpenAdd,
Devices(Result<Vec<backend::Device>, String>),
Drivers(Result<Vec<backend::Driver>, String>),
@ -112,6 +120,24 @@ fn printer_icon(printer: &backend::Printer) -> &'static str {
}
}
fn quality_label(value: &str) -> String {
match value.to_lowercase().as_str() {
"draft" => fl!("printers-quality-draft"),
"normal" => fl!("printers-quality-normal"),
"high" => fl!("printers-quality-high"),
_ => value.to_string(),
}
}
fn sides_label(value: &str) -> String {
match value {
"None" => fl!("printers-sides-one-sided"),
"DuplexNoTumble" => fl!("printers-sides-long-edge"),
"DuplexTumble" => fl!("printers-sides-short-edge"),
_ => value.to_string(),
}
}
impl page::Page<crate::pages::Message> for Page {
fn info(&self) -> page::Info {
page::Info::new("printers", "printer-symbolic")
@ -137,6 +163,7 @@ impl page::Page<crate::pages::Message> for Page {
fn on_context_drawer_close(&mut self) -> Task<crate::pages::Message> {
self.add = AddFlow::default();
self.selected = None;
self.options = None;
Task::none()
}
@ -217,11 +244,12 @@ impl Page {
}
Message::Select(name) => {
self.selected = Some(name);
self.selected = Some(name.clone());
self.options = None;
let open_drawer: cosmic::Task<crate::Message> = cosmic::task::message(
crate::app::Message::OpenContextDrawer(self.entity),
);
return open_drawer;
return Task::batch([open_drawer, Self::fetch_options(name).map(Into::into)]);
}
Message::SetDefault(name) => {
@ -252,6 +280,7 @@ impl Page {
return Task::none();
};
self.selected = None;
self.options = None;
return Self::admin(async move { backend::remove_printer(&name).await });
}
@ -271,7 +300,31 @@ impl Page {
if let Err(err) = result {
self.notice = Some(fl!("printers-error", error = err));
}
return self.reload().map(Into::into);
let mut tasks: Vec<cosmic::Task<crate::Message>> =
vec![self.reload().map(Into::into)];
if let Some(selected) = self.selected.clone() {
tasks.push(Self::fetch_options(selected).map(Into::into));
}
return Task::batch(tasks);
}
Message::OptionsLoaded(name, result) => {
self.options = result.ok().map(|options| (name, options));
}
Message::SetOption {
printer,
option,
value,
} => {
self.notice = None;
return Self::admin(async move {
backend::set_default_option(&printer, &option, &value).await
});
}
Message::Surface(action) => {
return cosmic::task::message(crate::app::Message::Surface(action));
}
Message::OpenAdd => {
@ -360,6 +413,7 @@ impl Page {
Message::AddDone(result) => match result {
Ok(name) => {
self.add = AddFlow::default();
self.options = None;
self.notice = Some(fl!("printers-added", name = name));
let close_drawer: cosmic::Task<crate::Message> = cosmic::task::message(
@ -378,6 +432,13 @@ impl Page {
Task::none()
}
fn fetch_options(name: String) -> Task<crate::pages::Message> {
cosmic::task::future(async move {
Message::OptionsLoaded(name.clone(), backend::printer_options(&name).await)
})
.map(crate::pages::Message::Printers)
}
fn reload(&self) -> Task<crate::pages::Message> {
Task::batch([
cosmic::task::future(async {
@ -487,6 +548,112 @@ impl Page {
content = content.push(actions);
if let Some((options_name, options)) = self.options.as_ref()
&& options_name == &printer.name
&& (!options.media.is_empty()
|| !options.qualities.is_empty()
|| !options.sides.is_empty())
{
content = content.push(text::body(fl!("printers-options")));
let mut list = widget::list_column();
if !options.media.is_empty() {
let values = options.media.clone();
let selected = options
.media_default
.as_ref()
.and_then(|default| values.iter().position(|value| value == default));
let labels = values.clone();
let name = printer.name.clone();
list = list.add(
widget::settings::item::builder(fl!("printers-option-media")).control(
widget::dropdown::popup_dropdown(
labels,
selected,
move |index| Message::SetOption {
printer: name.clone(),
option: "PageSize".to_string(),
value: values.get(index).cloned().unwrap_or_default(),
},
cosmic::iced::window::Id::RESERVED,
Message::Surface,
|action| {
crate::app::Message::PageMessage(crate::pages::Message::Printers(
action,
))
},
),
),
);
}
if !options.qualities.is_empty() {
let values = options.qualities.clone();
let selected = options
.quality_default
.as_ref()
.and_then(|default| values.iter().position(|value| value == default));
let labels: Vec<String> = values.iter().map(|v| quality_label(v)).collect();
let name = printer.name.clone();
list = list.add(
widget::settings::item::builder(fl!("printers-option-quality")).control(
widget::dropdown::popup_dropdown(
labels,
selected,
move |index| Message::SetOption {
printer: name.clone(),
option: "cupsPrintQuality".to_string(),
value: values.get(index).cloned().unwrap_or_default(),
},
cosmic::iced::window::Id::RESERVED,
Message::Surface,
|action| {
crate::app::Message::PageMessage(crate::pages::Message::Printers(
action,
))
},
),
),
);
}
if !options.sides.is_empty() {
let values = options.sides.clone();
let selected = options
.sides_default
.as_ref()
.and_then(|default| values.iter().position(|value| value == default));
let labels: Vec<String> = values.iter().map(|v| sides_label(v)).collect();
let name = printer.name.clone();
list = list.add(
widget::settings::item::builder(fl!("printers-option-sides")).control(
widget::dropdown::popup_dropdown(
labels,
selected,
move |index| Message::SetOption {
printer: name.clone(),
option: "Duplex".to_string(),
value: values.get(index).cloned().unwrap_or_default(),
},
cosmic::iced::window::Id::RESERVED,
Message::Surface,
|action| {
crate::app::Message::PageMessage(crate::pages::Message::Printers(
action,
))
},
),
),
);
}
content = content.push(list);
}
let jobs: Vec<&backend::Job> = self
.jobs
.iter()

View file

@ -673,12 +673,22 @@ printers-no-matching-drivers = No matching drivers
printers-no-printers = No printers configured
printers-no-printers-desc = Printers connected to this computer or discovered on the network will appear here.
printers-not-accepting = Not accepting jobs
printers-option-media = Paper format
printers-option-quality = Print quality
printers-option-sides = Two-sided printing
printers-options = Print options
printers-pause = Pause
printers-quality-draft = Draft
printers-quality-high = High
printers-quality-normal = Normal
printers-remove = Remove printer
printers-remove-confirm = Remove this printer?
printers-remove-confirm-button = Remove
printers-remove-confirm-description = “{ $name }” will be removed from this system. Print jobs queued on it will be discarded.
printers-resume = Resume
printers-sides-long-edge = Long edge
printers-sides-one-sided = One-sided
printers-sides-short-edge = Short edge
printers-state = State
printers-state-idle = Idle
printers-state-processing = Printing

View file

@ -461,12 +461,22 @@ printers-no-matching-drivers = Aucun pilote ne correspond
printers-no-printers = Aucune imprimante configurée
printers-no-printers-desc = Les imprimantes connectées à cet ordinateur ou découvertes sur le réseau apparaîtront ici.
printers-not-accepting = N'accepte pas les travaux
printers-option-media = Format du papier
printers-option-quality = Qualité d'impression
printers-option-sides = Impression recto-verso
printers-options = Options d'impression
printers-pause = Suspendre
printers-quality-draft = Brouillon
printers-quality-high = Haute
printers-quality-normal = Normale
printers-remove = Supprimer l'imprimante
printers-remove-confirm = Supprimer cette imprimante ?
printers-remove-confirm-button = Supprimer
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-sides-long-edge = Bords longs
printers-sides-one-sided = Recto seul
printers-sides-short-edge = Bords courts
printers-state = État
printers-state-idle = Disponible
printers-state-processing = Impression en cours