diff --git a/crates/print/README.md b/crates/print/README.md index 00b12689..22d39e43 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -81,10 +81,21 @@ their own fidelity checks. Native Windows PDF submission checks the driver's validated copy count and explicit simplex/duplex and grayscale requests before sending. Collation must be retained when both -the page count and copy count exceed one. A driver that drops or changes these options is -refused; select supported options or flatten grayscale into the print-ready page content. -Without an explicit duplex request, the queue default applies. Normal color output retains -the driver's color policy. Single-page or single-copy jobs have no collating order to check. +the page count and copy count exceed one. A driver that offers one of these options and drops +or changes it is refused; select supported options or flatten grayscale into the print-ready +page content. A driver without the feature, per its PrintCapabilities, is accepted when it can +only do what was asked: one-sided on a queue with no two-sided feature, grayscale on one with no +colour option, uncollated copies on one that can't collate. Two-sided on such a queue is +refused. Windows doesn't document collating for a driver that can't collate, so collated copies +on one are a single job of one copy that holds the document's pages once per copy; on two-sided +paper an odd page count gets a blank page after each copy but the last, so every copy starts on +a sheet of its own. Where one job would print the copies wrongly or can't hold them (pages with +annotations, an encrypted PDF, a very large job, a queue set to several pages per sheet, binding, +stapling or a two-sided option outside the Print Schema's), each copy is a job of its own. +Without an explicit duplex request, the queue default applies. Without a grayscale request the +ticket names no colour, so the colour in the user's printing preferences (Properties…, or +Windows Settings) applies, black and white included. Single-page or single-copy jobs have no +collating order to check. `cargo test -p craft-print` tests CUPS transport (including pipe-sized jobs and refusal), printer parsing, PPD options and argument mapping, and on every platform the Windows pieces that diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 3c26974a..ecf97508 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -483,7 +483,9 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result { /// [`submit`] with Windows sheet-processing progress: `(processed, sheets)` before the first /// sheet and after each sheet is successfully added to the native print job. Counts refer to -/// source PDF sheets, regardless of copies or duplex. CUPS reports no progress. +/// source PDF sheets, regardless of copies or duplex, except for collated copies on a driver +/// that can't collate: every copy's sheets are sent, in one job or a job per copy, and counted +/// together. CUPS reports no progress. /// /// The callback runs synchronously on the thread calling this function, need not be `Send`, /// and should return promptly. It is never retained. All sheets processed is not terminal @@ -590,7 +592,7 @@ fn windows_preview(job: &Job) -> Vec { Scaling::ShrinkToFit => "shrink-to-fit", } ), - format!("color={}", if job.grayscale { "monochrome" } else { "color" }), + format!("color={}", if job.grayscale { "monochrome" } else { "(driver default)" }), ] } diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index bbb4c7c7..6e588f61 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -31,6 +31,8 @@ const MAX_TOKENS: usize = 16 << 20; const MAX_OBJECT_NODES: usize = 32_000; const MAX_OBJECT_TOKENS: usize = 1 << 20; const MAX_SECTIONS: usize = 256; +/// The most the page objects of repeated copies ([`Repeat`]) may take, in bytes. +const MAX_REPEATED: usize = 64 << 20; /// A page without a MediaBox (invalid, but readers accept it) is US Letter. const DEFAULT_MEDIA: [f64; 4] = [0.0, 0.0, 612.0, 792.0]; /// Sheet sizes this close, in points (0.18 mm), are the same size. @@ -814,12 +816,18 @@ pub(crate) struct Sheet { /// The page object (number, generation); `None` for a page written inside its parent. obj: Option<(u32, u16)>, dict: Dict, + /// The MediaBox, inherited or not. + media: [f64; 4], /// The visible box (CropBox within MediaBox), in the page's own unrotated space. pub(crate) bounds: [f64; 4], + /// `/Resources` inherited from the page tree; `None` when the page has its own or none. + resources: Option>, /// `/Rotate`, 0, 90, 180 or 270. pub(crate) rotate: u32, /// Annotation flags match the renderer's visible printing behavior. print_annotations_safe: bool, + /// `/Annots` lists at least one annotation. + annotated: bool, } impl Sheet { @@ -835,6 +843,7 @@ pub(crate) struct Sheets<'a> { data: &'a [u8], pub(crate) sheets: Vec, trailer: Dict, + catalog: Dict, /// The newest cross-reference section is a stream (so is the update's). xref_stream: bool, startxref: usize, @@ -848,6 +857,7 @@ struct Inherited { media: Option>, crop: Option>, rotate: Option>, + resources: Option>, } /// Read the sheets of a print-ready PDF. @@ -905,10 +915,14 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { _ => None, }; let mut dict = reader.resolve(&node).and_then(|d| d.dict().cloned()).ok_or_else(|| unreadable("a page is missing"))?; + // A null value is the same as no entry (ISO 32000-1, 7.3.9), so it inherits. + let own = |key: &[u8]| get(&dict, key).filter(|v| **v != Obj::Null).cloned().map(Arc::new); + let parent_resources = inherited.resources.clone(); let here = Inherited { - media: get(&dict, b"MediaBox").cloned().map(Arc::new).or(inherited.media), - crop: get(&dict, b"CropBox").cloned().map(Arc::new).or(inherited.crop), - rotate: get(&dict, b"Rotate").cloned().map(Arc::new).or(inherited.rotate), + media: own(b"MediaBox").or(inherited.media), + crop: own(b"CropBox").or(inherited.crop), + rotate: own(b"Rotate").or(inherited.rotate), + resources: own(b"Resources").or(inherited.resources), }; let kids = get(&dict, b"Kids").cloned().and_then(|k| reader.resolve(&k)); let is_node = get(&dict, b"Type").and_then(Obj::name) == Some(b"Pages") || (get(&dict, b"Type").is_none() && kids.is_some()); @@ -955,20 +969,31 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { let rotate = if rotate % 90 == 0 { u32::try_from(rotate).unwrap_or(0) } else { 0 }; // Windows renders viewer visibility, not annotation print visibility. Only a plain // visible printable annotation is safe; flatten other flags before native submission. - let print_annotations_safe = match get(&dict, b"Annots") { - None | Some(Obj::Null) => true, + let (print_annotations_safe, annotated) = match get(&dict, b"Annots") { + None | Some(Obj::Null) => (true, false), Some(annotations) => match reader.resolve(annotations) { - Some(Obj::Null) => true, - Some(Obj::Array(items)) => items.iter().all(|item| { - reader - .resolve(item) - .and_then(|a| a.dict().cloned()) - .is_some_and(|annotation| get(&annotation, b"F").and_then(|f| reader.resolve(f)).and_then(|f| f.int()) == Some(4)) - }), - _ => false, + Some(Obj::Null) => (true, false), + Some(Obj::Array(items)) => ( + items.iter().all(|item| { + reader + .resolve(item) + .and_then(|a| a.dict().cloned()) + .is_some_and(|annotation| get(&annotation, b"F").and_then(|f| reader.resolve(f)).and_then(|f| f.int()) == Some(4)) + }), + !items.is_empty(), + ), + _ => (false, true), }, }; - sheets.push(Sheet { obj, dict, bounds, rotate, print_annotations_safe }); + // A reference to a missing object is null too. Only the cross-reference table is + // looked at: resolving a shared Resources dictionary for every page would parse it again. + let own_resources = match get(&dict, b"Resources") { + None | Some(Obj::Null) => false, + Some(Obj::Ref(num, _)) => matches!(reader.entries.get(num), Some(Entry::At(_) | Entry::In(..))), + Some(_) => true, + }; + let resources = if own_resources { None } else { parent_resources }; + sheets.push(Sheet { obj, dict, media, bounds, resources, rotate, print_annotations_safe, annotated }); } if reader.budget.exhausted { return Err(unreadable("the parser memory budget was exceeded")); @@ -976,7 +1001,7 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { if sheets.is_empty() { return Err(Error::Spool("the print-ready PDF has no pages".into())); } - Ok(Sheets { data, sheets, trailer, xref_stream, startxref: start, size }) + Ok(Sheets { data, sheets, trailer, catalog, xref_stream, startxref: start, size }) } /// How sheets of another size than the paper are printed. @@ -1067,6 +1092,30 @@ pub(crate) fn plan(sheets: &[Sheet], paper: (f64, f64), fit: Fit) -> Result bool { + get(dict, b"Annots").is_some_and(|a| !matches!(a, Obj::Null) && !matches!(a, Obj::Array(items) if items.is_empty())) +} + +fn rect_obj(r: [f64; 4]) -> Obj { + Obj::Array(r.into_iter().map(num_obj).collect()) +} + +/// Starts object `num` in an update, recording where it is. +fn new_object(out: &mut Vec, num: u32, generation: u16, written: &mut Vec<(u32, u16, usize)>) { + written.push((num, generation, out.len())); + out.extend_from_slice(format!("{num} {generation} obj\n").as_bytes()); +} + /// Boxes a reframed page drops: they were in the old geometry. const OLD_GEOMETRY: [&[u8]; 6] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"ArtBox", b"Rotate"]; @@ -1084,7 +1133,25 @@ impl Sheets<'_> { /// original bytes, then the new versions of the edited pages, the content streams a /// reframed page wraps its own in, and a cross-reference section of the same kind as the /// newest one, chained to it with `/Prev`. + /// pub(crate) fn update(&self, edits: &[Edit]) -> Result, Error> { + // Only a repeat can be turned down, so there is always a PDF. + Ok(self.write(edits, None)?.unwrap_or_else(|| self.data.to_vec())) + } + + /// [`update`](Self::update) with every copy of `repeat` in the one PDF. The edited pages + /// are not written in place: the update adds a page tree of its own, every copy's pages as + /// new page objects that share the original content and resources, and a new version of + /// the catalog pointing to it. Each new page carries the boxes, rotation and resources it + /// inherited, since its parent is the new tree. `None` when the copies can't be one PDF: + /// the PDF is encrypted (strings are encrypted with keys made from their object's number), + /// a page has annotations (an annotation belongs to one page; whether a renderer draws it on + /// others is unknown), or the job would be too large. + pub(crate) fn repeated(&self, edits: &[Edit], repeat: Repeat) -> Result>, Error> { + self.write(edits, Some(repeat)) + } + + fn write(&self, edits: &[Edit], repeat: Option) -> Result>, Error> { let refuse = |why: &str| Error::Spool(format!("the print-ready PDF can't be fitted to the paper: {why}")); let mut out = self.data.to_vec(); if !out.ends_with(b"\n") { @@ -1093,13 +1160,10 @@ impl Sheets<'_> { let mut next = self.size; // Each new object's number, generation and offset. let mut written: Vec<(u32, u16, usize)> = Vec::new(); - fn new_object(out: &mut Vec, num: u32, generation: u16, written: &mut Vec<(u32, u16, usize)>) { - written.push((num, generation, out.len())); - out.extend_from_slice(format!("{num} {generation} obj\n").as_bytes()); - } + let repeat = repeat.filter(|r| r.copies > 1); + let mut edited = HashMap::new(); for edit in edits { let sheet = self.sheets.get(edit.sheet).ok_or_else(|| refuse("no such sheet"))?; - let (num, generation) = sheet.obj.ok_or_else(|| refuse("a page is not an object of its own"))?; let mut dict = sheet.dict.clone(); dict.retain(|(k, _)| k != b"Rotate"); if let Some(frame) = &edit.frame { @@ -1109,10 +1173,7 @@ impl Sheets<'_> { // Annotation appearances live outside /Contents. Transforming only content // would leave them at the old coordinates; deleting them loses printed marks. // Require callers to flatten before fitting until appearances can be reframed. - if let Some(annotations) = get(&dict, b"Annots") - && !matches!(annotations, Obj::Null) - && !matches!(annotations, Obj::Array(items) if items.is_empty()) - { + if has_annotations(&dict) { return Err(refuse("flatten page annotations before using shrink-to-fit")); } let [a, b, c, d, e, f] = frame.matrix; @@ -1155,12 +1216,126 @@ impl Sheets<'_> { dict.push((b"Contents".to_vec(), Obj::Array(contents))); } dict.push((b"Rotate".to_vec(), Obj::Num(edit.rotate.to_string().into_bytes()))); - new_object(&mut out, num, generation, &mut written); - write_dict(&mut out, &dict); + if repeat.is_some() { + edited.insert(edit.sheet, dict); + } else { + let (num, generation) = sheet.obj.ok_or_else(|| refuse("a page is not an object of its own"))?; + new_object(&mut out, num, generation, &mut written); + write_dict(&mut out, &dict); + out.extend_from_slice(b"\nendobj\n"); + } + } + if let Some(Repeat { copies, two_sided }) = repeat { + let refuse = |why: &str| Error::Spool(format!("the copies can't be written: {why}")); + if get(&self.trailer, b"Encrypt").is_some() || self.sheets.iter().any(|s| s.annotated) { + return Ok(None); + } + let Some(&Obj::Ref(root, root_generation)) = get(&self.trailer, b"Root") else { + return Ok(None); + }; + let blank = two_sided && self.sheets.len() % 2 == 1; + let per_copy = self.sheets.len() + usize::from(blank); + let count = + usize::try_from(copies).ok().and_then(|c| per_copy.checked_mul(c)).map(|n| n - usize::from(blank)).filter(|&n| n <= MAX_PAGES); + let Some(count) = count else { return Ok(None) }; + let tree = next; + next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?; + let parent = (b"Parent".to_vec(), Obj::Ref(tree, 0)); + // Bytes written once, and bytes written once per copy (a page, its xref entry and kid). + let (mut once, mut each) = (0usize, 0usize); + let within = + |once: usize, each: usize| each.checked_mul(copies as usize).and_then(|n| n.checked_add(once)).is_some_and(|n| n <= MAX_REPEATED); + // Inherited inline resources are written once, as an object of their own, and every + // page that inherited them refers to it. + let mut shared: HashMap<*const Obj, Obj> = HashMap::new(); + let mut bodies: Vec> = Vec::with_capacity(per_copy); + let mut last = Dict::new(); + for (i, sheet) in self.sheets.iter().enumerate() { + let mut dict = edited.remove(&i).unwrap_or_else(|| sheet.dict.clone()); + dict.retain(|(k, _)| k != b"Parent"); + // The boxes as read, not the inherited objects: those can be anything. + for (key, value) in [(&b"MediaBox"[..], rect_obj(sheet.media)), (b"CropBox", rect_obj(sheet.bounds))] { + if get(&dict, key).is_none_or(|v| *v == Obj::Null) { + dict.retain(|(k, _)| k != key); + dict.push((key.to_vec(), value)); + } + } + if get(&dict, b"Rotate").is_none_or(|v| *v == Obj::Null) { + dict.retain(|(k, _)| k != b"Rotate"); + dict.push((b"Rotate".to_vec(), Obj::Num(sheet.rotate.to_string().into_bytes()))); + } + if let Some(resources) = &sheet.resources { + let value = match &**resources { + Obj::Ref(..) => Obj::clone(resources), + _ => match shared.get(&Arc::as_ptr(resources)) { + Some(r) => r.clone(), + None => { + let num = next; + next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?; + let at = out.len(); + new_object(&mut out, num, 0, &mut written); + write_obj(&mut out, resources); + out.extend_from_slice(b"\nendobj\n"); + once += out.len() - at; + if !within(once, each) { + return Ok(None); + } + shared.insert(Arc::as_ptr(resources), Obj::Ref(num, 0)); + Obj::Ref(num, 0) + } + }, + }; + dict.retain(|(k, _)| k != b"Resources"); + dict.push((b"Resources".to_vec(), value)); + } + dict.push(parent.clone()); + let mut body = Vec::new(); + write_dict(&mut body, &dict); + each += body.len() + 64; + if !within(once, each) { + return Ok(None); + } + bodies.push(body); + last = dict; + } + if blank { + let mut page = vec![(b"Type".to_vec(), Obj::Name(b"Page".to_vec())), parent.clone(), (b"Resources".to_vec(), Obj::Dict(Vec::new()))]; + for key in [&b"MediaBox"[..], b"CropBox", b"Rotate"] { + if let Some(value) = get(&last, key) { + page.push((key.to_vec(), value.clone())); + } + } + let mut body = Vec::new(); + write_dict(&mut body, &page); + bodies.push(body); + } + let mut kids = Vec::with_capacity(count); + for body in bodies.iter().cycle().take(count) { + let num = next; + next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?; + new_object(&mut out, num, 0, &mut written); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + kids.push(Obj::Ref(num, 0)); + } + new_object(&mut out, tree, 0, &mut written); + let node = vec![ + (b"Type".to_vec(), Obj::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Obj::Array(kids)), + (b"Count".to_vec(), Obj::Num(count.to_string().into_bytes())), + ]; + write_dict(&mut out, &node); + out.extend_from_slice(b"\nendobj\n"); + let mut catalog = self.catalog.clone(); + // Page labels and the open action name pages of the old tree. + catalog.retain(|(k, _)| k != b"Pages" && k != b"PageLabels" && k != b"OpenAction"); + catalog.push((b"Pages".to_vec(), Obj::Ref(tree, 0))); + new_object(&mut out, root, root_generation, &mut written); + write_dict(&mut out, &catalog); out.extend_from_slice(b"\nendobj\n"); } if written.is_empty() { - return Ok(self.data.to_vec()); + return Ok(Some(self.data.to_vec())); } written.sort_by_key(|(num, _, _)| *num); let mut trailer: Dict = Vec::new(); @@ -1207,7 +1382,7 @@ impl Sheets<'_> { write_dict(&mut out, &trailer); out.extend_from_slice(format!("\nstartxref\n{at}\n%%EOF\n").as_bytes()); } - Ok(out) + Ok(Some(out)) } } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index db03fb8a..e051cda6 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -309,7 +309,7 @@ fn command_previews_do_not_require_pdf_files() { } else { assert_eq!( preview, - ["xps-print", "printer=Office (east)", "copies=2", "collate=true", "duplex=(queue default)", "scaling=exact", "color=color"] + ["xps-print", "printer=Office (east)", "copies=2", "collate=true", "duplex=(queue default)", "scaling=exact", "color=(driver default)"] ); } } diff --git a/crates/print/src/tests/pdf_bounds.rs b/crates/print/src/tests/pdf_bounds.rs index 76b1409a..e33922bf 100644 --- a/crates/print/src/tests/pdf_bounds.rs +++ b/crates/print/src/tests/pdf_bounds.rs @@ -283,3 +283,138 @@ fn an_oversized_inherited_media_box_is_not_copied_for_every_page() { assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); assert_eq!(sheets.sheets.len(), pages); } + +/// A classic-xref PDF of these objects, numbered from 1. +fn classic(objects: &[String]) -> Vec { + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + pdf +} + +/// Three Letter pages that inherit their MediaBox and Resources; the third is turned by its own +/// `/Rotate 90`, and carries `extra` in its dictionary. +fn three_pages(extra: &str) -> Vec { + classic(&three_page_objects(extra)) +} + +fn three_page_objects(extra: &str) -> Vec { + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R /PageMode /UseNone >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << /ProcSet [/PDF] >> >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /Contents 8 0 R /Rotate 90 {extra} >>"), + ]; + objects.extend((0..3).map(|_| "<< /Length 0 >>\nstream\n\nendstream".to_string())); + objects +} + +/// `pdf` with `copies` collated copies in one job, or `None` when they can't be. +fn repeat(pdf: &[u8], copies: u32, two_sided: bool) -> Option> { + read(pdf).unwrap().repeated(&[], Repeat { copies, two_sided }).unwrap() +} + +#[test] +fn collated_copies_repeat_the_pages_with_a_blank_back_between_odd_copies() { + let pdf = three_pages(""); + let sheets = read(&pdf).unwrap(); + // The turned page comes back upright in a portrait job, in every copy. + let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap(); + let content = |n: u32| Some(Obj::Ref(n, 0)); + for (two_sided, expected) in [ + (true, vec![content(6), content(7), content(8), None, content(6), content(7), content(8)]), + (false, vec![content(6), content(7), content(8), content(6), content(7), content(8)]), + ] { + let updated = sheets.repeated(&edits, Repeat { copies: 2, two_sided }).unwrap().unwrap(); + let repeated = read(&updated).unwrap(); + let contents: Vec> = repeated.sheets.iter().map(|s| get(&s.dict, b"Contents").cloned()).collect(); + assert_eq!(contents, expected); + for sheet in &repeated.sheets { + assert_eq!((sheet.size(), sheet.rotate % 180), ((612.0, 792.0), 0), "{sheet:?}"); + // What the old parent gave each page is the page's own now. + assert!(get(&sheet.dict, b"Resources").is_some()); + assert!(sheet.obj.is_some_and(|(num, _)| num >= sheets.size), "every copy is a new page object"); + } + assert_eq!(get(&repeated.catalog, b"PageMode"), get(&sheets.catalog, b"PageMode"), "the rest of the catalog is kept"); + // The inherited resources are written once and shared. + let resources: Vec<_> = repeated.sheets.iter().filter(|s| get(&s.dict, b"Contents").is_some()).map(|s| get(&s.dict, b"Resources")).collect(); + assert!(matches!(resources[0], Some(Obj::Ref(..))) && resources.iter().all(|r| *r == resources[0]), "{resources:?}"); + } + // Without the edits the last page stays turned, and so does the blank back after it. + let bytes = repeat(&pdf, 2, true).unwrap(); + let repeated = read(&bytes).unwrap(); + assert_eq!((repeated.sheets[3].size(), repeated.sheets[3].rotate), (repeated.sheets[2].size(), 90)); + // An even page count needs no blank; one copy is the document as it is. + let even = classic(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + assert_eq!(read(&repeat(&even, 3, true).unwrap()).unwrap().sheets.len(), 6); + assert_eq!(repeat(&pdf, 1, true).unwrap(), pdf); + // A cross-reference stream, with the catalog and the page in an object stream. + for compressed in [false, true] { + let bytes = repeat(&fixture("3 0 R", compressed, ""), 3, false).unwrap(); + let repeated = read(&bytes).unwrap(); + assert_eq!(repeated.sheets.len(), 3); + assert_eq!(repeated.xref_stream, compressed); + } +} + +#[test] +fn collated_copies_that_cannot_be_one_job_are_left_to_separate_jobs() { + assert!(repeat(&three_pages("/Annots [6 0 R]"), 2, false).is_none(), "annotations"); + let pdf = three_pages(""); + assert!(repeat(&pdf, 50_000, false).is_none(), "too many pages"); + let encrypted = Sheets { trailer: vec![(b"Encrypt".to_vec(), Obj::Ref(9, 0))], ..read(&pdf).unwrap() }; + assert!(encrypted.repeated(&[], Repeat { copies: 2, two_sided: false }).unwrap().is_none(), "encrypted"); +} + +#[test] +fn a_null_missing_or_free_entry_inherits_and_an_empty_annotation_list_is_none() { + // Object 9 is written, but its cross-reference entry is free. + let mut objects = three_page_objects("/Resources 9 0 R"); + objects.push("<< /ProcSet [/Text] >>".into()); + let mut free = classic(&objects); + let at = free.windows(9).rposition(|w| w == b"00000 n\r\n").unwrap(); + free[at + 6] = b'f'; + for (extra, pdf) in [ + ("/Resources null", three_pages("/Resources null")), + ("/Resources 99 0 R", three_pages("/Resources 99 0 R")), + ("/Resources 9 0 R (free)", free), + ("/Annots null", three_pages("/Annots null")), + ("/Annots []", three_pages("/Annots []")), + ] { + let bytes = repeat(&pdf, 2, false).unwrap(); + let repeated = read(&bytes).unwrap(); + let resources: Vec<_> = repeated.sheets.iter().map(|s| get(&s.dict, b"Resources")).collect(); + assert!(matches!(resources[0], Some(Obj::Ref(..))) && resources.iter().all(|r| *r == resources[0]), "{extra}: {resources:?}"); + } +} + +#[test] +fn large_inherited_resources_are_written_once_for_every_copy() { + let pages = 4_000; + let procset = vec!["/PDF"; 25_000].join(" "); + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", i + 3)).collect(); + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 612 792] /Resources << /ProcSet [{procset}] >> >>", kids.join(" ")), + ]; + objects.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R >>".to_string())); + let pdf = classic(&objects); + let updated = repeat(&pdf, 3, false).unwrap(); + assert!(updated.len() < pdf.len() + (4 << 20), "{} bytes", updated.len()); + assert_eq!(read(&updated).unwrap().sheets.len(), 3 * pages); +} diff --git a/crates/print/src/tests/windows_native.rs b/crates/print/src/tests/windows_native.rs index c42bccc9..f25f0cfb 100644 --- a/crates/print/src/tests/windows_native.rs +++ b/crates/print/src/tests/windows_native.rs @@ -429,6 +429,9 @@ fn windows_jobs_reach_the_spooler() { } else { None }; + // The Print dialog's default, an explicit one-sided request, on a queue with no duplex unit. + let simplex_only = listed.as_ref().is_some_and(|p| p.two_sided == Some(false)); + let one_sided = simplex_only.then(|| spool::submit(&mixed, &Job { duplex: Some(Duplex::Off), title: "simplex".into(), ..job.clone() })); let tabloid = test_pdf(&[TABLOID, LETTER], false); let shrink = Job { printer: Some(name.into()), scaling: Scaling::ShrinkToFit, title: "shrink".into(), ..Job::default() }; let shrunk = spool::submit(&tabloid, &shrink); @@ -451,12 +454,16 @@ fn windows_jobs_reach_the_spooler() { if let Some(refused) = duplex_refused { assert!(matches!(refused, Err(Error::Spool(ref m)) if m.contains("simplex/duplex") && m.contains("nothing was sent")), "{refused:?}"); } + if let Some(one_sided) = one_sided { + assert_eq!(one_sided.as_deref(), Ok("spooled 3 sheet(s)"), "one-sided is what a simplex-only queue does: {one_sided:?}"); + } assert_eq!(*processed.borrow(), [(0, 3), (1, 3), (2, 3), (3, 3)]); assert!(duplex_progress.is_empty(), "driver refusal before submission has no progress"); assert_eq!(shrunk.as_deref(), Ok("spooled 2 sheet(s)"), "{shrunk:?}"); assert!(matches!(exact_refused, Err(Error::Spool(ref m)) if m.contains("one paper size")), "{exact_refused:?}"); assert!(refused_progress.is_empty(), "rejection before a job starts has no progress: {refused_progress:?}"); - assert_eq!(queued.as_deref().map(str::trim), Ok("mixed.pdf 3|shrink.pdf 2"), "one job each, all sheets, in the paused queue: {queued:?}"); + let expected = if simplex_only { "mixed.pdf 3|simplex.pdf 3|shrink.pdf 2" } else { "mixed.pdf 3|shrink.pdf 2" }; + assert_eq!(queued.as_deref().map(str::trim), Ok(expected), "one job each, all sheets, in the paused queue: {queued:?}"); assert!(cleaned.is_ok(), "the test printer is removed again: {cleaned:?}"); // Asking for a printer that isn't there is an error, never a job on another one. let missing = spool::submit(&mixed, &Job { printer: Some(name.into()), ..Job::default() }); diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 5f8c914b..ade33dc8 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -43,14 +43,15 @@ pub(crate) fn ticket_xml(job: &Job, landscape: bool) -> String { } }; let collate = if job.collate { "Collated" } else { "Uncollated" }; - let color = if job.grayscale { "Monochrome" } else { "Color" }; + // Only grayscale is asked for. Without it the ticket says nothing about colour, so a black and + // white chosen in the driver's own preferences (Properties…) still applies (storytold/pdfcraft#2422). + let color = if job.grayscale { "\n " } else { "" }; let copies = job.copies.clamp(1, 999); format!( r#" {copies} - {duplex} - + {duplex}{color} "# @@ -171,10 +172,97 @@ pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: boo Ok(size) } +/// What a driver's PrintCapabilities offer for the options [`verify_job_options`] checks. A +/// field is `None` when the capabilities couldn't be read; that option is then checked strictly. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Offered { + /// The two-sided feature (`JobDuplexAllDocumentsContiguously`) has any option. + pub duplex: Option, + /// The colour feature (`PageOutputColor`) has an option other than Monochrome and Grayscale. + pub color: Option, + /// The collation feature (`DocumentCollate`) has a Collated option. + pub collate: Option, +} + +/// What these PrintCapabilities offer ([`Offered`]); all unknown when the document is not +/// PrintCapabilities. +pub(crate) fn offered(capabilities_xml: &str) -> Offered { + let Ok(document) = roxmltree::Document::parse(capabilities_xml) else { return Offered::default() }; + let root = document.root_element(); + if !root.has_tag_name((NS_PSF, "PrintCapabilities")) { + return Offered::default(); + } + let options = |name: &'static str| { + root.children() + .filter(move |n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, name)) + .flat_map(|f| f.children().filter(|n| n.has_tag_name((NS_PSF, "Option")))) + }; + Offered { + // The schema's two duplex features are alternatives; a driver offers one or the other. + duplex: Some(options("JobDuplexAllDocumentsContiguously").chain(options("DocumentDuplex")).next().is_some()), + // A vendor's own option might be colour, so only the two schema keywords count as not. + color: Some(options("PageOutputColor").any(|n| !named(n, "Monochrome") && !named(n, "Grayscale"))), + collate: Some(options("DocumentCollate").any(|n| named(n, "Collated"))), + } +} + +/// Whether a `pages`-page job asks for collated copies. +pub(crate) fn collated_copies(job: &Job, pages: usize) -> bool { + job.collate && job.copies.clamp(1, 999) > 1 && pages > 1 +} + +/// Whether collated copies are made by repeating the document in one job with one copy on the +/// ticket: the job asks for them and the driver can't collate. +pub(crate) fn repeats_collated(job: &Job, pages: usize, offered: Offered) -> bool { + collated_copies(job, pages) && offered.collate == Some(false) +} + +/// Whether a repeated job's validated one-copy ticket ([`crate::sheets::Repeat`]) prints on +/// both sides; `None` when the copies can't share one job and are printed as a job each: a +/// duplex option other than the schema's (the blank backs would be guessed), several pages per +/// sheet, binding (booklets and offset stacking included) or stapling, which would treat every +/// copy as one document. +pub(crate) fn one_job_sides(xml: &str) -> Option { + let document = roxmltree::Document::parse(xml).ok()?; + let features = |names: &'static [&'static str]| { + document.root_element().children().filter(move |n| n.has_tag_name((NS_PSF, "Feature")) && names.iter().any(|name| named(*n, name))) + }; + fn option<'a, 'i>(feature: roxmltree::Node<'a, 'i>) -> Option> { + feature.children().find(|n| n.has_tag_name((NS_PSF, "Option"))) + } + let mut two_sided = false; + for feature in features(&["JobDuplexAllDocumentsContiguously", "DocumentDuplex"]) { + match option(feature) { + Some(o) if named(o, "OneSided") => {} + Some(o) if named(o, "TwoSidedLongEdge") || named(o, "TwoSidedShortEdge") => two_sided = true, + _ => return None, + } + } + if features(&["JobNUpAllDocumentsContiguously", "DocumentNUp"]).any(|f| integer(f, "ScoredProperty", "PagesPerSheet").is_some_and(|n| n > 1)) { + return None; + } + if features(&["JobBindAllDocuments", "DocumentBinding", "JobStapleAllDocuments", "DocumentStaple"]) + .any(|f| !option(f).is_some_and(|o| named(o, "None"))) + { + return None; + } + Some(two_sided) +} + /// Confirm explicit job options survived driver validation. Collation only changes output /// when both the document and copy count exceed one. Without a duplex request the queue's -/// default applies; without forced grayscale the driver's normal color policy applies. -pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<(), Error> { +/// default applies; without a grayscale request the driver's own colour setting applies. +/// +/// A driver that offers an option ([`Offered`]) and returned a ticket without the requested +/// value dropped it, and the job is refused. A driver without the feature is accepted where it +/// can only do what was asked: one-sided on a driver with no two-sided feature, grayscale on one +/// with no colour option, uncollated copies on one that can't collate. Two-sided on a simplex +/// driver is refused, and so are collated copies on a driver that can't collate: Windows doesn't +/// document collating for such a driver (the standard XPS filters make the copies, and leave +/// collated copies to the device only when its GPD or PPD has a Collate command). The job then +/// asks for one copy and repeats the document instead ([`repeats_collated`]), in one job or a +/// job per copy ([`one_job_sides`]). An unknown offer (`None`) is checked strictly. +pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, offered: Offered) -> Result<(), Error> { let document = roxmltree::Document::parse(xml).map_err(spool_error)?; let root = document.root_element(); let option = |name: &str| { @@ -188,7 +276,15 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<( return Err(refused("copy count")); } if copies > 1 && pages > 1 && !option("DocumentCollate").is_some_and(|n| named(n, if job.collate { "Collated" } else { "Uncollated" })) { - return Err(refused("collation")); + match (job.collate, offered.collate) { + (false, Some(false)) => {} + (true, Some(false)) => { + return Err(spool_error( + "the printer's driver can't collate copies; nothing was sent (turn collation off, or print one copy at a time)", + )); + } + _ => return Err(refused("collation")), + } } if let Some(duplex) = job.duplex { let wanted = match duplex { @@ -196,11 +292,13 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<( Duplex::LongEdge => "TwoSidedLongEdge", Duplex::ShortEdge => "TwoSidedShortEdge", }; - if !option("JobDuplexAllDocumentsContiguously").is_some_and(|n| named(n, wanted)) { + let one_sided_only = duplex == Duplex::Off && offered.duplex == Some(false); + if !one_sided_only && !option("JobDuplexAllDocumentsContiguously").is_some_and(|n| named(n, wanted)) { return Err(refused("simplex/duplex setting")); } } - if job.grayscale && !option("PageOutputColor").is_some_and(|n| named(n, "Monochrome") || named(n, "Grayscale")) { + let monochrome_only = offered.color == Some(false); + if job.grayscale && !monochrome_only && !option("PageOutputColor").is_some_and(|n| named(n, "Monochrome") || named(n, "Grayscale")) { return Err(refused("grayscale")); } Ok(()) @@ -256,10 +354,11 @@ mod tests { assert!(!xml.contains("<&>"), "the title never enters the XML"); assert!(roxmltree::Document::parse(&xml).is_ok()); let xml = ticket_xml(&Job { copies: 5000, ..Job::default() }, false); - for value in [">999<", "psk:Collated", "psk:Color", "psk:Portrait"] { + for value in [">999<", "psk:Collated", "psk:Portrait"] { assert!(xml.contains(value), "missing {value}"); } assert!(!xml.contains("JobDuplex"), "no duplex asked: the queue's default applies"); + assert!(!xml.contains("PageOutputColor"), "no grayscale asked: the driver's own colour setting applies"); assert!(ticket_xml(&Job { duplex: Some(Duplex::Off), ..Job::default() }, false).contains("psk:OneSided")); assert!(ticket_xml(&Job { duplex: Some(Duplex::LongEdge), ..Job::default() }, false).contains("psk:TwoSidedLongEdge")); assert!(!ticket_xml(&Job { printer: Some("Office \"east\" <1>".into()), ..Job::default() }, false).contains("east")); @@ -301,31 +400,159 @@ mod tests { fn a_driver_that_drops_explicit_job_options_is_refused() { let job = Job { copies: 2, duplex: Some(Duplex::LongEdge), grayscale: true, ..Job::default() }; let xml = ticket_xml(&job, false); - assert!(verify_job_options(&xml, &job, 3).is_ok()); + assert!(verify_job_options(&xml, &job, 3, Offered::default()).is_ok()); for (from, to) in [(">2<", ">1<"), ("psk:Collated", "psk:Uncollated"), ("psk:TwoSidedLongEdge", "psk:OneSided"), ("psk:Monochrome", "psk:Color")] { - assert!(verify_job_options(&xml.replace(from, to), &job, 3).is_err(), "{from}"); + assert!(verify_job_options(&xml.replace(from, to), &job, 3, Offered::default()).is_err(), "{from}"); } - assert!(verify_job_options(&xml.replace("psk:Collated", "vendor:Collated"), &job, 3).is_err(), "namespace matters"); - assert!(verify_job_options(&xml.replace("psk:Monochrome", "psk:Grayscale"), &job, 3).is_ok()); + assert!(verify_job_options(&xml.replace("psk:Collated", "vendor:Collated"), &job, 3, Offered::default()).is_err(), "namespace matters"); + assert!(verify_job_options(&xml.replace("psk:Monochrome", "psk:Grayscale"), &job, 3, Offered::default()).is_ok()); for feature in ["DocumentCollate", "JobDuplexAllDocumentsContiguously", "PageOutputColor"] { let start = xml.find(&format!("").unwrap() + "".len(); let mut dropped = xml.clone(); dropped.replace_range(start..end, ""); - assert!(verify_job_options(&dropped, &job, 3).is_err(), "{feature}"); + assert!(verify_job_options(&dropped, &job, 3, Offered::default()).is_err(), "{feature}"); if feature == "DocumentCollate" { - assert!(verify_job_options(&dropped, &job, 1).is_ok(), "one page has no collating order"); + assert!(verify_job_options(&dropped, &job, 1, Offered::default()).is_ok(), "one page has no collating order"); } } let default = Job::default(); let xml = ticket_xml(&default, false); - assert!(verify_job_options(&xml, &default, 3).is_ok()); + assert!(verify_job_options(&xml, &default, 3, Offered::default()).is_ok()); let simplex = Job { duplex: Some(Duplex::Off), ..Job::default() }; - assert!(verify_job_options(&ticket_xml(&simplex, false), &simplex, 3).is_ok()); - assert!(verify_job_options(&xml, &simplex, 3).is_err(), "explicit simplex must be confirmed"); - assert!(verify_job_options(" String { + let feature = |name: &str, options: &[&str]| { + if options.is_empty() { + return String::new(); + } + let options: String = options.iter().map(|o| format!(r#""#)).collect(); + format!(r#"{options}"#) + }; + let duplex = feature("JobDuplexAllDocumentsContiguously", if duplex { &["psk:OneSided", "psk:TwoSidedLongEdge"] } else { &[] }); + format!( + r#" +{}{duplex}{} +"#, + feature("DocumentCollate", collations), + feature("PageOutputColor", colors) + ) + } + + const COLOR: &[&str] = &["psk:Color", "psk:Monochrome"]; + const COLLATE: &[&str] = &["psk:Collated", "psk:Uncollated"]; + + /// The ticket without one of its features, as a driver that doesn't know it returns it. + fn without(xml: &str, feature: &str) -> String { + let start = xml.find(&format!("").unwrap() + "".len(); + let mut dropped = xml.to_owned(); + dropped.replace_range(start..end, ""); + dropped + } + + #[test] + fn a_driver_without_a_duplex_feature_prints_one_side_and_is_not_refused_for_it() { + let offered = |duplex| offered(&capabilities_xml(duplex, COLOR, COLLATE)); + assert_eq!(offered(true), Offered { duplex: Some(true), color: Some(true), collate: Some(true) }); + assert_eq!(offered(false).duplex, Some(false)); + assert_eq!(super::offered(""), Offered::default(), "another namespace is not capabilities"); + assert_eq!(super::offered(""# + ); + assert_eq!(super::offered(&empty).duplex, Some(false), "a feature with no option offers nothing"); + let document_duplex = format!( + r#""# + ); + assert_eq!(super::offered(&document_duplex).duplex, Some(true), "DocumentDuplex is a duplex feature too"); + + let one_sided = Job { duplex: Some(Duplex::Off), ..Job::default() }; + let two_sided = Job { duplex: Some(Duplex::LongEdge), ..Job::default() }; + let kept = ticket_xml(&one_sided, false); + // The ticket a driver with no duplex feature returns: the setting is gone. + let none_kept = ticket_xml(&Job::default(), false); + + // The Print dialog's defaults (duplex Off) on a simplex-only queue. + assert!(verify_job_options(&none_kept, &one_sided, 3, offered(false)).is_ok()); + // Offered and kept. + assert!(verify_job_options(&kept, &one_sided, 3, offered(true)).is_ok()); + // Offered but the ticket lost it: the driver dropped the choice. + assert!(verify_job_options(&none_kept, &one_sided, 3, offered(true)).is_err()); + // Two-sided never rests on the missing feature. + assert!(verify_job_options(&none_kept, &two_sided, 3, offered(false)).is_err()); + // Unknown capabilities stay strict. + assert!(verify_job_options(&none_kept, &one_sided, 3, Offered::default()).is_err()); + // The other options are still checked on a simplex-only queue. + let copies = Job { copies: 2, duplex: Some(Duplex::Off), ..Job::default() }; + assert!(verify_job_options(&none_kept, &copies, 3, offered(false)).is_err(), "copy count"); + let gray = Job { grayscale: true, duplex: Some(Duplex::Off), ..Job::default() }; + assert!(verify_job_options(&none_kept, &gray, 3, offered(false)).is_err(), "grayscale"); + } + + #[test] + fn a_driver_without_colour_prints_grayscale_and_is_not_refused_for_it() { + let colors = |colors: &[&str]| offered(&capabilities_xml(true, colors, COLLATE)).color; + assert_eq!(colors(COLOR), Some(true)); + assert_eq!(colors(&["psk:Monochrome"]), Some(false), "monochrome-only"); + assert_eq!(colors(&["psk:Grayscale", "psk:Monochrome"]), Some(false)); + assert_eq!(colors(&[]), Some(false), "no colour feature"); + assert_eq!(colors(&["psk:Monochrome", "ns0000:Vivid"]), Some(true), "a vendor's option may be colour"); + + let gray = Job { grayscale: true, ..Job::default() }; + let kept = ticket_xml(&gray, false); + // A driver without the feature returns a ticket without it. + let dropped = without(&kept, "PageOutputColor"); + let color = kept.replace("psk:Monochrome", "psk:Color"); + let caps = |colors: &[&str]| offered(&capabilities_xml(true, colors, COLLATE)); + + assert!(verify_job_options(&dropped, &gray, 3, caps(&["psk:Monochrome"])).is_ok()); + assert!(verify_job_options(&dropped, &gray, 3, caps(&[])).is_ok()); + assert!(verify_job_options(&kept, &gray, 3, caps(COLOR)).is_ok()); + // A colour driver that dropped or changed the request. + assert!(verify_job_options(&color, &gray, 3, caps(COLOR)).is_err()); + assert!(verify_job_options(&dropped, &gray, 3, caps(COLOR)).is_err()); + assert!(verify_job_options(&dropped, &gray, 3, Offered::default()).is_err(), "unknown stays strict"); + } + + #[test] + fn copies_on_a_driver_that_cannot_collate_are_accepted_only_uncollated() { + let caps = |collations: &[&str]| offered(&capabilities_xml(true, COLOR, collations)); + assert_eq!(caps(COLLATE).collate, Some(true)); + assert_eq!(caps(&["psk:Uncollated"]).collate, Some(false)); + assert_eq!(caps(&[]).collate, Some(false)); + + let collated = Job { copies: 2, collate: true, ..Job::default() }; + let uncollated = Job { copies: 2, collate: false, ..Job::default() }; + let dropped = |job: &Job| without(&ticket_xml(job, false), "DocumentCollate"); + + // Uncollated copies are what a driver without collation prints. + assert!(verify_job_options(&dropped(&uncollated), &uncollated, 3, caps(&[])).is_ok()); + assert!(verify_job_options(&ticket_xml(&uncollated, false), &uncollated, 3, caps(&["psk:Uncollated"])).is_ok()); + // Collated copies are refused, saying why. + let refused = verify_job_options(&dropped(&collated), &collated, 3, caps(&[])).unwrap_err(); + assert!(refused.to_string().contains("can't collate"), "{refused}"); + // A driver that collates and dropped or changed the request. + assert!(verify_job_options(&dropped(&collated), &collated, 3, caps(COLLATE)).is_err()); + assert!(verify_job_options(&dropped(&uncollated), &uncollated, 3, caps(COLLATE)).is_err()); + let changed = ticket_xml(&collated, false).replace("psk:Collated", "psk:Uncollated"); + assert!(verify_job_options(&changed, &collated, 3, caps(COLLATE)).is_err()); + assert!(verify_job_options(&dropped(&uncollated), &uncollated, 3, Offered::default()).is_err(), "unknown stays strict"); + // The job repeats the document instead of asking for collated copies. + assert!(repeats_collated(&collated, 3, caps(&[]))); + assert!(!repeats_collated(&collated, 3, caps(COLLATE)) && !repeats_collated(&collated, 3, Offered::default())); + assert!(!repeats_collated(&uncollated, 3, caps(&[])) && !repeats_collated(&collated, 1, caps(&[]))); + assert!(!repeats_collated(&Job { copies: 1, ..collated.clone() }, 3, caps(&[]))); + // One page or one copy has no collating order. + assert!(verify_job_options(&dropped(&collated), &collated, 1, caps(&[])).is_ok()); } #[test] @@ -404,4 +631,27 @@ mod tests { assert_eq!(job_filename(""), "Print.pdf"); assert_eq!(job_filename("Отчёт"), "Отчёт.pdf"); } + + #[test] + fn a_repeated_job_reads_its_sides_and_leaves_layouts_that_mix_copies_to_separate_jobs() { + let xml = ticket_xml(&Job { duplex: Some(Duplex::LongEdge), ..Job::default() }, false); + assert_eq!(one_job_sides(&xml), Some(true)); + assert_eq!(one_job_sides(&xml.replace("JobDuplexAllDocumentsContiguously", "DocumentDuplex")), Some(true)); + assert_eq!(one_job_sides(&ticket_xml(&Job { duplex: Some(Duplex::Off), ..Job::default() }, false)), Some(false)); + assert_eq!(one_job_sides(&ticket_xml(&Job::default(), false)), Some(false), "no duplex feature"); + assert_eq!(one_job_sides(&xml.replace("psk:TwoSidedLongEdge", "vendor:Duplex")), None, "an option it can't read"); + let with = |feature: &str| xml.replace("", &format!("{feature}")); + let n_up = |n: u32| { + with(&format!( + r#"{n}"# + )) + }; + assert_eq!(one_job_sides(&n_up(1)), Some(true)); + assert_eq!(one_job_sides(&n_up(2)), None); + for feature in ["JobBindAllDocuments", "JobStapleAllDocuments", "DocumentStaple"] { + let finishing = |option: &str| with(&format!(r#""#)); + assert_eq!(one_job_sides(&finishing("None")), Some(true), "{feature}"); + assert_eq!(one_job_sides(&finishing("StapleTopLeft")), None, "{feature}"); + } + } } diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index 45a23aa7..b99d1cf0 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -45,6 +45,15 @@ fn two_sided(device: &PrinterDevice) -> Option { Some(capabilities.options_for_feature(duplex).any(|o| o.name.as_ref().is_some_and(|n| n.local_name.starts_with("TwoSided")))) } +/// What the driver's PrintCapabilities offer for the job options [`ticket::verify_job_options`] +/// checks; all unknown when they can't be read. +fn driver_offers(device: &PrinterDevice) -> ticket::Offered { + PrintCapabilities::fetch_xml_for_ticket(device, None) + .ok() + .and_then(|xml| String::from_utf8(xml).ok()) + .map_or_else(ticket::Offered::default, |xml| ticket::offered(&xml)) +} + /// The queue the job goes to: the one asked for, else the default. Never another one: a job /// for a queue that has gone must not come out of a different printer. fn select_device(devices: Vec, requested: Option<&str>, default: Option<&str>) -> Result { @@ -142,9 +151,32 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM let read = &read; // The worker is joined before `dir` is dropped: the spooler reads the file. let result = crate::progress::run(progress, move |sender| { - let (ticket, paper) = driver_ticket(&device, job, first, landscape)?; + let (mut ticket, mut paper) = driver_ticket(&device, job, first, landscape)?; let xml = std::str::from_utf8(ticket.get_xml()).map_err(spool_error)?; - ticket::verify_job_options(xml, job, count)?; + let mut repeat = None; + // Jobs of one copy each, for collated copies that can't be one job. + let mut jobs = 1; + // The capabilities are read when the ticket lacks a requested value: they tell a driver + // that dropped it from one that never had the feature. Unreadable stays strict. A driver + // can also keep a collation it doesn't offer, so collated copies always read them. + let checked = ticket::verify_job_options(xml, job, count, ticket::Offered::default()); + if checked.is_err() || ticket::collated_copies(job, count) { + let offered = driver_offers(&device); + if ticket::repeats_collated(job, count, offered) { + let single = Job { copies: 1, ..job.clone() }; + (ticket, paper) = driver_ticket(&device, &single, first, landscape)?; + let xml = std::str::from_utf8(ticket.get_xml()).map_err(spool_error)?; + ticket::verify_job_options(xml, &single, count, offered)?; + // Two-sided as the driver will print it, the queue's default included. + let copies = job.copies.clamp(1, 999); + match ticket::one_job_sides(xml) { + Some(two_sided) => repeat = Some(sheets::Repeat { copies, two_sided }), + None => jobs = copies, + } + } else if checked.is_err() { + ticket::verify_job_options(xml, job, count, offered)?; + } + } let (fit, paper) = match job.scaling { Scaling::Exact => (Fit::Exact, first), Scaling::ShrinkToFit => { @@ -153,29 +185,50 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM } }; let edits = sheets::plan(&read.sheets, paper, fit)?; - let updated = if edits.is_empty() { None } else { Some(read.update(&edits)?) }; + let mut updated = None; + if let Some(r) = repeat { + updated = read.repeated(&edits, r)?; + if updated.is_none() { + (repeat, jobs) = (None, r.copies); + } + } + if updated.is_none() && !edits.is_empty() { + updated = Some(read.update(&edits)?); + } let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(written)?; file.write_all(updated.as_deref().unwrap_or(pdf)).map_err(written)?; drop(file); - WinPdfPrinter::new(device) - .print_with_progress(&path, ticket, |processed, total| { - // Page acceptance is distinct from terminal job success. The callback - // drains on the submitter's thread, so it can retain non-Send UI state. - let _ = sender.send((processed as usize, total as usize)); - }) - .map_err(|e| { - let mut message = e.to_string(); - let mut cause = std::error::Error::source(&e); - while let Some(source) = cause { - message.push_str(&format!(": {source}")); - cause = source.source(); - } - spool_error(message) - }) + let printer = WinPdfPrinter::new(device); + let jobs = jobs as usize; + for copy in 0..jobs { + printer + .print_with_progress(&path, ticket.clone(), |processed, total| { + // Page acceptance is distinct from terminal job success. The callback + // drains on the submitter's thread, so it can retain non-Send UI state. + let (processed, total) = (processed as usize, total as usize); + let _ = sender.send((copy * total + processed, jobs * total)); + }) + .map_err(|e| { + let mut message = e.to_string(); + let mut cause = std::error::Error::source(&e); + while let Some(source) = cause { + message.push_str(&format!(": {source}")); + cause = source.source(); + } + if copy > 0 { + message = format!("{copy} of {jobs} copies were sent, each as a job of its own, and the next was not: {message}"); + } + spool_error(message) + })?; + } + Ok((repeat, jobs)) }); drop(dir); - result?; - Ok(format!("spooled {count} sheet(s)")) + Ok(match result? { + (Some(repeat), _) => format!("spooled {count} sheet(s) {} times, collated", repeat.copies), + (None, jobs) if jobs > 1 => format!("spooled {count} sheet(s) as {jobs} jobs, one per collated copy"), + _ => format!("spooled {count} sheet(s)"), + }) } #[cfg(test)] @@ -199,7 +252,7 @@ mod tests { let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap(); let job = Job { copies: 2, ..Job::default() }; let (ticket, paper) = driver_ticket(&device, &job, (612.0, 792.0), false).unwrap(); - ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &job, 1).unwrap(); + ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &job, 1, ticket::Offered::default()).unwrap(); assert_eq!(paper, (215_900, 279_400)); eprintln!("unprintable margin: {} pt", imageable_margin(&device, &ticket)); if let Ok(path) = std::env::var("CRAFT_PRINT_TEST_TICKET_OUTPUT") { @@ -212,7 +265,7 @@ mod tests { ("DocumentCollate", "Collated", Job { copies: 2, ..Job::default() }), ] { let (ticket, _) = driver_ticket(&device, &requested, (612.0, 792.0), false).unwrap(); - let retained = ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &requested, 3); + let retained = ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &requested, 3, ticket::Offered::default()); eprintln!("{feature}={wanted}: {retained:?}"); let offered = capabilities .options_for_feature(OwnedName::qualified(feature, NS_PSK, Some("psk"))) @@ -242,4 +295,30 @@ mod tests { eprintln!("{sent:?}"); assert!(sent.is_ok()); } + + /// A colour chosen in the driver's own preferences (Properties…) reaches the job when the job + /// doesn't ask for grayscale: the validated ticket has the colour of the user's preferences, + /// whatever they are (storytold/pdfcraft#2422; it was always Color before). Set the queue to + /// black and white in its preferences to see the case that failed. Sends no job. + #[test] + #[ignore = "needs CRAFT_PRINT_TEST_PRINTER naming an installed printer; sends no job"] + fn the_preferences_colour_reaches_a_job_without_grayscale() { + fn colour(ticket: &PrintTicket) -> Option { + let xml = std::str::from_utf8(ticket.get_xml()).ok()?; + let document = roxmltree::Document::parse(xml).ok()?; + let feature = document + .root_element() + .children() + .find(|n| n.has_tag_name((ticket::NS_PSF, "Feature")) && n.attribute("name").is_some_and(|a| a.ends_with(":PageOutputColor")))?; + feature.children().find(|n| n.has_tag_name((ticket::NS_PSF, "Option")))?.attribute("name").map(str::to_owned) + } + let name = std::env::var("CRAFT_PRINT_TEST_PRINTER").unwrap(); + let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap(); + let mut builder = PrintTicketBuilder::new(&device).unwrap(); + builder.merge(PrintTicket::from_xml(winprint::ticket::DEFAULT_PRINT_TICKET_XML.as_bytes().to_vec())).unwrap(); + let preferences = colour(&builder.build().unwrap()); + eprintln!("the preferences' colour: {preferences:?}"); + let (ticket, _) = driver_ticket(&device, &Job::default(), (595.28, 841.89), false).unwrap(); + assert_eq!(colour(&ticket), preferences, "a job without grayscale keeps the preferences' colour"); + } } diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 86198840..aff39e73 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -32,7 +32,7 @@ pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PA pub use render::{close_flyout, flyout, open_flyout}; pub use surface::{ DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, - WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, + WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, route_app_drag, }; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect, zone_shape}; pub use workspace::Workspace; diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 3c18c37e..8e8fd655 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -37,6 +37,41 @@ struct Content { input_probe: Option, events: Vec, raw_events: Vec, + /// The panel with an app drag source, and where it was drawn. + source: Option, + /// How the source marks its drag as one that carries something. + marks: Marks, + source_rect: Option, + /// The source's drag so far, and how often its drag stopped. + source_delta: egui::Vec2, + source_stopped: usize, + /// How often the source's body saw a primary-button release in its input. + source_releases: usize, + /// The source asks egui to run its window's pass again, once, the next time it draws. + discard_next: bool, + /// The source asks for that many more passes, drawn or not, before the frame completes. + discard_passes: usize, + /// A drop site asks for its window's pass again when it reads a release. + discard_drop: bool, + /// Panels whose bodies are app drop sites: where each saw the pointer, and where a release + /// came with the pointer inside. + drop_sites: Vec, + hovers: Vec<(u32, Pos2)>, + drops: Vec<(u32, Pos2)>, + /// Drop sites whose release found egui's drag-and-drop payload. + payload_drops: Vec, +} + +/// How a test's app drag source marks its drag. +#[derive(Default)] +enum Marks { + /// It does not: a plain egui drag, such as a slider's. + #[default] + Nothing, + /// [`super::route_app_drag`] for this area, each frame while it drags. + Route(egui::Id), + /// An egui drag-and-drop payload, its panel. + Payload, } impl DockContent for Content { @@ -59,6 +94,41 @@ impl DockContent for Content { self.count += usize::from(response.clicked()); self.counter_rect = Some(response.rect); } + if self.source == Some(*panel) { + let response = ui.allocate_response(vec2(120.0, 60.0), egui::Sense::drag()); + self.source_rect = Some(response.rect); + self.source_delta += response.drag_delta(); + self.source_stopped += usize::from(response.drag_stopped()); + self.source_releases += usize::from(ui.input(|i| i.pointer.primary_released())); + let discard = std::mem::take(&mut self.discard_next); + let more = self.discard_passes > 0; + if more { + self.discard_passes -= 1; + } + if discard || more { + ui.ctx().request_discard("the source's pass runs twice"); + } + match self.marks { + Marks::Route(area) if response.dragged() => super::route_app_drag(ui.ctx(), area), + Marks::Payload => response.dnd_set_drag_payload(*panel), + _ => {} + } + } + // As apps' drop sites do: the pointer over the body, and a release there. + if self.drop_sites.contains(panel) + && let Some(pos) = ui.ctx().pointer_hover_pos().filter(|p| ui.max_rect().contains(*p)) + { + self.hovers.push((*panel, pos)); + if ui.input(|i| i.pointer.any_released()) { + self.drops.push((*panel, pos)); + if egui::DragAndDrop::has_payload_of_type::(ui.ctx()) { + self.payload_drops.push(*panel); + } + if std::mem::take(&mut self.discard_drop) { + ui.ctx().request_discard("a drop site's pass runs twice"); + } + } + } self.saw_files |= ui.input(|i| !i.raw.hovered_files.is_empty() || !i.raw.dropped_files.is_empty()); if ui.rect_contains_pointer(ui.max_rect()) { let pressed = [PointerButton::Primary, PointerButton::Secondary].into_iter().filter(|b| ui.input(|i| i.pointer.button_pressed(*b))); @@ -1680,6 +1750,50 @@ fn a_focused_field_and_a_held_press_stay_inert_across_a_block() { assert_eq!(d.content.count, 0); } +#[test] +fn blocking_input_hands_focus_from_a_native_window_to_the_main_window() { + let focus_main = |d: &DockDriver| { + d.host().commands.iter().filter(|(v, c)| *v == egui::ViewportId::ROOT && matches!(c, ViewportCommand::Focus)).count() + }; + let steps = |d: &mut DockDriver, n: usize| { + for _ in 0..n { + d.step(vec![]); + } + }; + let focused_float = || { + let mut d = blocked_driver(); + let viewport = d.viewport(id_of(&d)); + d.host().focus = Some(viewport); + d.step(vec![]); + d.input_blocked = true; + d + }; + // A dialog opened from a floating panel needs the keys there, in the main window: once, + // after a short wait. + let mut d = focused_float(); + steps(&mut d, 2); + assert_eq!(focus_main(&d), 0, "not yet: an OS dialog may still take focus"); + steps(&mut d, 30); + assert_eq!(focus_main(&d), 1); + // An OS dialog took focus meanwhile: no window of the area has it, so nothing is sent. + let mut d = focused_float(); + d.step(vec![]); + d.host().focus = None; + steps(&mut d, 30); + assert_eq!(focus_main(&d), 0); + // Blocking ended before the wait was over. + let mut d = focused_float(); + d.step(vec![]); + d.input_blocked = false; + steps(&mut d, 30); + assert_eq!(focus_main(&d), 0); + // With the main window focused already, nothing is sent. + let mut d = blocked_driver(); + d.input_blocked = true; + steps(&mut d, 30); + assert_eq!(focus_main(&d), 0); +} + #[test] fn blocking_input_cancels_a_drag_in_progress() { // A tab drag a native window owns. @@ -1849,3 +1963,427 @@ fn the_session_follows_winits_environment_rule() { assert_eq!(session(true, with(&[])), Session::Unknown); assert_eq!(session(false, with(&["WAYLAND_DISPLAY"])), Session::Other); } + +/// A driver whose panel `source` has an app drag source and whose panels `sites` are drop sites. +fn app_drag(layout: Layout, source: u32, sites: &[u32]) -> DockDriver { + let mut d = driver(layout); + (d.content.source, d.content.drop_sites, d.content.marks) = (Some(source), sites.to_vec(), Marks::Route(d.id)); + d.settle(); + d +} + +#[allow(clippy::expect_used)] +fn source(d: &DockDriver) -> Pos2 { + d.content.source_rect.expect("source drawn").center() +} + +#[test] +fn an_app_drag_from_the_main_window_reaches_a_native_windows_panel() { + // Panel 3's frame lies under window [5, 6], which takes the drop. + let mut d = app_drag(layout(true), 1, &[3, 5]); + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + assert!(d.content.hovers.contains(&(5, local)), "{:?}", d.content.hovers); + d.release(point); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, local)]); + assert!(!d.content.hovers.iter().any(|(panel, _)| *panel == 3), "{:?}", d.content.hovers); +} + +#[test] +fn a_stop_withdraws_a_routed_release_no_window_read_but_not_the_owners() { + let mut d = app_drag(layout(true), 5, &[1]); + let id = window(&d, 5).id; + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, source(&d), local); + // The release is queued for the main window's next pass, and the drag is cancelled first. + d.window_release(id, local); + super::cancel_drag::(&d.ctx, d.id); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.drops, vec![]); + // The owner still gets its own release. + assert_eq!(d.content.source_stopped, 1); +} + +#[test] +fn a_plain_egui_drag_over_a_native_window_keeps_its_moves_and_is_not_routed() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.marks = Marks::Nothing; + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + let from = source(&d); + d.hold(from, over); + d.move_to(over + vec2(20.0, 10.0)); + let moved = over + vec2(20.0, 10.0) - from; + assert!((d.content.source_delta - moved).length() < 0.01, "{:?}, not {moved:?}", d.content.source_delta); + d.release(over); + d.step(vec![]); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); + assert_eq!(d.content.source_stopped, 1); +} + +#[test] +fn an_egui_payload_reaches_a_window_drawn_before_the_owner() { + // The main window draws before the native window that owns the drag. + let mut d = app_drag(layout(true), 5, &[1]); + d.content.marks = Marks::Payload; + let id = window(&d, 5).id; + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, source(&d), local); + d.window_release(id, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.payload_drops, vec![1]); + assert!(!egui::DragAndDrop::has_any_payload(&d.ctx)); +} + +#[test] +fn an_owner_that_discards_its_release_pass_keeps_the_payload_for_the_target() { + let mut d = app_drag(layout(true), 5, &[1]); + d.content.marks = Marks::Payload; + let id = window(&d, 5).id; + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, source(&d), local); + // The owner's pass that holds its release runs twice. + d.content.discard_next = true; + d.window_release(id, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.payload_drops, vec![1]); +} + +#[test] +fn pointer_events_after_a_held_release_wait_behind_it() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let point = d.host().windows[&d.viewport(id)].rect.min + vec2(120.0, 200.0) - ORIGIN.to_vec2(); + d.hold(source(&d), point); + let delta = d.content.source_delta; + // The release over the native window, then the pointer back on the source and a new press, + // in one input. + let at = source(&d); + d.step(vec![button(point, false), Event::PointerMoved(at), button(at, true)]); + assert_eq!(d.content.source_delta, delta, "no move after the release reaches the source's drag"); + d.step(vec![]); + assert!(d.ctx.input(|i| i.pointer.primary_down()), "the new press outlives the held release"); +} + +#[test] +fn the_owners_drag_stops_a_pass_after_its_release_over_another_window() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + d.release(point); + assert_eq!((d.content.drops.clone(), d.content.source_stopped), (vec![(5, local)], 0)); + // The owner saw the pointer go, and holds its release and the pointer's going once. + assert_eq!(d.ctx.input(|i| i.pointer.hover_pos()), None); + let held = super::render::held_events(&d.ctx, egui::ViewportId::ROOT); + assert!(matches!(held.as_slice(), [Event::PointerButton { pressed: false, .. }, Event::PointerGone]), "{held:?}"); + d.step(vec![]); + assert_eq!(d.content.source_stopped, 1); + assert_eq!(d.ctx.dragged_id(), None); +} + +#[test] +fn a_press_right_after_a_routed_release_outlives_the_held_release() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let point = d.host().windows[&d.viewport(id)].rect.min + vec2(120.0, 200.0) - ORIGIN.to_vec2(); + d.hold(source(&d), point); + d.release(point); + // The owner's held release comes before a press in its next input. + let at = source(&d); + d.step(vec![Event::PointerMoved(at), button(at, true)]); + assert!(d.ctx.input(|i| i.pointer.primary_down())); +} + +#[test] +fn a_routed_release_clicks_nothing_in_the_target() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.counter = Some(5); + d.step(vec![]); + let id = window(&d, 5).id; + let button = d.content.counter_rect.unwrap().center(); + let point = d.host().windows[&d.viewport(id)].rect.min + button.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + d.release(point); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, button)]); + assert_eq!(d.content.count, 0); +} + +#[test] +fn an_app_drag_from_a_native_window_reaches_the_main_window() { + let mut d = app_drag(layout(true), 5, &[1]); + let id = window(&d, 5).id; + // Frame [1, 2], left of the native window on the desktop. + let point = pos2(100.0, 300.0); + let local = d.window_local(id, ORIGIN + point.to_vec2()).unwrap(); + d.window_hold(id, source(&d), local); + d.window_release(id, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(1, point)]); + assert!(d.content.hovers.contains(&(1, point)), "{:?}", d.content.hovers); +} + +#[test] +fn an_app_drag_between_native_windows_drops_in_the_front_one() { + // Window [7] overlaps window [5, 6] at (550, 280) on the desktop; window [8] is apart. + let mut layout = layout(true); + layout.floating.push(Floating::single(vec![7], 0, [350.0, 150.0, 240.0, 150.0])); + layout.floating.push(Floating::single(vec![8], 0, [20.0, 420.0, 240.0, 150.0])); + let overlap = pos2(550.0, 280.0); + for front in [7, 5] { + let mut d = app_drag(layout.clone(), 8, &[5, 7]); + let viewport = d.viewport(window(&d, front).id); + d.host().focus = Some(viewport); + d.step(vec![]); + d.host().focus = Some(egui::ViewportId::ROOT); + d.step(vec![]); + let (owner, target) = (window(&d, 8).id, window(&d, front).id); + let local = d.window_local(owner, overlap).unwrap(); + d.window_hold(owner, source(&d), local); + d.window_release(owner, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(front, d.window_local(target, overlap).unwrap())], "front {front}"); + } +} + +#[test] +fn an_app_drag_released_outside_every_window_drops_nowhere() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + d.hold(source(&d), over); + assert!(d.content.hovers.iter().any(|(panel, _)| *panel == 5), "{:?}", d.content.hovers); + // Left of the main window, over no window of the app: the native window sees the pointer go. + let out = pos2(-60.0, 120.0); + d.move_to(out); + d.content.hovers.clear(); + d.move_to(out); + d.release(out); + d.step(vec![]); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} + +/// Hold an app drag from panel 1 over window [5, 6] and release it there. +fn drag_onto_window(d: &mut DockDriver) { + let id = window(d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + d.hold(source(d), over); + d.release(over); + d.step(vec![]); + d.step(vec![]); +} + +#[test] +fn no_app_drag_is_routed_where_windows_report_no_positions() { + let mut d = app_drag(layout(true), 1, &[5]); + d.host().no_position = true; + d.step(vec![]); + drag_onto_window(&mut d); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} + +#[test] +fn a_blocked_native_window_gets_no_app_drag() { + let mut d = app_drag(layout(true), 1, &[5]); + d.input_blocked = true; + d.settle(); + drag_onto_window(&mut d); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} + +#[test] +fn escape_and_cancel_drag_end_the_routing_of_an_app_drag() { + for cancel in [false, true] { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + d.hold(source(&d), over); + assert!(!d.content.hovers.is_empty(), "cancel {cancel}"); + if cancel { + super::cancel_drag::(&d.ctx, d.id); + d.step(vec![]); + } else { + d.key(Key::Escape); + } + d.content.hovers.clear(); + d.move_to(over); + d.release(over); + d.step(vec![]); + assert!(d.content.hovers.is_empty(), "cancel {cancel}: {:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "cancel {cancel}: {:?}", d.content.drops); + } +} + +#[test] +fn a_tab_drag_onto_a_native_window_is_no_app_drag() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let out = d.drag(tab, over); + d.step(vec![]); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 5 | 6, .. }]), "{:?}", out.actions); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} + +/// `cancel_drag` from an app's per-area native input hook (`DockArea::native_input_hook`) +/// runs while egui holds the `NativeInput` plugin lock. It must not try to lock that plugin +/// again: in a debug build the original deadlocks, and egui's debug mutex panics after its +/// 10 s deadlock bound. +#[test] +fn cancel_drag_from_a_native_input_hook_returns() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + let cancel = Arc::new(AtomicBool::new(false)); + let flag = cancel.clone(); + let mut d = driver(layout(true)).with_native_input_hook(move |ctx, _input| { + if flag.swap(false, Ordering::SeqCst) { + // `driver` uses this area id. + super::cancel_drag::(ctx, egui::Id::unique("dock-driver")); + } + }); + let id = id_of(&d); + d.settle(); + + // The hook runs for the native window's input, inside `NativeInput::input_hook`. + cancel.store(true, Ordering::SeqCst); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 150.0))]); + assert!(!cancel.load(Ordering::SeqCst), "the hook did not run"); +} + +/// An app drag with a release queued for the main window: `cancel_drag` from the per-area +/// native input hook (inside `Plugin::input_hook`, with egui holding the `NativeInput` plugin +/// lock) returns without deadlocking, withdraws that release, and leaves the owner's own +/// release. +#[test] +fn cancel_drag_from_a_native_input_hook_withdraws_a_queued_release() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + let cancel = Arc::new(AtomicBool::new(false)); + let flag = cancel.clone(); + let mut d = app_drag(layout(true), 5, &[1]).with_native_input_hook(move |ctx, _input| { + if flag.swap(false, Ordering::SeqCst) { + // `driver` uses this area id. + super::cancel_drag::(ctx, egui::Id::unique("dock-driver")); + } + }); + let id = window(&d, 5).id; + // The release is over the main window: it is queued for the main window's next pass. + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, source(&d), local); + cancel.store(true, Ordering::SeqCst); + d.window_release(id, local); + assert!(!cancel.load(Ordering::SeqCst), "the hook did not run"); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); + assert_eq!(d.content.source_stopped, 0, "the owner's own release is still held"); + d.step(vec![]); + d.step(vec![]); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); + assert_eq!(d.content.source_stopped, 1, "the owner still gets its own release"); + assert!(super::render::held_events(&d.ctx, egui::ViewportId::ROOT).is_empty()); + assert!(super::render::held_events(&d.ctx, d.viewport(id)).is_empty()); +} + +/// A main-window owner that asks for its release pass to be discarded still gets its release +/// exactly once, and the native target reads the payload exactly once. +#[test] +fn a_root_owner_that_discards_its_release_pass_drops_the_payload_once() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.marks = Marks::Payload; + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + + d.content.discard_next = true; + d.release(point); + assert_eq!(d.content.drops, vec![(5, local)]); + assert_eq!(d.content.payload_drops, vec![5]); + // The owner's release may arrive in the same frame's rerun. + assert_eq!(d.content.source_stopped, 1); + assert_eq!(d.content.source_releases, 1, "the owner receives its release once"); + + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, local)], "the target drops once"); + assert_eq!(d.content.payload_drops, vec![5], "the payload drops once"); + assert_eq!(d.content.source_stopped, 1, "the owner gets its release once"); + assert_eq!(d.content.source_releases, 1, "the owner receives its release once"); + assert!(!egui::DragAndDrop::has_any_payload(&d.ctx)); + assert!(super::render::held_events(&d.ctx, egui::ViewportId::ROOT).is_empty()); +} + +/// A native target that asks for its release pass to be discarded reads the release and the +/// payload once; its rerun has no events, and the owner's held release follows a pass later. +#[test] +fn a_target_that_discards_its_release_pass_drops_the_payload_once() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.marks = Marks::Payload; + d.content.discard_drop = true; + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + + d.release(point); + assert_eq!(d.content.drops, vec![(5, local)]); + assert_eq!(d.content.payload_drops, vec![5]); + assert_eq!(d.content.source_stopped, 0, "the owner's release is held for a later pass"); + assert_eq!(d.content.source_releases, 0, "the owner's release is held for a later pass"); + + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, local)], "the target drops once"); + assert_eq!(d.content.payload_drops, vec![5], "the payload drops once"); + assert_eq!(d.content.source_stopped, 1, "the owner gets its release once"); + assert_eq!(d.content.source_releases, 1, "the owner receives its release once"); + assert!(!egui::DragAndDrop::has_any_payload(&d.ctx)); + assert!(super::render::held_events(&d.ctx, egui::ViewportId::ROOT).is_empty()); +} + +/// Several discarded passes in one frame neither duplicate nor lose the routed release: the +/// target's drop and payload read, and the owner's release, happen exactly once. +#[test] +fn repeated_discards_deliver_the_release_and_drop_once() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.marks = Marks::Payload; + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + + d.ctx.memory_mut(|memory| memory.options.max_passes = std::num::NonZeroUsize::new(4).unwrap()); + d.content.discard_passes = 3; + d.release(point); + assert_eq!(d.content.drops, vec![(5, local)], "the target drops once"); + assert_eq!(d.content.payload_drops, vec![5], "the payload drops once"); + assert_eq!(d.content.source_stopped, 1, "the owner gets its release once"); + assert_eq!(d.content.source_releases, 1, "the owner receives its release once"); + + // The frame ran its fourth, completing pass here; the release and payload are gone. + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, local)]); + assert_eq!(d.content.payload_drops, vec![5]); + assert_eq!(d.content.source_stopped, 1); + assert_eq!(d.content.source_releases, 1); + assert!(!egui::DragAndDrop::has_any_payload(&d.ctx)); + assert!(super::render::held_events(&d.ctx, egui::ViewportId::ROOT).is_empty()); +} diff --git a/crates/ui/src/docking/render/body_drag.rs b/crates/ui/src/docking/render/body_drag.rs new file mode 100644 index 00000000..0d5d5b4a --- /dev/null +++ b/crates/ui/src/docking/render/body_drag.rs @@ -0,0 +1,290 @@ +//! App drags in panel bodies across OS windows (feature `native-windows`). +//! +//! egui gives each window only its own input, and the window where the button went down gets +//! the pointer until the release. An app's drag that began in a panel body (a media item onto +//! a timeline in another window) would never show its pointer or release to the window it is +//! over. So while one that carries something lasts (an egui drag-and-drop payload, or +//! [`crate::docking::route_app_drag`]), the owner's input hook ([`super::native`]'s plugin) +//! sends each pointer move over another of the app's windows to that window, in its +//! coordinates, as `PointerMoved`, `PointerGone` when the pointer leaves it, and the release as +//! a primary `PointerButton` there. The owner sees the pointer gone while it is over another +//! window, so its own panels under that window see no hover and no drop. +//! +//! A window drawn after the owner in the same pass gets the events in that pass: the main +//! window owning the drag reaches every native window at once. The main window, and a native +//! window drawn before the owner, get them on the next pass, which the hook asks for. The +//! owner's own release then waits a pass too, so its payload outlives the target's read. + +use std::{collections::HashMap, fmt::Debug, hash::Hash}; + +use egui::{Context, Event, Id, Key, PointerButton, Pos2, RawInput, Rect, ViewportId}; + +use super::Renderer; +use super::native::{WindowDrag, body_input, content_of}; +use crate::docking::desktop::{Over, window_at}; +use crate::docking::{Drag, GroupDrag}; + +/// The application's windows on the desktop, as the main window last mapped them. +#[derive(Clone, Debug, Default)] +pub(super) struct Desktop { + /// The main window's content, where known. + pub main: Option, + /// Shown native windows' content, front first, and how many lie in front of the main window. + pub windows: Vec<(u64, Rect)>, + pub front: usize, + /// The viewport of each native window. + pub viewports: Vec<(u64, ViewportId)>, +} + +impl Desktop { + /// The window under the desktop point `point`, and the point in its coordinates. + fn at(&self, point: Pos2) -> Option<(ViewportId, Pos2)> { + match window_at(point, &self.windows, self.front, Some(self.main?), None) { + Over::Main(local) => Some((ViewportId::ROOT, local)), + Over::Window(window, local) => self.viewports.iter().find(|(w, _)| *w == window).map(|(_, viewport)| (*viewport, local)), + Over::Desktop => None, + } + } +} + +/// A dock area's app drag in a panel body, in the input plugin. +#[derive(Debug)] +pub(super) struct BodyRoute { + area: Id, + /// The window the drag began in, and the widget egui drags there. + owner: ViewportId, + /// [`crate::docking::Floating::id`] of the owner; `None` for the main window. + window: Option, + id: Id, + desktop: Desktop, + /// The window other than the owner that the pointer is over. + target: Option, + /// Nothing more is routed until the drag ends: released, or ended by Escape, a focus loss, + /// input blocking or [`crate::docking::cancel_drag`]. + ended: bool, +} + +/// Events waiting for each viewport's next input, with the main window's pass that queued them +/// and the dock area whose route queued each. +pub(super) type Queued = HashMap)>; + +/// Each owner's held release and the pointer events after it, with the main window's pass that +/// held them: delivered in a later pass only, never taken back. +pub(super) type Held = HashMap)>; + +fn queue(queued: &mut Queued, pass: u64, area: Id, viewport: ViewportId, event: Event) { + let (at, events) = queued.entry(viewport).or_default(); + *at = pass; + events.push((area, event)); +} + +impl BodyRoute { + /// The owner's input before egui reads it: each pointer event over another of the app's + /// windows goes to that window, and the owner sees the pointer gone. Returns whether + /// anything was queued. Where the owner reports no desktop position nothing is routed. + pub(super) fn route(&mut self, input: &mut RawInput, queued: &mut Queued, held: &mut Held, pass: u64) -> bool { + if input.viewport_id != self.owner || self.ended { + return false; + } + let Some((origin, _)) = input.viewports.get(&input.viewport_id).and_then(content_of) else { return false }; + let (mut routed, mut holding) = (false, false); + let mut events = Vec::with_capacity(input.events.len()); + for event in std::mem::take(&mut input.events) { + // Pointer events after a held release wait behind it, in order. + if holding && matches!(event, Event::PointerMoved(_) | Event::PointerButton { .. } | Event::PointerGone) { + held.entry(self.owner).or_default().1.push(event); + continue; + } + let (pos, release) = match &event { + Event::PointerMoved(pos) => (*pos, None), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers } => (*pos, Some(*modifiers)), + _ => { + events.push(event); + continue; + } + }; + if self.ended { + events.push(event); + continue; + } + let over = pos.is_finite().then(|| self.desktop.at(origin + pos.to_vec2())).flatten().filter(|(v, _)| *v != self.owner); + let target = over.map(|(viewport, _)| viewport); + if let Some(left) = self.target.filter(|left| Some(*left) != target) { + queue(queued, pass, self.area, left, Event::PointerGone); + routed = true; + } + self.target = target; + if let Some((viewport, local)) = over { + queue(queued, pass, self.area, viewport, Event::PointerMoved(local)); + if let Some(modifiers) = release { + let release = Event::PointerButton { pos: local, button: PointerButton::Primary, pressed: false, modifiers }; + queue(queued, pass, self.area, viewport, release); + // The owner's own release waits for its input in a later pass of the main + // window (not a discarded pass's rerun), so its drag, and an egui + // drag-and-drop payload, last until a window drawn before it has read the + // release. A stop never takes it back, or the button would stay down. + let (at, held_events) = held.entry(self.owner).or_default(); + *at = pass; + held_events.extend([event, Event::PointerGone]); + holding = true; + routed = true; + (self.ended, self.target) = (true, None); + events.push(Event::PointerGone); + continue; + } + routed = true; + events.push(Event::PointerGone); + } else { + events.push(event); + } + if release.is_some() { + (self.ended, self.target) = (true, None); + } + } + input.events = events; + routed + } +} + +/// End `area`'s routing: the window the pointer was over sees it go. `remove`: the drag is +/// over, so another may start. Otherwise the routing was stopped (Escape, a focus loss, input +/// blocking, [`crate::docking::cancel_drag`]), and events it queued that no window has read +/// yet are taken back, so a release queued before the stop reaches no window. +fn end_route(ctx: &Context, area: Id, remove: bool) { + let body = body_input(ctx); + let queued = { + let mut input = body.lock(); + let input = &mut *input; + if let Some(route) = input.routes.get_mut(&area) { + let mut gone: Vec = route.target.take().into_iter().collect(); + route.ended = true; + if remove { + input.routes.remove(&area); + } else { + for (viewport, (_, events)) in &mut input.queued { + let before = events.len(); + events.retain(|(from, _)| *from != area); + if events.len() != before && !gone.contains(viewport) { + gone.push(*viewport); + } + } + } + for viewport in &gone { + queue(&mut input.queued, input.pass, area, *viewport, Event::PointerGone); + } + Some(!gone.is_empty()) + } else { + None + } + }; + if queued == Some(true) { + ctx.request_repaint_of(ViewportId::ROOT); + } +} + +/// [`crate::docking::cancel_drag`]: an app drag in a panel body goes on, but no longer reaches +/// other windows. +pub(in crate::docking) fn cancel_body_route(ctx: &Context, area: Id) { + end_route(ctx, area, false); +} + +impl Renderer<'_, P> { + /// In the main window, once the desktop is mapped and before native windows draw: give the + /// routing the map, and end it where it cannot go on. `native`: viewports are native. + pub(super) fn map_body_route(&mut self, ctx: &Context, native: bool) { + let desktop = self.desktop(); + let owner = { + let body = body_input(ctx); + let mut input = body.lock(); + input.routes.get_mut(&self.id).map(|route| { + route.desktop = desktop; + route.window + }) + }; + // Windows are no longer native, or the owner closed. + let natives = &self.cross.viewports; + if owner.is_some_and(|window| !native || window.is_some_and(|w| !natives.iter().any(|(id, _)| *id == w))) { + end_route(ctx, self.id, true); + } + if native { + self.route_body_drag(ctx, None); + } + } + + fn desktop(&self) -> Desktop { + Desktop { main: self.cross.main, windows: self.cross.windows.clone(), front: self.cross.front, viewports: self.cross.viewports.clone() } + } + + /// After the window drawing now (`window`; `None` for the main window) drew its panels' + /// bodies: start routing an app drag that began in one of them, and end it when the drag + /// is over. An Escape in any of the app's windows, a focus loss and input blocking end it. + pub(super) fn route_body_drag(&mut self, ctx: &Context, window: Option) { + let (area, viewport, dragged) = (self.id, ctx.viewport_id(), ctx.dragged_id()); + let route = { + let body = body_input(ctx); + let input = body.lock(); + input.routes.get(&area).map(|r| (r.owner, r.id)) + }; + let stops = self.input_blocked || self.cross.focus_lost || ctx.input(|i| i.key_pressed(Key::Escape)); + match route { + Some((owner, id)) if owner == viewport && dragged != Some(id) => end_route(ctx, area, true), + Some(_) if stops => end_route(ctx, area, false), + Some(_) => {} + None => { + let Some(id) = dragged.filter(|id| !stops && self.starts_in_body(ctx, *id)) else { return }; + let route = BodyRoute { area, owner: viewport, window, id, desktop: self.desktop(), target: None, ended: false }; + let _ = body_input(ctx).lock().routes.insert(area, route); + } + } + } + + /// Whether egui's drag of `id` in the window drawing now is the app's and carries something: + /// pressed with the primary button in a panel body, none of the area's own controls or + /// drags, and with an egui drag-and-drop payload or [`crate::docking::route_app_drag`] + /// called this frame or the last. Other drags (sliders, scrollbars, text selection, canvas + /// pans) keep the owner's pointer. + fn starts_in_body(&self, ctx: &Context, id: Id) -> bool { + let payload = ctx.plugin_opt::().is_some() && egui::DragAndDrop::has_any_payload(ctx); + let main = ctx.cumulative_pass_nr_for(ViewportId::ROOT); + let asked = ctx.data(|d| d.get_temp::(app_drag_key(self.id))).is_some_and(|at| at + 1 >= main); + if !payload && !asked { + return false; + } + let (area, viewport) = (self.id, ctx.viewport_id()); + let (down, press) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.press_origin())); + let (pressed, owned, dock) = ctx.data(|d| { + let pressed = d.get_temp::(body_press_key(area, viewport)); + let owned = d.get_temp::>(area.with("owned-interactions")).is_some_and(|ids| ids.contains(&id)) + || d.get_temp::(area.with("pointer-resize")) == Some(id); + let dock = d.get_temp::>(area.with("drag")).is_some() + || d.get_temp::>(area.with("group-drag")).is_some() + || d.get_temp::(area.with("window-drag")).is_some(); + (pressed, owned, dock) + }); + down && press.is_some() && pressed == press && !owned && !dock && !self.owned_interactions.contains(&id) + } +} + +/// The main window's pass in which the app last called [`crate::docking::route_app_drag`]. +fn app_drag_key(area: Id) -> Id { + area.with("route-app-drag") +} + +/// [`crate::docking::route_app_drag`]. +pub(in crate::docking) fn mark_app_drag(ctx: &Context, area: Id) { + let main = ctx.cumulative_pass_nr_for(ViewportId::ROOT); + ctx.data_mut(|d| d.insert_temp(app_drag_key(area), main)); +} + +/// The events held for `viewport`'s input in a later pass. +#[cfg(test)] +pub(in crate::docking) fn held_events(ctx: &Context, viewport: ViewportId) -> Vec { + let body = body_input(ctx); + let events = body.lock().held.get(&viewport).map(|(_, events)| events.clone()); + events.unwrap_or_default() +} + +/// Where the last press in one of the area's panel bodies in `viewport` began. +pub(super) fn body_press_key(area: Id, viewport: ViewportId) -> Id { + area.with(("body-press", viewport)) +} diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index 4516da33..65cf3337 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -104,6 +104,8 @@ pub(in crate::docking) struct Cross

