feat(printers): page native de gestion des imprimantes (cups_rs + CUPS CLI)
Nouvelle page Imprimantes: liste et etat des files CUPS, ajout (decouverte lpinfo, pilote driverless IPP Everywhere par defaut), suppression, imprimante par defaut, suspension/reprise, page de test et file des travaux avec annulation. Lectures via cups_rs (FFI libcups serialisee), mutations via lpadmin/cupsenable. i18n en/fr, sous-commande 'cosmic-settings printers'.
This commit is contained in:
parent
f9b27c8d4d
commit
26dcaa6fd3
9 changed files with 1280 additions and 16 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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::<power::Page>(),
|
||||
#[cfg(feature = "page-printers")]
|
||||
PageCommands::Printers => self.pages.page_id::<printers::Page>(),
|
||||
#[cfg(feature = "page-region")]
|
||||
PageCommands::RegionLanguage => self.pages.page_id::<time::region::Page>(),
|
||||
#[cfg(feature = "page-sound")]
|
||||
|
|
@ -225,6 +229,8 @@ impl cosmic::Application for SettingsApp {
|
|||
app.insert_page::<power::Page>();
|
||||
#[cfg(feature = "page-input")]
|
||||
app.insert_page::<input::Page>();
|
||||
#[cfg(feature = "page-printers")]
|
||||
app.insert_page::<printers::Page>();
|
||||
app.insert_page::<applications::Page>();
|
||||
app.insert_page::<time::Page>();
|
||||
app.insert_page::<system::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::<printers::Page>() {
|
||||
return page.update(message).map(Into::into);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "page-networking")]
|
||||
crate::pages::Message::Vpn(message) => {
|
||||
if let Some(page) = self.pages.page_mut::<networking::vpn::Page>() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
285
cosmic-settings/src/pages/printers/backend.rs
Normal file
285
cosmic-settings/src/pages/printers/backend.rs
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
// Copyright 2026 Lionel DARNIS <lionel@wopr.io>
|
||||
// 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<String>,
|
||||
pub location: Option<String>,
|
||||
pub device_uri: Option<String>,
|
||||
pub state: PrinterState,
|
||||
pub state_reasons: Vec<String>,
|
||||
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<Vec<Printer>, 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<Vec<Job>, 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<Vec<Device>, 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<Vec<Driver>, String> {
|
||||
let output = run_cli_capture("lpinfo", &["-m"]).await?;
|
||||
|
||||
let mut drivers: Vec<Driver> = 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<String, String> {
|
||||
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::<Vec<_>>()
|
||||
.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()
|
||||
}
|
||||
772
cosmic-settings/src/pages/printers/mod.rs
Normal file
772
cosmic-settings/src/pages/printers/mod.rs
Normal file
|
|
@ -0,0 +1,772 @@
|
|||
// Copyright 2026 Lionel DARNIS <lionel@wopr.io>
|
||||
// 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<backend::Device>,
|
||||
selected_device: Option<usize>,
|
||||
name: String,
|
||||
drivers: Vec<backend::Driver>,
|
||||
driver_filter: String,
|
||||
selected_driver: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub enum Dialog {
|
||||
RemovePrinter { name: String },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Page {
|
||||
entity: page::Entity,
|
||||
printers: Vec<backend::Printer>,
|
||||
jobs: Vec<backend::Job>,
|
||||
selected: Option<String>,
|
||||
notice: Option<String>,
|
||||
dialog: Option<Dialog>,
|
||||
add: AddFlow,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Message {
|
||||
Reload,
|
||||
Printers(Result<Vec<backend::Printer>, String>),
|
||||
Jobs(Result<Vec<backend::Job>, String>),
|
||||
Select(Option<String>),
|
||||
SetDefault(String),
|
||||
ToggleEnabled(String, bool),
|
||||
PrintTestPage(String),
|
||||
Remove(String),
|
||||
ConfirmRemove,
|
||||
CancelDialog,
|
||||
CancelJob(i32),
|
||||
OperationDone(Result<(), String>),
|
||||
OpenAdd,
|
||||
Devices(Result<Vec<backend::Device>, String>),
|
||||
Drivers(Result<Vec<backend::Driver>, String>),
|
||||
SelectDevice(usize),
|
||||
NameInput(String),
|
||||
DriverFilter(String),
|
||||
SelectDriver(String),
|
||||
SubmitAdd,
|
||||
AddDone(Result<String, String>),
|
||||
}
|
||||
|
||||
impl From<Message> for crate::pages::Message {
|
||||
fn from(message: Message) -> Self {
|
||||
crate::pages::Message::Printers(message)
|
||||
}
|
||||
}
|
||||
|
||||
impl page::Page<crate::pages::Message> 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<section::Entity, Section<crate::pages::Message>>,
|
||||
) -> Option<page::Content> {
|
||||
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<crate::pages::Message> {
|
||||
self.reload()
|
||||
}
|
||||
|
||||
fn on_context_drawer_close(&mut self) -> Task<crate::pages::Message> {
|
||||
self.add = AddFlow::default();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn dialog(&self) -> Option<Element<'_, crate::pages::Message>> {
|
||||
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<cosmic::app::ContextDrawer<'_, crate::pages::Message>> {
|
||||
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<crate::pages::Message> {
|
||||
cosmic::iced::time::every(REFRESH_EVERY)
|
||||
.map(|_| crate::pages::Message::Printers(Message::Reload))
|
||||
}
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub fn update(&mut self, message: Message) -> cosmic::Task<crate::Message> {
|
||||
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<crate::Message> = 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<crate::Message> = 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<String> {
|
||||
// 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<crate::pages::Message> {
|
||||
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<Output = Result<(), String>> + Send + 'static,
|
||||
) -> cosmic::Task<crate::Message> {
|
||||
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<String>, 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::pages::Message> {
|
||||
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::<Page>(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<String> = 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::pages::Message> {
|
||||
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| page.selected.is_some())
|
||||
.view::<Page>(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<Element<'_, crate::pages::Message>> = 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::pages::Message> {
|
||||
crate::slab!(descriptions {
|
||||
cancel_job = fl!("printers-cancel-job");
|
||||
});
|
||||
|
||||
Section::default()
|
||||
.title(fl!("printers-jobs"))
|
||||
.descriptions(descriptions)
|
||||
.show_while::<Page>(|page| !page.jobs.is_empty())
|
||||
.view::<Page>(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<crate::pages::Message> for Page {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue