mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 07:39:23 +00:00
egui037: preserve current docking and printing and prevent input-hook deadlock
This commit is contained in:
commit
6fe4139998
17 files changed
+1749
-89
No files matched your search
+15
-4
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user