Add shared printer settings and asynchronous job feedback
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
name: Shared UI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
|
||||
pull_request:
|
||||
paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: shared-ui-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
native:
|
||||
name: UI (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: craft-ui
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy -p craft-ui --all-targets --locked -- -D warnings
|
||||
- run: cargo clippy -p craft-ui --all-targets --all-features --locked -- -D warnings
|
||||
- run: cargo test -p craft-ui --locked
|
||||
- run: cargo test -p craft-ui --all-features --locked
|
||||
wasm:
|
||||
name: UI WASM
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: craft-ui-wasm
|
||||
- run: cargo check -p craft-ui --all-features --locked --target wasm32-unknown-unknown
|
||||
@@ -26,7 +26,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-print`](crates/print/README.md): PDF submission to CUPS and Windows, printer discovery,
|
||||
duplex/copies/colour and driver preferences. PDF generation stays in each app.
|
||||
|
||||
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking and theme helpers for egui frontends;
|
||||
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking, print settings and theme helpers for egui frontends;
|
||||
assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later.
|
||||
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
|
||||
and compatibility boundaries across the Craft suite.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles tab fitting and
|
||||
interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling.
|
||||
`docking` supplies transactional panel moves and an optional docking surface.
|
||||
`printing` supplies printer/job controls and asynchronous feedback used by WordCraft and GridCraft.
|
||||
The current delivery covers nine creative apps (DesignCraft, VectorCraft, PhotoCraft, FilmCraft, EffectCraft, CADCraft, PDFCraft, LightCraft and SoundCraft); prior basic and office adapters are separate. The [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance)
|
||||
records their scope and acceptance limits. Assets, complete themes, localization and command handling
|
||||
remain in each application. No icons, fonts or application engines are bundled.
|
||||
@@ -211,6 +212,26 @@ selection colors, shadows, spacing, context storage and preference serialization
|
||||
application. See the [eight-app comparison](../../docs/theme-infrastructure.md) for the exact
|
||||
common operation and the differences preserved by the adapters.
|
||||
|
||||
## Print settings and feedback
|
||||
|
||||
`printing::State` holds the transient printer selection, copies, duplex and grayscale settings,
|
||||
plus discovery and submission receivers. Hosts supply `Discover` and `Submit` workers; neither
|
||||
the state nor the controls invokes an OS spooler or generates PDF content. Apps map `Settings`
|
||||
to `craft-print::Job` and own the Print action, page selection, preview and export behavior.
|
||||
|
||||
`State::show` takes a stable `egui::Id` and app-localized `Labels`. It draws the settings only;
|
||||
apps render typed `Feedback` using their own translations. `poll` requests occasional repaints
|
||||
while work is pending. Refresh disables submission until the new printer list arrives, preserves
|
||||
a still-present selection, and otherwise chooses the system default or first available printer.
|
||||
An empty or failed discovery cannot leave a stale printer ready to submit.
|
||||
|
||||
`ready` is false while a job is pending. `begin` refuses replacing an active job receiver;
|
||||
dropping a receiver does not cancel a native job. Apps must test readiness before invoking the
|
||||
host submit callback and keep active work alive when closing a window. Queue-default duplex is
|
||||
represented by `None`, independently of explicit one-sided, long-edge or short-edge choices.
|
||||
Receipts and backend errors remain unmodified data for the app to display. These controls work
|
||||
on WASM too; browser PDF printing remains an app-owned service.
|
||||
|
||||
## Compatibility and checks
|
||||
|
||||
The crate uses egui 0.36 and Rust 1.95 or later. Its production dependency disables egui's default
|
||||
|
||||
@@ -9,5 +9,6 @@ pub mod buttons;
|
||||
pub mod docking;
|
||||
pub mod icons;
|
||||
pub mod layout;
|
||||
pub mod printing;
|
||||
pub mod tabs;
|
||||
pub mod theme;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Printer selection and asynchronous job feedback, independent of any spooler or document.
|
||||
//! Hosts provide workers; applications own PDF generation, commands and translated wording.
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Printer {
|
||||
pub name: String,
|
||||
pub default: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Duplex {
|
||||
Off,
|
||||
LongEdge,
|
||||
ShortEdge,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Settings {
|
||||
pub printer: String,
|
||||
pub copies: u32,
|
||||
/// None preserves the printer's default instead of forcing simplex.
|
||||
pub duplex: Option<Duplex>,
|
||||
pub grayscale: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self { printer: String::new(), copies: 1, duplex: None, grayscale: false }
|
||||
}
|
||||
}
|
||||
|
||||
pub type Discover = Box<dyn Fn() -> Receiver<Vec<Printer>>>;
|
||||
pub type Submission = Receiver<Result<String, String>>;
|
||||
pub type Submit = Box<dyn Fn(Vec<u8>, Settings) -> Submission>;
|
||||
|
||||
/// Apps format these states in their own language. Receipts and errors are backend text.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Feedback {
|
||||
FindingPrinters,
|
||||
NoPrinters,
|
||||
DiscoveryFailed,
|
||||
Submitting,
|
||||
Submitted(String),
|
||||
Failed(String),
|
||||
WorkerStopped,
|
||||
}
|
||||
|
||||
/// Translated labels supplied by the app; identity never depends on their text.
|
||||
pub struct Labels<'a> {
|
||||
pub printer: &'a str,
|
||||
pub select_printer: &'a str,
|
||||
pub refresh: &'a str,
|
||||
pub copies: &'a str,
|
||||
pub printer_default: &'a str,
|
||||
pub duplex: &'a str,
|
||||
pub one_sided: &'a str,
|
||||
pub long_edge: &'a str,
|
||||
pub short_edge: &'a str,
|
||||
pub grayscale: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub settings: Settings,
|
||||
pub printers: Vec<Printer>,
|
||||
pub message: Option<Feedback>,
|
||||
discovery: Option<Receiver<Vec<Printer>>>,
|
||||
submission: Option<Submission>,
|
||||
loaded: bool,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn busy(&self) -> bool {
|
||||
self.submission.is_some()
|
||||
}
|
||||
|
||||
pub fn discovering(&self) -> bool {
|
||||
self.discovery.is_some()
|
||||
}
|
||||
|
||||
/// A refresh invalidates the old list until discovery completes: a removed queue must
|
||||
/// not remain selectable while the host is checking it.
|
||||
pub fn ready(&self) -> bool {
|
||||
!self.busy() && !self.discovering() && self.printers.iter().any(|p| p.name == self.settings.printer)
|
||||
}
|
||||
|
||||
/// Refuse replacing an active job's receiver. The caller still owns the refused receiver;
|
||||
/// dropping a receiver cannot cancel a job already handed to a native worker.
|
||||
pub fn begin(&mut self, receiver: Submission) -> Result<(), Submission> {
|
||||
if self.busy() {
|
||||
return Err(receiver);
|
||||
}
|
||||
self.submission = Some(receiver);
|
||||
self.message = Some(Feedback::Submitting);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// False means another discovery or job is active; no worker was started.
|
||||
pub fn refresh(&mut self, discover: &Discover) -> bool {
|
||||
if self.discovering() || self.busy() {
|
||||
return false;
|
||||
}
|
||||
self.discovery = Some(discover());
|
||||
self.loaded = true;
|
||||
self.message = Some(Feedback::FindingPrinters);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, ctx: &egui::Context) {
|
||||
if let Some(receiver) = &self.discovery {
|
||||
match receiver.try_recv() {
|
||||
Ok(printers) => {
|
||||
self.printers = printers;
|
||||
if !self.printers.iter().any(|p| p.name == self.settings.printer) {
|
||||
self.settings.printer =
|
||||
self.printers.iter().find(|p| p.default).or(self.printers.first()).map(|p| p.name.clone()).unwrap_or_default();
|
||||
}
|
||||
self.message = self.printers.is_empty().then_some(Feedback::NoPrinters);
|
||||
self.discovery = None;
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.printers.clear();
|
||||
self.message = Some(Feedback::DiscoveryFailed);
|
||||
self.discovery = None;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
}
|
||||
}
|
||||
if let Some(receiver) = &self.submission {
|
||||
match receiver.try_recv() {
|
||||
Ok(result) => {
|
||||
self.message = Some(match result {
|
||||
Ok(receipt) => Feedback::Submitted(receipt),
|
||||
Err(error) => Feedback::Failed(error),
|
||||
});
|
||||
self.submission = None;
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.message = Some(Feedback::WorkerStopped);
|
||||
self.submission = None;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
}
|
||||
}
|
||||
if self.discovering() || self.busy() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw just the controls. Apps render feedback, their preview and the submit/export
|
||||
/// buttons around this component, using their own command and document state.
|
||||
pub fn show(&mut self, ui: &mut egui::Ui, id: egui::Id, discover: &Discover, labels: &Labels<'_>) {
|
||||
if !self.loaded {
|
||||
self.refresh(discover);
|
||||
}
|
||||
self.poll(ui.ctx());
|
||||
ui.push_id(id, |ui| {
|
||||
ui.add_enabled_ui(!self.busy(), |ui| {
|
||||
let width = ui.available_width().clamp(32.0, 230.0);
|
||||
let printer_label = ui.label(labels.printer);
|
||||
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 })
|
||||
.show_ui(ui, |ui| {
|
||||
for printer in &self.printers {
|
||||
ui.selectable_value(&mut self.settings.printer, printer.name.clone(), &printer.name);
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(printer_label.id);
|
||||
if ui.add_enabled(!self.discovering(), egui::Button::new(labels.refresh)).clicked() {
|
||||
self.refresh(discover);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(labels.copies);
|
||||
ui.add(egui::DragValue::new(&mut self.settings.copies).range(1..=999)).labelled_by(label.id);
|
||||
});
|
||||
let choices = [
|
||||
(None, labels.printer_default),
|
||||
(Some(Duplex::Off), labels.one_sided),
|
||||
(Some(Duplex::LongEdge), labels.long_edge),
|
||||
(Some(Duplex::ShortEdge), labels.short_edge),
|
||||
];
|
||||
let duplex_label = ui.label(labels.duplex);
|
||||
egui::ComboBox::from_id_salt("duplex")
|
||||
.width(width)
|
||||
.wrap_mode(egui::TextWrapMode::Truncate)
|
||||
.selected_text(
|
||||
choices.iter().find(|(value, _)| *value == self.settings.duplex).map(|(_, text)| *text).unwrap_or(labels.printer_default),
|
||||
)
|
||||
.show_ui(ui, |ui| {
|
||||
for (value, text) in choices {
|
||||
ui.selectable_value(&mut self.settings.duplex, value, text);
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(duplex_label.id);
|
||||
ui.checkbox(&mut self.settings.grayscale, labels.grayscale);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,111 @@
|
||||
use super::*;
|
||||
|
||||
fn queue(name: &str, default: bool) -> Printer {
|
||||
Printer { name: name.into(), default }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_preserves_choice_and_blocks_stale_or_removed_queues() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut state = State::default();
|
||||
state.settings.printer = "Chosen".into();
|
||||
let discover: Discover = Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
sender.send(vec![queue("Default", true), queue("Chosen", false)]).unwrap();
|
||||
receiver
|
||||
});
|
||||
assert!(state.refresh(&discover));
|
||||
assert!(!state.ready());
|
||||
state.poll(&ctx);
|
||||
assert_eq!(state.settings.printer, "Chosen");
|
||||
assert!(state.ready());
|
||||
let empty: Discover = Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
sender.send(Vec::new()).unwrap();
|
||||
receiver
|
||||
});
|
||||
assert!(state.refresh(&empty));
|
||||
assert!(!state.ready());
|
||||
state.poll(&ctx);
|
||||
assert!(!state.ready());
|
||||
assert_eq!(state.message, Some(Feedback::NoPrinters));
|
||||
assert!(state.settings.printer.is_empty());
|
||||
assert!(state.refresh(&discover));
|
||||
state.poll(&ctx);
|
||||
assert_eq!(state.settings.printer, "Default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_discovery_cannot_reuse_a_previously_available_queue() {
|
||||
let mut state = State::default();
|
||||
state.printers.push(queue("Old", true));
|
||||
state.settings.printer = "Old".into();
|
||||
let discover: Discover = Box::new(|| std::sync::mpsc::channel().1);
|
||||
assert!(state.refresh(&discover));
|
||||
state.poll(&egui::Context::default());
|
||||
assert_eq!(state.message, Some(Feedback::DiscoveryFailed));
|
||||
assert!(!state.ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_jobs_keep_their_receiver_and_report_receipt_refusal_or_worker_failure() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut state = State::default();
|
||||
state.printers.push(queue("Queue", true));
|
||||
state.settings.printer = "Queue".into();
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
assert!(state.begin(receiver).is_ok());
|
||||
assert!(!state.ready());
|
||||
let (other_sender, other_receiver) = std::sync::mpsc::channel();
|
||||
assert!(state.begin(other_receiver).is_err());
|
||||
drop(other_sender);
|
||||
state.poll(&ctx);
|
||||
assert!(state.busy());
|
||||
sender.send(Ok("Queue-42".into())).unwrap();
|
||||
state.poll(&ctx);
|
||||
assert_eq!(state.message, Some(Feedback::Submitted("Queue-42".into())));
|
||||
assert!(state.ready());
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
assert!(state.begin(receiver).is_ok());
|
||||
sender.send(Err("queue refused the job".into())).unwrap();
|
||||
state.poll(&ctx);
|
||||
assert_eq!(state.message, Some(Feedback::Failed("queue refused the job".into())));
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
assert!(state.begin(receiver).is_ok());
|
||||
drop(sender);
|
||||
state.poll(&ctx);
|
||||
assert_eq!(state.message, Some(Feedback::WorkerStopped));
|
||||
assert!(state.ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_and_controls_do_not_submit_a_job() {
|
||||
let mut state = State::default();
|
||||
let discover: Discover = Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
sender.send(vec![queue("Office", true)]).unwrap();
|
||||
receiver
|
||||
});
|
||||
let labels = Labels {
|
||||
printer: "Drucker",
|
||||
select_printer: "Wählen",
|
||||
refresh: "Aktualisieren",
|
||||
copies: "Kopien",
|
||||
printer_default: "Standard",
|
||||
duplex: "Seiten",
|
||||
one_sided: "Einseitig",
|
||||
long_edge: "Lange Kante",
|
||||
short_edge: "Kurze Kante",
|
||||
grayscale: "Graustufen",
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
let _ = ctx.run(egui::RawInput::default(), |ctx| {
|
||||
egui::CentralPanel::default().show(ctx, |ui| state.show(ui, egui::Id::new("print"), &discover, &labels));
|
||||
});
|
||||
assert_eq!(state.settings.printer, "Office");
|
||||
assert!(state.ready());
|
||||
assert!(!state.busy());
|
||||
assert_eq!(state.settings.copies, 1);
|
||||
assert_eq!(state.settings.duplex, None);
|
||||
assert!(!state.settings.grayscale);
|
||||
}
|
||||
Reference in New Issue
Block a user