diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml new file mode 100644 index 00000000..9f5e98db --- /dev/null +++ b/.github/workflows/ui.yml @@ -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 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/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 = ", } #[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, + 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..1fb84a8c --- /dev/null +++ b/crates/ui/src/printing/tests.rs @@ -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"); +}