egui037: preserve current docking and printing and prevent input-hook deadlock

This commit is contained in:
skylartaylor committed 2026-10-25 04:00:32 +01:00
commit 6fe4139998
17 files changed
+1749 -89

No files matched your search

+15 -4
View File
@@ -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
+4 -2
View File
@@ -483,7 +483,9 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result<String, Error> {
/// [`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<String> {
Scaling::ShrinkToFit => "shrink-to-fit",
}
),
format!("color={}", if job.grayscale { "monochrome" } else { "color" }),
format!("color={}", if job.grayscale { "monochrome" } else { "(driver default)" }),
]
}
+203 -28
View File
@@ -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<Arc<Obj>>,
/// `/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<Sheet>,
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<Arc<Obj>>,
crop: Option<Arc<Obj>>,
rotate: Option<Arc<Obj>>,
resources: Option<Arc<Obj>>,
}
/// Read the sheets of a print-ready PDF.
@@ -905,10 +915,14 @@ pub(crate) fn read(data: &[u8]) -> Result<Sheets<'_>, 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<Sheets<'_>, 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<Sheets<'_>, 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<Vec<
Ok(edits)
}
/// The whole document printed `copies` times in one job, for collated copies the driver can't
/// make: each copy's pages follow the previous copy's. On two-sided paper, a document with an odd
/// number of sheets gets a blank page after every copy but the last, so each copy starts on a
/// sheet of its own.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Repeat {
pub(crate) copies: u32,
pub(crate) two_sided: bool,
}
fn has_annotations(dict: &Dict) -> 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<u8>, 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<Vec<u8>, 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<Option<Vec<u8>>, Error> {
self.write(edits, Some(repeat))
}
fn write(&self, edits: &[Edit], repeat: Option<Repeat>) -> Result<Option<Vec<u8>>, 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<u8>, 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<u8>> = 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))
}
}
+1 -1
View File
@@ -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)"]
);
}
}
+135
View File
@@ -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<u8> {
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<u8> {
classic(&three_page_objects(extra))
}
fn three_page_objects(extra: &str) -> Vec<String> {
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<Vec<u8>> {
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<Option<Obj>> = 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<String> = (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);
}
+8 -1
View File
@@ -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() });
+269 -19
View File
@@ -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 <psf:Feature name=\"psk:PageOutputColor\"><psf:Option name=\"psk:Monochrome\"/></psf:Feature>" } else { "" };
let copies = job.copies.clamp(1, 999);
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<psf:PrintTicket version="1" xmlns:psf="{NS_PSF}" xmlns:psk="{NS_PSK}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<psf:ParameterInit name="psk:JobCopiesAllDocuments"><psf:Value xsi:type="xsd:integer">{copies}</psf:Value></psf:ParameterInit>
<psf:Feature name="psk:DocumentCollate"><psf:Option name="psk:{collate}"/></psf:Feature>{duplex}
<psf:Feature name="psk:PageOutputColor"><psf:Option name="psk:{color}"/></psf:Feature>
<psf:Feature name="psk:DocumentCollate"><psf:Option name="psk:{collate}"/></psf:Feature>{duplex}{color}
<psf:Feature name="psk:PageScaling"><psf:Option name="psk:None"/></psf:Feature>
<psf:Feature name="psk:PageOrientation"><psf:Option name="psk:{orientation}"/></psf:Feature>
</psf:PrintTicket>"#
@@ -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<bool>,
/// The colour feature (`PageOutputColor`) has an option other than Monochrome and Grayscale.
pub color: Option<bool>,
/// The collation feature (`DocumentCollate`) has a Collated option.
pub collate: Option<bool>,
}
/// 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<bool> {
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<roxmltree::Node<'a, 'i>> {
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!("<psf:Feature name=\"psk:{feature}\"")).unwrap();
let end = start + xml[start..].find("</psf:Feature>").unwrap() + "</psf:Feature>".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("<not xml", &default, 1).is_err());
assert!(verify_job_options(&ticket_xml(&simplex, false), &simplex, 3, Offered::default()).is_ok());
assert!(verify_job_options(&xml, &simplex, 3, Offered::default()).is_err(), "explicit simplex must be confirmed");
assert!(verify_job_options("<not xml", &default, 1, Offered::default()).is_err());
}
/// PrintCapabilities as a driver might write them: with or without a duplex feature, and the
/// colour and collation options given (none: no such feature).
fn capabilities_xml(duplex: bool, colors: &[&str], collations: &[&str]) -> String {
let feature = |name: &str, options: &[&str]| {
if options.is_empty() {
return String::new();
}
let options: String = options.iter().map(|o| format!(r#"<psf:Option name="{o}"/>"#)).collect();
format!(r#"<psf:Feature name="psk:{name}">{options}</psf:Feature>"#)
};
let duplex = feature("JobDuplexAllDocumentsContiguously", if duplex { &["psk:OneSided", "psk:TwoSidedLongEdge"] } else { &[] });
format!(
r#"<psf:PrintCapabilities version="1" xmlns:psf="{NS_PSF}" xmlns:psk="{NS_PSK}" xmlns:ns0000="http://example.com/vendor">
{}{duplex}{}
</psf:PrintCapabilities>"#,
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!("<psf:Feature name=\"psk:{feature}\"")).unwrap();
let end = start + xml[start..].find("</psf:Feature>").unwrap() + "</psf:Feature>".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("<psf:PrintCapabilities xmlns:psf=\"x\"/>"), Offered::default(), "another namespace is not capabilities");
assert_eq!(super::offered("<not xml"), Offered::default());
let empty = format!(
r#"<psf:PrintCapabilities xmlns:psf="{NS_PSF}"><psf:Feature name="psk:JobDuplexAllDocumentsContiguously"/></psf:PrintCapabilities>"#
);
assert_eq!(super::offered(&empty).duplex, Some(false), "a feature with no option offers nothing");
let document_duplex = format!(
r#"<psf:PrintCapabilities xmlns:psf="{NS_PSF}" xmlns:psk="{NS_PSK}"><psf:Feature name="psk:DocumentDuplex"><psf:Option name="psk:OneSided"/><psf:Option name="psk:TwoSidedLongEdge"/></psf:Feature></psf:PrintCapabilities>"#
);
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("</psf:PrintTicket>", &format!("{feature}</psf:PrintTicket>"));
let n_up = |n: u32| {
with(&format!(
r#"<psf:Feature name="psk:JobNUpAllDocumentsContiguously"><psf:Option><psf:ScoredProperty name="psk:PagesPerSheet"><psf:Value xsi:type="xsd:integer">{n}</psf:Value></psf:ScoredProperty></psf:Option></psf:Feature>"#
))
};
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#"<psf:Feature name="psk:{feature}"><psf:Option name="psk:{option}"/></psf:Feature>"#));
assert_eq!(one_job_sides(&finishing("None")), Some(true), "{feature}");
assert_eq!(one_job_sides(&finishing("StapleTopLeft")), None, "{feature}");
}
}
}
+101 -22
View File
@@ -45,6 +45,15 @@ fn two_sided(device: &PrinterDevice) -> Option<bool> {
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<PrinterDevice>, requested: Option<&str>, default: Option<&str>) -> Result<PrinterDevice, Error> {
@@ -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<String> {
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");
}
}
+1 -1
View File
@@ -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;
+538
View File
@@ -37,6 +37,41 @@ struct Content {
input_probe: Option<u32>,
events: Vec<Event>,
raw_events: Vec<Event>,
/// The panel with an app drag source, and where it was drawn.
source: Option<u32>,
/// How the source marks its drag as one that carries something.
marks: Marks,
source_rect: Option<Rect>,
/// 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<u32>,
hovers: Vec<(u32, Pos2)>,
drops: Vec<(u32, Pos2)>,
/// Drop sites whose release found egui's drag-and-drop payload.
payload_drops: Vec<u32>,
}
/// 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<u32> for Content {
@@ -59,6 +94,41 @@ impl DockContent<u32> 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::<u32>(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<u32, Content>| {
d.host().commands.iter().filter(|(v, c)| *v == egui::ViewportId::ROOT && matches!(c, ViewportCommand::Focus)).count()
};
let steps = |d: &mut DockDriver<u32, Content>, 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<u32>, source: u32, sites: &[u32]) -> DockDriver<u32, Content> {
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<u32, Content>) -> 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::<u32>(&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<u32, Content>) {
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::<u32>(&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::<u32>(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::<u32>(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());
}
+290
View File
@@ -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<Rect>,
/// 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<u64>,
id: Id,
desktop: Desktop,
/// The window other than the owner that the pointer is over.
target: Option<ViewportId>,
/// 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<ViewportId, (u64, Vec<(Id, Event)>)>;
/// 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<ViewportId, (u64, Vec<Event>)>;
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<ViewportId> = 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<u64>) {
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::<egui::DragAndDrop>().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::<u64>(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::<Pos2>(body_press_key(area, viewport));
let owned = d.get_temp::<Vec<Id>>(area.with("owned-interactions")).is_some_and(|ids| ids.contains(&id))
|| d.get_temp::<Id>(area.with("pointer-resize")) == Some(id);
let dock = d.get_temp::<Drag<P>>(area.with("drag")).is_some()
|| d.get_temp::<GroupDrag<P>>(area.with("group-drag")).is_some()
|| d.get_temp::<WindowDrag>(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<Event> {
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))
}
+3
View File
@@ -104,6 +104,8 @@ pub(in crate::docking) struct Cross<P> {
pub held: bool,
/// The panel whose body is under the pointer in the native window drawing now.
pub pointed: Option<P>,
/// The viewport of each native window drawn this frame.
pub viewports: Vec<(u64, egui::ViewportId)>,
}
impl<P> Default for Cross<P> {
@@ -122,6 +124,7 @@ impl<P> Default for Cross<P> {
docks: false,
held: false,
pointed: None,
viewports: Vec::new(),
}
}
}
+44
View File
@@ -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<P>(ctx: &Context, area: Id, layout: &Layout<P>, blocked: bool, starts: bool) {
let (key, now) = (area.with("blocked-focus"), ctx.input(|i| i.time));
if !blocked {
ctx.data_mut(|d| d.remove::<f64>(key));
return;
}
if starts {
ctx.data_mut(|d| d.insert_temp(key, now));
}
let Some(since) = ctx.data(|d| d.get_temp::<f64>(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::<f64>(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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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) {
+69 -6
View File
@@ -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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> 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<Id, egui::mutex::Mutex<InputHook>>,
/// The dock area of each native window shown this frame.
windows: std::collections::HashMap<ViewportId, Id>,
}
/// 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<Id, BodyRoute>,
/// 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<egui::mutex::Mutex<NativeBodyInput>> {
ctx.data_mut(|data| data.get_temp_mut_or_default::<Arc<egui::mutex::Mutex<NativeBodyInput>>>(body_input_key()).clone())
}
fn body_input_key() -> Id {
Id::unique("craft-ui docking native body input")
}
pub(in crate::docking) type InputHook = Box<dyn FnMut(&Context, &mut RawInput) + Send>;
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<InputHook>) {
if hook.is_some() && ctx.plugin_opt::<NativeInput>().is_none() {
if ctx.plugin_opt::<NativeInput>().is_none() {
ctx.add_plugin(NativeInput::default());
}
ctx.with_plugin(|input: &mut NativeInput| {
+21 -2
View File
@@ -702,7 +702,8 @@ pub fn is_dragging<P: Clone + Send + Sync + 'static>(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<P: Clone + Send + Sync + 'static>(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::<bool>(blocked_key).unwrap_or(false)) {
let starts = self.input_blocked && !ui.ctx().data(|d| d.get_temp::<bool>(blocked_key).unwrap_or(false));
if starts {
cancel_drag::<P>(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);
+4
View File
@@ -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.
+43 -3
View File
@@ -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