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/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..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"); +}