{ pub held: bool, /// The panel whose body is under the pointer in the native window drawing now. pub pointed: Option

, + /// The viewport of each native window drawn this frame. + pub viewports: Vec<(u64, egui::ViewportId)>, } impl

Default for Cross

{ @@ -122,6 +124,7 @@ impl

Default for Cross

{ docks: false, held: false, pointed: None, + viewports: Vec::new(), } } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index de94342f..ebfdd627 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -6,6 +6,8 @@ use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions, dock_model::Place}; +#[cfg(feature = "native-windows")] +mod body_drag; #[cfg(feature = "native-windows")] mod cross; mod dock; @@ -22,9 +24,46 @@ pub(in crate::docking) use { native::{APPKIT_CORNER, MACOS_CLOSE, Session, appkit_resize_areas, close_rects, floats_shown, resize_bands, session, wayland}, }; +#[cfg(all(test, feature = "native-windows"))] +pub(in crate::docking) use body_drag::held_events; +#[cfg(feature = "native-windows")] +pub(in crate::docking) use body_drag::{cancel_body_route, mark_app_drag}; #[cfg(feature = "native-windows")] pub(in crate::docking) use native::{InputHook, set_input_hook}; +/// How long after blocking starts a native window that still has the keyboard hands it to the +/// main window: an OS dialog the app opens with the modal (a file dialog) takes focus a little +/// later, and a Focus sent before then could raise the main window over it. +#[cfg(feature = "native-windows")] +const BLOCKED_FOCUS_WAIT: f64 = 0.2; + +/// While input is blocked (`starts`: from this frame), a native window of the dock area `area` +/// with the keyboard would drop the keys the modal needs, so once, after +/// [`BLOCKED_FOCUS_WAIT`], the main window, where the modal is, takes the keyboard if one still +/// has it. If focus went elsewhere meanwhile, such as to an OS dialog, nothing is sent. +#[cfg(feature = "native-windows")] +pub(in crate::docking) fn focus_main_from_native

