From d991bf6792f8ade99c51358bf3c1d902c8cd96b5 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 01:04:26 +0200 Subject: [PATCH 1/5] Report Windows driver duplex capability to print callers --- NOTICE | 3 +++ crates/print/README.md | 3 +++ crates/print/src/lib.rs | 25 +++++++++++++++++-------- crates/print/src/tests.rs | 28 +++++++++++++++++++--------- 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/NOTICE b/NOTICE index c0a1872a..03fcc7fb 100644 --- a/NOTICE +++ b/NOTICE @@ -49,3 +49,6 @@ The PDF spooler in crates/print is extracted from: https://github.com/storytold/pdfcraft Copyright (c) 2026 ArtCraft Team and the PdfCraft contributors. Licensed MIT OR Apache-2.0. PDF composition and application code are not copied. + +The Windows printer duplex-capability query and parser additionally draw on PdfCraft, +crates/print/src/spool.rs, revision fb97ce958dfe92b6762ce40615c7836551710139 (PR #1154), under the same licenses above. diff --git a/crates/print/README.md b/crates/print/README.md index cdec5634..f5a44fa7 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -48,3 +48,6 @@ The cross-platform GitHub Actions workflow is defined in `.github/workflows/prin It runs formatting, Clippy and this crate's own tests on Windows, Linux and macOS when print code changes, and supports manual runs. The Windows job executes the WinRT and spooler regressions above; physical printer fidelity still requires hardware verification. + +Windows discovery reports driver duplex support in `Printer::two_sided`; CUPS leaves it unknown. +Callers can warn when a Windows driver ignores the job’s duplex choice. diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index f8ff9a77..beb5a8bb 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -29,6 +29,8 @@ impl std::error::Error for Error {} pub struct Printer { pub name: String, pub default: bool, + /// Whether the Windows driver reports duplex support; None when unknown (CUPS). + pub two_sided: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] @@ -91,7 +93,7 @@ pub fn parse_lpstat(out: &str) -> Vec { 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) }) + .map(|n| Printer { name: n.to_string(), default: default == Some(n), two_sided: None }) .collect() } @@ -383,7 +385,7 @@ pub fn printers_parsed(queues: &str, available: Option<&str>) -> Vec { 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 }); + printers.push(Printer { default: default == Some(name.as_str()), name, two_sided: None }); } } } @@ -405,22 +407,29 @@ fn powershell_command(script: &str) -> std::process::Command { } /// The Windows printer list: one line per printer, `*` on the system default. UTF-8, through -/// the output encoding the script sets first. Only the two properties the list needs are +/// the output encoding the script sets first. Only the properties the list needs are /// pulled, so a machine with many printers doesn't wait for the rest of each record. #[cfg(windows)] -const POWERSHELL_PRINTERS: &str = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-CimInstance Win32_Printer -Property Name, Default | ForEach-Object { if ($_.Default) { '*' + $_.Name } else { $_.Name } }"; +const POWERSHELL_PRINTERS: &str = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-CimInstance Win32_Printer -Property Name, Default, Capabilities | ForEach-Object { $sides = if (@($_.Capabilities) -contains 3) { '2' } else { '1' }; if ($_.Default) { '*' + $_.Name + [char]9 + $sides } else { $_.Name + [char]9 + $sides } }"; /// The Print dialog's printers from the Windows printer list: one per line, `*` before the -/// system default (a Windows queue name may not contain `*`). The first `*` wins when a broken +/// system default, followed by a tab and 2/1 for duplex support. The first `*` wins when a broken /// spooler marks more than one, so the dialog still has a single default to preselect. pub fn parse_windows_printers(out: &str) -> Vec { let mut printers: Vec = out .lines() .map(|l| l.strip_suffix('\r').unwrap_or(l)) .filter(|l| !l.is_empty()) - .map(|l| match l.strip_prefix('*') { - Some(name) => Printer { name: name.to_string(), default: true }, - None => Printer { name: l.to_string(), default: false }, + .map(|l| { + let (l, two_sided) = match l.rsplit_once('\t') { + Some((name, "2")) => (name, Some(true)), + Some((name, "1")) => (name, Some(false)), + _ => (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 }, + } }) .collect(); let mut seen_default = false; diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 019e5828..8d04f7cd 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -6,7 +6,10 @@ fn spooler_arguments_and_printer_list() { let out = "printer Office_Laser is idle. enabled since Thu Oct 1 09:00:00 2026\nprinter Label_Writer disabled since …\nsystem default destination: Office_Laser\n"; assert_eq!( parse_lpstat(out), - [spool::Printer { name: "Office_Laser".into(), default: true }, spool::Printer { name: "Label_Writer".into(), default: false }] + [ + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, + spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None } + ] ); assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty()); let job = Job { @@ -47,16 +50,19 @@ fn driverless_printers_join_the_list_without_a_queue() { let queues = "printer Office_Laser is idle. enabled since Thu Oct 1 09:00:00 2026\nsystem default destination: Office_Laser\n"; assert_eq!( spool::printers_parsed(queues, Some("Basement_Color\nOffice_Laser\n")), - [spool::Printer { name: "Office_Laser".into(), default: true }, spool::Printer { name: "Basement_Color".into(), default: false },], + [ + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, + spool::Printer { name: "Basement_Color".into(), 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 }]); + assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), 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 }], + [spool::Printer { name: "Basement_Color".into(), 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 [\"\"]"); @@ -148,12 +154,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 }); - assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false }); - assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false }); + 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 }); // 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 }); + assert_eq!(printers[3], Printer { name: "XPS via USB".into(), 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) }); } #[test] @@ -209,7 +219,7 @@ fn windows_powershell(script: &str, envs: &[(&str, &str)]) -> Result Date: Mon, 12 Oct 2026 02:32:49 +0200 Subject: [PATCH 2/5] Add shared printer settings and asynchronous job feedback --- .github/workflows/ui.yml | 50 ++++++++ README.md | 2 +- crates/ui/README.md | 21 ++++ crates/ui/src/lib.rs | 1 + crates/ui/src/printing.rs | 208 ++++++++++++++++++++++++++++++++ crates/ui/src/printing/tests.rs | 111 +++++++++++++++++ 6 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ui.yml create mode 100644 crates/ui/src/printing.rs create mode 100644 crates/ui/src/printing/tests.rs diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml new file mode 100644 index 00000000..89fb8a82 --- /dev/null +++ b/.github/workflows/ui.yml @@ -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 diff --git a/README.md b/README.md index d5b69650..ab70495e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ", + pub grayscale: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { printer: String::new(), copies: 1, duplex: None, grayscale: false } + } +} + +pub type Discover = Box Receiver>>; +pub type Submission = Receiver>; +pub type Submit = Box, 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, + pub message: Option, + discovery: Option>>, + submission: Option, + 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; diff --git a/crates/ui/src/printing/tests.rs b/crates/ui/src/printing/tests.rs new file mode 100644 index 00000000..19674820 --- /dev/null +++ b/crates/ui/src/printing/tests.rs @@ -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); +} From d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:34:53 +0200 Subject: [PATCH 3/5] Preserve accessible print control labels and narrow layout --- crates/ui/src/printing/tests.rs | 53 +++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/printing/tests.rs b/crates/ui/src/printing/tests.rs index 19674820..ef5e670f 100644 --- a/crates/ui/src/printing/tests.rs +++ b/crates/ui/src/printing/tests.rs @@ -99,9 +99,7 @@ fn discovery_and_controls_do_not_submit_a_job() { 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)); - }); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| state.show(ui, egui::Id::new("print"), &discover, &labels)); assert_eq!(state.settings.printer, "Office"); assert!(state.ready()); assert!(!state.busy()); @@ -109,3 +107,52 @@ fn discovery_and_controls_do_not_submit_a_job() { assert_eq!(state.settings.duplex, None); assert!(!state.settings.grayscale); } + +#[test] +fn localized_controls_fit_narrow_ui_and_disable_while_submitting() { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut state = State::default(); + let discover: Discover = Box::new(|| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(vec![queue("Office printer with a very long destination name", 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 frame = |state: &mut State, events| { + ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(220.0, 360.0))), events, ..Default::default() }, + |ui| state.show(ui, egui::Id::new("print"), &discover, &labels), + ) + }; + let output = frame(&mut state, Vec::new()); + let tree = output.platform_output.accesskit_update.unwrap(); + let checkbox = tree.nodes.iter().find(|(_, node)| node.label() == Some("Graustufen")).unwrap().1.clone(); + assert!(!checkbox.is_disabled()); + let bounds = checkbox.bounds().unwrap(); + assert!(bounds.x0 >= 0.0 && bounds.x1 <= 220.0 && bounds.y1 <= 360.0, "control stays reachable in a narrow pane"); + let center = egui::pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let pointer = + |pressed| egui::Event::PointerButton { pos: center, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut state, vec![egui::Event::PointerMoved(center), pointer(true)]); + frame(&mut state, vec![pointer(false)]); + assert!(state.settings.grayscale, "the localized checkbox changes the job setting"); + let (_sender, receiver) = std::sync::mpsc::channel(); + assert!(state.begin(receiver).is_ok()); + let output = frame(&mut state, Vec::new()); + assert!(output.platform_output.accesskit_update.unwrap().nodes.iter().any(|(_, node)| node.label() == Some("Graustufen") && node.is_disabled())); + frame(&mut state, vec![egui::Event::PointerMoved(center), pointer(true)]); + frame(&mut state, vec![pointer(false)]); + assert!(state.settings.grayscale, "pending work disables editing its settings"); +} From 4cae45dbe8eff90c138fd4ffb68b6b2f3b65be39 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:37:47 +0200 Subject: [PATCH 4/5] Handle texture deltas in print control tests --- crates/ui/src/printing/tests.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/printing/tests.rs b/crates/ui/src/printing/tests.rs index ef5e670f..1fb84a8c 100644 --- a/crates/ui/src/printing/tests.rs +++ b/crates/ui/src/printing/tests.rs @@ -99,7 +99,8 @@ fn discovery_and_controls_do_not_submit_a_job() { grayscale: "Graustufen", }; let ctx = egui::Context::default(); - let _ = ctx.run_ui(egui::RawInput::default(), |ui| state.show(ui, egui::Id::new("print"), &discover, &labels)); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| state.show(ui, egui::Id::new("print"), &discover, &labels)); + output.textures_delta.clear(); assert_eq!(state.settings.printer, "Office"); assert!(state.ready()); assert!(!state.busy()); @@ -131,10 +132,12 @@ fn localized_controls_fit_narrow_ui_and_disable_while_submitting() { grayscale: "Graustufen", }; let frame = |state: &mut State, events| { - ctx.run_ui( + let mut output = ctx.run_ui( egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(220.0, 360.0))), events, ..Default::default() }, |ui| state.show(ui, egui::Id::new("print"), &discover, &labels), - ) + ); + output.textures_delta.clear(); + output }; let output = frame(&mut state, Vec::new()); let tree = output.platform_output.accesskit_update.unwrap(); From 5d41e29e4678bb7a99b669d2588e929f4f22b76d Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:41:42 +0200 Subject: [PATCH 5/5] Install software Vulkan for shared UI rendering tests --- .github/workflows/ui.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml index 89fb8a82..9f5e98db 100644 --- a/.github/workflows/ui.yml +++ b/.github/workflows/ui.yml @@ -25,6 +25,12 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + - name: Install software Vulkan renderer + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y mesa-vulkan-drivers libvulkan1 + echo 'WGPU_BACKEND=vulkan' >> "$GITHUB_ENV" - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy