Merge remote-tracking branch 'origin/main' into fix-quit5-20261012

This commit is contained in:
skylar taylor-barrick
2026-10-12 20:15:22 +02:00
10 changed files with 490 additions and 18 deletions
+56
View File
@@ -0,0 +1,56 @@
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
- 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
- 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
+3
View File
@@ -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.
+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.
+3
View File
@@ -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.
+17 -8
View File
@@ -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<bool>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -91,7 +93,7 @@ pub fn parse_lpstat(out: &str) -> Vec<Printer> {
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<Printer> {
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<Printer> {
let mut printers: Vec<Printer> = 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;
+19 -9
View File
@@ -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<String, Str
#[test]
fn windows_lists_the_printers_the_system_knows() {
let printers = spool::printers();
assert!(printers.iter().all(|p| !p.name.trim().is_empty()), "{printers:?}");
assert!(printers.iter().all(|p| !p.name.trim().is_empty() && p.two_sided.is_some()), "{printers:?}");
assert!(printers.iter().filter(|p| p.default).count() <= 1, "{printers:?}");
}
+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;
+161
View File
@@ -0,0 +1,161 @@
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 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());
assert_eq!(state.settings.copies, 1);
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| {
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();
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");
}