(ctx: &Context, area: Id, layout: &Layout

, blocked: bool, starts: bool) { + let (key, now) = (area.with("blocked-focus"), ctx.input(|i| i.time)); + if !blocked { + ctx.data_mut(|d| d.remove::(key)); + return; + } + if starts { + ctx.data_mut(|d| d.insert_temp(key, now)); + } + let Some(since) = ctx.data(|d| d.get_temp::(key)) else { return }; + if now - since < BLOCKED_FOCUS_WAIT { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(BLOCKED_FOCUS_WAIT - (now - since))); + return; + } + ctx.data_mut(|d| d.remove::(key)); + let focused = + ctx.input(|i| layout.floating.iter().any(|w| i.raw.viewports.get(&native_viewport(area, w.id)).and_then(|v| v.focused) == Some(true))); + if focused { + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); + } +} + /// The viewport of the native window `window` of the dock area `area`. #[cfg(feature = "native-windows")] pub(in crate::docking) fn native_viewport(area: Id, window: u64) -> egui::ViewportId { @@ -370,6 +409,11 @@ impl Renderer<'_, P> { // Any button: a canvas pans with the middle button and opens menus with the secondary. if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { self.output.focused = Some(panel.clone()); + #[cfg(feature = "native-windows")] + if let Some(press) = child.input(|i| i.pointer.press_origin()) { + let key = body_drag::body_press_key(self.id, child.ctx().viewport_id()); + child.ctx().data_mut(|d| d.insert_temp(key, press)); + } } #[cfg(feature = "native-windows")] if child.rect_contains_pointer(rect) { diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 0a6e6d33..a2e1ce61 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -5,9 +5,10 @@ //! application is activated. Where egui embeds viewports (wasm, tests) windows stay //! in-window at their in-window rect, and so do they on Wayland, unless the app turns native //! windows on ([`crate::docking::DockArea::native_windows`]). Tabs, groups and whole windows -//! drag between OS windows (see `cross.rs`). +//! drag between OS windows (see `cross.rs`), and so do the app's drags in panel bodies +//! (`body_drag.rs`). -use std::{fmt::Debug, hash::Hash}; +use std::{fmt::Debug, hash::Hash, sync::Arc}; use egui::{ Color32, Context, CursorIcon, Event, Id, Key, LayerId, Modifiers, Order, Pos2, RawInput, Rect, ResizeDirection, Sense, Ui, UiBuilder, Vec2, @@ -15,6 +16,7 @@ use egui::{ }; use super::Renderer; +use super::body_drag::{BodyRoute, Held, Queued}; use super::cross::{DragPointer, Surface}; use crate::docking::chrome::{ChromeStyle, TitleBar}; pub(in crate::docking) use crate::docking::placement::BAR_HEIGHT; @@ -159,7 +161,7 @@ fn differs(a: [f32; 4], b: [f32; 4]) -> bool { /// Desktop position of a viewport's content, and its offset from the window's outer /// position, where the window system reports both. -fn content_of(info: &ViewportInfo) -> Option<(Pos2, Vec2)> { +pub(super) fn content_of(info: &ViewportInfo) -> Option<(Pos2, Vec2)> { let (outer, inner) = (info.outer_rect?, info.inner_rect?); (outer.min.is_finite() && inner.min.is_finite()).then(|| (inner.min, inner.min - outer.min)) } @@ -251,7 +253,9 @@ impl Renderer<'_, P> { let origin = self.cross.main.map(|m| m.min); self.record_pointer(ctx, None, origin, None); } + self.cross.viewports = natives.iter().map(|(w, _, viewport)| (w.id, *viewport)).collect(); } + self.map_body_route(ctx, native); for (window, rect, viewport) in natives { if let Some(key) = self.native_window(ctx, window, rect, viewport, visible) { shown.push(key); @@ -546,6 +550,7 @@ impl Renderer<'_, P> { } } else { self.paint_cross(ui, &mut surface); + self.route_body_drag(ui.ctx(), Some(window.id)); self.forward_events(ui); self.report_files(ui, window, viewport); } @@ -801,14 +806,40 @@ impl Renderer<'_, P> { } /// Input hooks of dock areas for their native windows, run by egui before it reads each -/// window's input. eframe runs `App::raw_input_hook` for the main window only. +/// window's input. eframe runs `App::raw_input_hook` for the main window only. App drags in +/// panel bodies cross windows here too (`body_drag.rs`). #[derive(Default)] -struct NativeInput { +pub(super) struct NativeInput { hooks: std::collections::HashMap>, /// The dock area of each native window shown this frame. windows: std::collections::HashMap, } +/// Each dock area's app drag in a panel body, and the releases waiting for a window's next +/// input. Kept outside [`NativeInput`]: egui calls [`egui::Plugin::input_hook`] with the plugin +/// locked, and an app's input hook may call [`crate::docking::cancel_drag`], which must reach +/// the routes without locking the plugin again. The lock here is dropped before that hook runs. +#[derive(Default)] +pub(super) struct NativeBodyInput { + /// Each dock area's app drag in a panel body. + pub routes: std::collections::HashMap, + /// Pointer events routed to each window, for its next input. + pub queued: Queued, + /// Owners' held releases, for their input in a later main-window pass. + pub held: Held, + /// The main window's passes so far. + pub pass: u64, +} + +/// The app drag routing and routed events shared by every native input hook of `ctx`. +pub(super) fn body_input(ctx: &Context) -> Arc> { + ctx.data_mut(|data| data.get_temp_mut_or_default::>>(body_input_key()).clone()) +} + +fn body_input_key() -> Id { + Id::unique("craft-ui docking native body input") +} + pub(in crate::docking) type InputHook = Box; impl egui::Plugin for NativeInput { @@ -817,6 +848,38 @@ impl egui::Plugin for NativeInput { } fn input_hook(&mut self, ctx: &Context, input: &mut RawInput) { + let viewport = input.viewport_id; + let body = body_input(ctx); + let mut routed = false; + { + let mut body = body.lock(); + let body = &mut *body; + if viewport == ViewportId::ROOT { + body.pass += 1; + // Every window draws in the main window's pass: events for one that has not + // drawn since the last pass are for a window that is gone. + let pass = body.pass; + body.queued.retain(|_, (at, _)| *at + 1 >= pass); + body.held.retain(|_, (at, _)| *at + 1 >= pass); + } + // Taken before routing, which may queue more for this window's next input. A held + // release waits for a later main-window pass: a discarded pass reruns within its own. + let waiting = body.queued.remove(&viewport); + let held = if body.held.get(&viewport).is_some_and(|(at, _)| *at < body.pass) { body.held.remove(&viewport) } else { None }; + for route in body.routes.values_mut() { + routed |= route.route(input, &mut body.queued, &mut body.held, body.pass); + } + // Before the window's own events: they happened earlier, and a held release must + // not end a press that follows it. + let earlier = + held.into_iter().flat_map(|(_, events)| events).chain(waiting.into_iter().flat_map(|(_, events)| events.into_iter().map(|(_, e)| e))); + input.events.splice(0..0, earlier); + } + if routed { + ctx.request_repaint_of(ViewportId::ROOT); + } + // After the routing lock is dropped: the app's hook may call `cancel_drag`, which locks + // the same body state through egui's temporary data, not this plugin. if let Some(hook) = self.windows.get(&input.viewport_id).and_then(|area| self.hooks.get(area)) { (hook.lock())(ctx, input); } @@ -826,7 +889,7 @@ impl egui::Plugin for NativeInput { /// Set or clear the input hook of the dock area `area` for this frame, and forget its native /// windows until they show again. pub(in crate::docking) fn set_input_hook(ctx: &Context, area: Id, hook: Option) { - if hook.is_some() && ctx.plugin_opt::().is_none() { + if ctx.plugin_opt::().is_none() { ctx.add_plugin(NativeInput::default()); } ctx.with_plugin(|input: &mut NativeInput| { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 3e994ebc..c1af45d2 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -702,7 +702,8 @@ pub fn is_dragging(ctx: &Context, area_id: Id) /// Discard pending tab/group gestures, a native window's bar drag and an actively dragged /// renderer-owned control, in the main window now and in a native window when it next draws. -/// Body/custom-header/menu gestures and other areas are untouched. +/// Body/custom-header/menu gestures and other areas are untouched, except that an app drag in a +/// panel body no longer reaches other OS windows (feature `native-windows`). /// Ownership is recorded as controls render; before an initial render or drag threshold there /// may be no active egui drag to cancel. Keyboard focus is unchanged. Nothing is restored from /// before the drag: floating and native windows go where the next layout drawn has them. @@ -730,6 +731,21 @@ pub fn cancel_drag(ctx: &Context, area_id: Id) if owned { ctx.stop_dragging(); } + #[cfg(feature = "native-windows")] + super::render::cancel_body_route(ctx, area_id); +} + +/// Mark the app's drag in a panel body of the area `area_id` as one that carries something, so +/// it reaches the window under the pointer (feature `native-windows`; elsewhere it does +/// nothing). A drag with an egui drag-and-drop payload needs no call. Call it each frame while +/// the drag lasts, from the panel body or before the area shows; a call starts the routing +/// for this frame and the next, and a routing once started lasts until the drag ends. Drags +/// that are never marked (sliders, scrollbars, text selection, canvas pans) stay in their window. +pub fn route_app_drag(ctx: &Context, area_id: Id) { + #[cfg(feature = "native-windows")] + super::render::mark_app_drag(ctx, area_id); + #[cfg(not(feature = "native-windows"))] + let _ = (ctx, area_id); } /// One mutable application adapter owns bodies, menus, shaped text and per-panel policy, @@ -1120,9 +1136,12 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { // Blocking starts while the pointer may be down: a drag that went on would act on its // release, under the modal. let blocked_key = self.id.with("input-blocked"); - if self.input_blocked && !ui.ctx().data(|d| d.get_temp::(blocked_key).unwrap_or(false)) { + let starts = self.input_blocked && !ui.ctx().data(|d| d.get_temp::(blocked_key).unwrap_or(false)); + if starts { cancel_drag::

