Add shared printer settings and asynchronous job feedback

This commit is contained in:
skylar taylor-barrick
2026-10-12 02:32:49 +02:00
parent 1aa81a773a
commit b700ae753f
6 changed files with 392 additions and 1 deletions
+50
View File
@@ -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
+1 -1
View File
@@ -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.
+21
View File
@@ -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
+1
View File
@@ -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;
+208
View File
@@ -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;
+111
View File
@@ -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);
}