(ui.ctx(), self.id); } + #[cfg(feature = "native-windows")] + super::render::focus_main_from_native(ui.ctx(), self.id, layout, self.input_blocked, starts); ui.ctx().data_mut(|d| d.insert_temp(blocked_key, self.input_blocked)); cancel_drag_if_replaced(ui.ctx(), self.id, layout); release_moved_tab_focus(ui.ctx(), self.id, layout); diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 30f6cd86..c793b26d 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1,5 +1,9 @@ # egui 0.37 migration plan for craft-libs +Status update (2026-10-25): local candidate includes main `865c9af7fdf455cd3892402d18e526a9f019c6bc`, preserving current native docking, blocked-window focus and print-copy handling. Native input-callback cancellation no longer re-enters the plugin mutex. Both cancellation regressions reproduced the deadlock before the fix. Current gates passed: 1,774 test executions, zero failures, 21 ignored, plus strict workspace/UI/print lint, UI all-feature tests, native example build and UI WASM check. Independent merge/implementation and compiler-correction reviews passed. App-pinned ancestors 25da1314 and 650b1c0 remain reachable. Current-source native platforms/GUI/printer hardware and paired performance remain open; the attempted paired run stopped before measurement due to a collector metadata-projection bug. Publication needs separate explicit approval; human review and human merge are required. + +The original planning record below is historical. + Status: final landing integration, 2026-10-25. The reviewed shared UI includes screenshot completion, shaped-text alignment, keymap support and regression coverage. The manifest and lock select egui `6d518fc799681571963842f95b55a6b68431b15a` and winit `a517b3846ee8457d84ee4975110bfda6797c44dc` through canonical GitHub URLs. The integration of migration ancestor `25da1314d443171b4adeaad2cd9454b273f35c6f` with main `2ab6049d9031bc8fae7f7df669b5fccaf5b9966f` passed formatting, strict workspace all-target clippy, workspace tests and the PDF all-feature suite in managed job `egui037-craft-libs-current-main-gates-20261024-2021`. Source and lock hashes were checked against that job before the local integration checkpoint. diff --git a/docs/ui-docking.md b/docs/ui-docking.md index eef8db51..9123eaec 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -491,8 +491,12 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho main window is raised last, in front of the normal windows and of a palette the user clicked. - *Edges.* Turning blocking on cancels a drag in progress, and a widget of a native window - loses keyboard focus. A zoom gesture still shows in egui's `InputState::zoom_delta`, which - cannot be cleared; a widget that zooms only while hovered ignores it. egui smooths wheel + loses keyboard focus. If a native window has the OS keyboard focus then (the modal was + opened from it, say by a double-click in a floating panel) and still has it 0.2 s later, + the main window gets `ViewportCommand::Focus`, so the modal gets the keys. The wait lets an + OS dialog opened with the modal (a file dialog) take focus first; then nothing is sent. A + zoom gesture still shows in egui's `InputState::zoom_delta`, which cannot be cleared; a + widget that zooms only while hovered ignores it. egui smooths wheel scrolling, so a scroll area in a native window may still scroll for about 100 ms after blocking ends, with the remainder of a wheel turn made just before it began. - Floating windows drawn in the main window need nothing: the modal covers them. @@ -511,7 +515,8 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho shown panel, since the window system may send no pointer moves during a file drag), the pointer's last position in the window, and the window's `hovered_files` and `dropped_files`. Files on the main window stay in its own `RawInput`. -- **Drags.** Tabs, groups and whole windows drag between the app's OS windows. +- **Drags.** Tabs, groups and whole windows drag between the app's OS windows, and the app's + own drags in panel bodies reach the window under the pointer. - *Ownership.* The window where the button went down owns the drag: the window system sends it the pointer's moves and the release while the button is held, inside the window or not. So the pointer leaving a window (`PointerGone`) is not a release. A drag ends on release, @@ -554,6 +559,41 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho works inside its own window, and a release outside it floats a new window that the compositor places. Moving a window hands the move to the compositor, which never docks it. + - *App drags in panel bodies.* A drag the app starts in a panel body can reach the window + under the pointer, so a drop site there works as it does inside one window. + - *Which drags.* An egui drag of one of the app's widgets, pressed with the primary button + in a panel body, that carries something: egui's `DragAndDrop` holds a payload + (`dnd_set_drag_payload`), or the app calls `docking::route_app_drag(ctx, area_id)` each + frame while the drag lasts, from the body or before the area shows. An app that keeps its + payload in the context's data makes that call. Other drags (sliders, scrollbars, text + selection, canvas pans) stay in their window and keep getting its pointer. A call counts + for its frame and the next; once routing starts, it lasts until the drag ends. + - *Events.* While the drag lasts, the owner's pointer moves over another of the app's + windows (found as for tab drags, front window first) go to that window in its own + coordinates: `PointerMoved` while the pointer is over it, `PointerGone` when it leaves, + and the release as a primary `PointerButton` with `pressed: false`. That window sees + `pointer_hover_pos` and `any_released` as if the window system had sent them. It sees no + press, so `any_down` stays false there and nothing in it is clicked. The owner sees the + pointer gone while it is over another window, so its panels under a palette see no hover + and no drop. + - *Input path.* Events go in through the area's egui input plugin, before any widget reads + the input; the app calls nothing more. In a native window this runs before the app's + `native_input_hook`. eframe runs the main window's `App::raw_input_hook` earlier, so that + hook sees the main window's input without the routed events. + - *Release timing.* A window drawn after the owner in the same pass gets the events in that + pass (from the main window, every native window does). The main window, and a native + window drawn before the owner, get them on the next pass, which is requested. When the + release goes to another window, the owner gets its own release, and the pointer events + that came after it, in the main window's next pass (a rerun of a discarded pass does not + count), so its drag stops (`drag_stopped`) then. egui clears a `DragAndDrop` payload when the owner sees the + release, so the target always reads its release while the payload is there. An app that + keeps its own payload clears it no earlier than the owner's release. + - *Limits.* Nothing is routed where desktop positions are unknown (Wayland, + `native_windows(false)`, embedded viewports) or while input is blocked. Escape in any of + the app's windows, a focus loss, `cancel_drag` and a replaced layout stop the routing: + the window under the pointer sees it go and gets no release it has not read yet. The drag + goes on in its own window until the button comes up, and the owner still gets its + release. On the web and wherever egui embeds viewports, nothing changes: floating windows stay in the page at their in-window rect, `native` and the monitor fields are kept but unused, the input