diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index f8399e3c3b..7c715bed8f 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -91,7 +91,15 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut clip: ui.clip_rect(), }; if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { - let at = hits.at(view, o); + // A press with Shift or ⌘ in Edit text is the objects', whatever lies under it + // (`object_ui::modified_hit`): the page's objects are read for it alone. + let adds = tool == QuickTool::EditText + && can_modify + && crate::object_ui::adds(ptr.modifiers) + && !resp.context_menu_opened() + && !egui::Popup::is_any_open(ui.ctx()); + let objects = adds.then(|| view.objects.inventory(doc, i)); + let at = hits.at(view, o, objects.as_ref()); view.router.press(i, at, tool); } // What this page has to do with the press: its owner's, the active tool's, or another page's. @@ -99,7 +107,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // What lies under the pointer before the tools act, on the frames that need it: for a click's // release, a right-click's tool checks, and Fill & Sign's preview. let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; - let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p)).unwrap_or_default(); + let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p, None)).unwrap_or_default(); let released_over = match press { PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, @@ -135,7 +143,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut }; crate::redact_ui::page_input(cx, over_text, press == PagePress::Free, view) }; - let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); + let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, press, view); // Add text acts on the page's own paragraphs over them, and on the one its press was on until // the button is up: a drag that leaves the box still ends, and no new box takes it over. let over_source = tool == QuickTool::AddText @@ -217,7 +225,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut /// for hover painting. With `claim` (nothing drawn over the pages, such as the palette), its cursor /// and tip too, after the tools claimed theirs (the last claim on a layer stands). fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2, claim: bool) -> Option { - let under = hits.at(view, p); + let under = hits.at(view, p, None); if claim { for h in &under { view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); @@ -411,9 +419,10 @@ struct PageHits<'a> { } impl PageHits<'_> { - /// The hits at `p`, bottom up ([`Self::hits`]). - fn at(&self, view: &DocView, p: Pos2) -> Vec { - self.hits(view, p, false) + /// The hits at `p`, bottom up ([`Self::hits`]). For a press with Shift or ⌘ in Edit text, the + /// page's `objects` ([`crate::object_ui::ObjectSelection::inventory`]), which take it whole. + fn at(&self, view: &DocView, p: Pos2, objects: Option<&Result, String>>) -> Vec { + self.hits(view, p, false, objects) } /// What a right-click at `p` is on: what lies on top there, as for a press, but with every @@ -429,17 +438,24 @@ impl PageHits<'_> { { return Some(press.owner); } - top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner) + top(self.hits(view, p, true, None), QuickTool::Select).map(|h| h.owner) } /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text /// on top of artwork takes the press), added content, the objects selected together and the /// open paragraph's frame, the selected picture's grips over them all; the camera tool's corners; /// then form fields (Prepare's, being moved, over the comments; filled ones under added content - /// and the comments that move). In crop mode, only the picture being cropped. + /// and the comments that move). For a press with Shift or ⌘ in Edit text, only the objects + /// (`objects`, the page's); in crop mode, only the picture being cropped. /// For a right-click (`context`), comments by [`comments::context_hit`], and no camera corner (it /// has no menu). - fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec { + fn hits( + &self, + view: &DocView, + p: Pos2, + context: bool, + objects: Option<&Result, String>>, + ) -> Vec { let PageHits { page, xf, @@ -459,6 +475,10 @@ impl PageHits<'_> { images, clip, } = *self; + // A press with Shift or ⌘ is the objects', crop mode's page included (`object_ui::modified_hit`). + if let Some(objects) = objects { + return crate::object_ui::modified_hit(view, xf, info, page, objects, clip, p).into_iter().collect(); + } // Crop mode holds every press on its page (`edit_text_ui::crop_hit`). if tool == QuickTool::EditText && can_modify && view.image_selection.as_ref().is_some_and(|s| s.page == page && s.crop.is_some()) { return crate::edit_text_ui::crop_hit(xf, page, info, images, view, p).into_iter().collect(); diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index 38b028de24..e847009dd0 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -11,7 +11,7 @@ //! Dialogs are drawn after the pages, so on the frame one opens egui has not met its layer yet: //! the reading also takes whether one is open. -use egui::{Event, PointerButton, Pos2, Rect}; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect}; /// A primary-button press or release, and where it happened. #[derive(Clone, Copy, Debug, PartialEq)] @@ -46,13 +46,16 @@ pub(crate) struct PagePointer { pub primary_down: bool, /// Some button is down, wherever it was pressed. pub any_down: bool, + /// The modifier keys held at this frame's first primary press, or else now: a press with Shift + /// or ⌘ adds to a selection. + pub modifiers: Modifiers, } impl PagePointer { /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`, while /// a dialog is open over the window or not (`modal`). pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response, modal: bool) -> Self { - let (at, origin, edges, released, primary_down, any_down) = ui.input(|i| { + let (at, origin, edges, released, primary_down, any_down, modifiers) = ui.input(|i| { ( i.pointer.interact_pos(), i.pointer.press_origin(), @@ -60,6 +63,7 @@ impl PagePointer { last_release(&i.events), i.pointer.primary_down(), i.pointer.any_down(), + press_modifiers(&i.events).unwrap_or(i.modifiers), ) }); // egui's own answer to "do the pages hold a press?": from the press on until the frame @@ -75,9 +79,9 @@ impl PagePointer { resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p)))); let pos = at.filter(|p| held || on_top(p)); if !held { - return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down }; + return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down, modifiers }; } - Self { pos, origin: press_origin, edges, released, primary_down, any_down } + Self { pos, origin: press_origin, edges, released, primary_down, any_down, modifiers } } /// Where the pointer is, when it is inside `r`. @@ -108,6 +112,14 @@ fn first_press(edges: &[Edge]) -> Option { }) } +/// The modifier keys held at the first primary press among `events`. +fn press_modifiers(events: &[Event]) -> Option { + events.iter().find_map(|e| match e { + Event::PointerButton { button: PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), + _ => None, + }) +} + /// Where the last primary release among `events` was, however many came before it. fn last_release(events: &[Event]) -> Option { events.iter().rev().find_map(|e| match e { diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 3f5d1e105f..ee30ca98c6 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -573,7 +573,7 @@ const ROUTER_READS: [&str; 6] = /// page and tells each module what the press is to its page (`PagePress`); the Edit text group, the /// text layer and link following still ask, until they are told too (PR 4b), and an unused entry /// fails the test. -const ROUTER_READERS: [(&str, &str, &str); 8] = [ +const ROUTER_READERS: [(&str, &str, &str); 7] = [ ( "canvas/page_input.rs", "page_tool_input", @@ -585,7 +585,6 @@ const ROUTER_READERS: [(&str, &str, &str); 8] = [ ("tools/content_ui.rs", "page_input", "added text and pictures, until they are told (4b)"), ("tools/edit_text_ui.rs", "page_input", "Edit text's paragraphs, until they are told (4b)"), ("tools/edit_text_ui/image.rs", "image_input", "Edit text's pictures, until they are told (4b)"), - ("tools/object_ui.rs", "input", "the objects selected together, until they are told (4b)"), ]; /// What ignores the press the pages hold until the button is up. @@ -597,7 +596,7 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 12] = [ +const PAGE_HIT_CALLS: [&str; 13] = [ "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", @@ -609,6 +608,7 @@ const PAGE_HIT_CALLS: [&str; 12] = [ "edit_text_ui::picture_handle_hit(", "edit_text_ui::crop_hit(", "object_ui::hit(", + "object_ui::modified_hit(", "content_ui::hit(", ]; @@ -695,12 +695,9 @@ fn page_modules_act_on_a_press_only_through_the_router() { "no longer ignores a press: remove it from PRESS_IGNORERS.", )); let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - for (file, function) in [ - ("tools/content_ui.rs", "page_input"), - ("tools/edit_text_ui.rs", "page_input"), - ("tools/edit_text_ui/image.rs", "image_input"), - ("tools/object_ui.rs", "input"), - ] { + for (file, function) in + [("tools/content_ui.rs", "page_input"), ("tools/edit_text_ui.rs", "page_input"), ("tools/edit_text_ui/image.rs", "image_input")] + { let source = std::fs::read_to_string(src.join(file)).expect("page input module"); let body = source.lines().skip_while(|line| declares(line) != Some(function)).take_while(|line| *line != "}").collect::>().join("\n"); if !routed_press_handler(&body) { diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 45162666ce..06e32a272e 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -10,6 +10,7 @@ //! the active tool's, or none of its business. use egui::CursorIcon; +use pdfcraft_engine::ObjectTarget; use crate::QuickTool; use crate::page_cursor::Layer; @@ -73,6 +74,15 @@ pub(crate) enum ObjectsPart { Selection, /// The frame drawn around the paragraph open for typing: a click selects the box, a drag moves it. Frame, + /// A press with Shift or ⌘ (Ctrl): it adds the object under it to the objects selected + /// together, or takes it out of them, or on none starts an area selection that adds to them. + Toggle(Option), + /// A press with Shift or ⌘ while text being typed is open (a new box, or a paragraph with + /// changes), which must be finished or discarded first: it is held until the button is up, and + /// selects nothing. + Held, + /// A press with Shift or ⌘ on a page whose objects can't be selected together: it says so. + Refused, } /// The part of a paragraph a hit is on. diff --git a/crates/ui-egui/src/tools/object_ui.rs b/crates/ui-egui/src/tools/object_ui.rs index 67a077bc7b..bc6524c53a 100644 --- a/crates/ui-egui/src/tools/object_ui.rs +++ b/crates/ui-egui/src/tools/object_ui.rs @@ -9,7 +9,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; -use crate::page_router::{Hit, ObjectsPart, Owner}; +use crate::page_router::{Hit, ObjectsPart, Owner, PagePress}; const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -24,7 +24,6 @@ pub(crate) struct ObjectSelection { pending: Option>, pub notice: Option, pub hold_editor: bool, - blocked_page: Option, input_blocked: bool, /// A paragraph (page, block) to select once the document is at hand: Esc in the inline /// editor asks for it, and the next `prepare` selects it. @@ -40,10 +39,9 @@ struct Gesture { } impl ObjectSelection { - /// Clear any object-selection gesture and the page it was blocking. + /// Clear any object-selection gesture. pub(crate) fn cancel_pointer_gesture(&mut self) { self.gesture = None; - self.blocked_page = None; } pub fn count(&self) -> usize { @@ -56,7 +54,6 @@ impl ObjectSelection { self.input_blocked = blocked; if blocked { self.gesture = None; - self.blocked_page = None; } } @@ -65,7 +62,6 @@ impl ObjectSelection { self.selected.clear(); self.gesture = None; self.pending = None; - self.blocked_page = None; self.select_paragraph = None; } @@ -139,7 +135,9 @@ impl ObjectSelection { Edit::MoveObjects { page, objects: self.selected.iter().map(|o| o.target).collect(), offset } } - fn inventory(&mut self, doc: &Document, page: usize) -> Result, String> { + /// The objects of `page` that can be selected together, as `doc.editable_objects` lists them, or + /// why they can't be. + pub(crate) fn inventory(&mut self, doc: &Document, page: usize) -> Result, String> { if self.cache.len() >= 8 && !self.cache.contains_key(&page) { self.cache.clear(); } @@ -151,18 +149,9 @@ fn screen(xf: &PageXform, info: &DocInfo, page: usize, object: &EditableObject) xf.user_rect(info, page, object.rect.map(|v| v as f32)) } -fn modified(ui: &egui::Ui) -> bool { - ui.input(|i| { - let modifiers = i - .events - .iter() - .find_map(|e| match e { - egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), - _ => None, - }) - .unwrap_or(i.modifiers); - modifiers.shift || modifiers.ctrl || modifiers.command - }) +/// Whether `modifiers` make a press add to the objects selected together, or take from them. +pub(crate) fn adds(modifiers: egui::Modifiers) -> bool { + modifiers.shift || modifiers.ctrl || modifiers.command } fn single(view: &DocView, page: usize) -> Option { @@ -195,36 +184,84 @@ pub(crate) fn hit(view: &DocView, xf: &PageXform, info: &DocInfo, page: usize, o open.filter(|r| on_frame(r.shrink(2.0), p)).map(|_| Hit::new(Owner::Objects { part: ObjectsPart::Frame }, Layer::Handle, egui::CursorIcon::Move)) } -/// Returns true while group selection owns the interaction. Ordinary clicks still reach the -/// single-object text/resize tools. A text draft is finished or discarded through its editor. -pub(crate) fn page_input(cx: PageToolContext<'_>, view: &mut DocView) -> bool { - let PageToolContext { ui, resp, xf, ptr, page, .. } = cx; +/// The object under `p` among the page's `objects`: added content over text over pictures, so a +/// page-sized background picture does not hide editable labels or added notes, and within each +/// kind the last, as the single-item tools take it. +fn object_at(xf: &PageXform, info: &DocInfo, page: usize, objects: &[EditableObject], p: Pos2) -> Option { + [ObjectKind::Added, ObjectKind::Text, ObjectKind::Image] + .into_iter() + .find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p))) + .map(|o| o.target) +} + +/// What a press with Shift or ⌘ at `p` takes on `page` in Edit text, whatever lies there: the +/// object under it among the page's `objects` ([`ObjectSelection::inventory`]), which it adds to +/// the objects selected together or takes out of them, or none, where it starts an area selection +/// that adds to them. Held while text being typed is open, and refused on a page whose objects +/// can't be selected together. `None` off the page and outside the pages' visible area (`clip`). +pub(crate) fn modified_hit( + view: &DocView, + xf: &PageXform, + info: &DocInfo, + page: usize, + objects: &Result, String>, + clip: Rect, + p: Pos2, +) -> Option { + if !xf.rect.contains(p) || !clip.contains(p) { + return None; + } + let part = if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { + ObjectsPart::Held + } else { + match objects { + Ok(objects) => ObjectsPart::Toggle(object_at(xf, info, page, objects, p)), + Err(_) => ObjectsPart::Refused, + } + }; + Some(Hit::new(Owner::Objects { part }, Layer::Editor, egui::CursorIcon::Default)) +} + +/// The objects selected together, on one page, told what the press is to it (`press`). Returns +/// true while they own the interaction: while input is blocked, while a press with Shift or ⌘ is +/// held ([`modified_hit`]), and while objects on this page are selected or a gesture of theirs +/// runs. Ordinary presses on a paragraph, a picture or added content still reach their own tools. +/// A text draft is finished or discarded through its editor. +pub(crate) fn page_input(cx: PageToolContext<'_>, press: PagePress, view: &mut DocView) -> bool { + let PageToolContext { ui, resp, ptr, .. } = cx; if view.objects.input_blocked { return true; } if resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) { return false; } - if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { - let blocked = view.objects.blocked_page == Some(page); - let modifier_press = ptr.pressed().is_some() && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); - if blocked || modifier_press { + match press { + // Text being typed keeps the keyboard, and its editor stays open, until the button is up. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Held }) => { view.objects.hold_editor = true; - view.objects.blocked_page = ptr.primary_down.then_some(page); - if modifier_press { + if ptr.pressed().is_some() { view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into()); } return true; } + PagePress::Mine(Owner::Objects { part: ObjectsPart::Refused }) => { + if ptr.pressed().is_some() { + view.objects.notice = Some(tl!("This page contains objects that cannot be selected together.").into()); + } + return true; + } + _ => {} + } + if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { return false; } let mut selection = std::mem::take(&mut view.objects); - let result = input(&mut selection, cx, view); + let result = input(&mut selection, cx, press, view); view.objects = selection; result } -fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -> bool { +fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, press: PagePress, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, doc, .. } = cx; let pointer = ptr.pos; let (pressed, down) = (ptr.pressed().is_some(), ptr.primary_down); @@ -285,70 +322,63 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) - if !pressed { return s.page == Some(page) && !s.selected.is_empty(); } - let objects = match s.inventory(doc, page) { - Ok(objects) => objects, - Err(_) => { - if modified(ui) { - s.notice = Some(tl!("This page contains objects that cannot be selected together.").into()); + let Ok(objects) = s.inventory(doc, page) else { return false }; + // What the press was on ([`hit`], [`modified_hit`]). + match press { + // With Shift or ⌘: the object it was on joins the selection or leaves it, and on none an + // area selection adds to it. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Toggle(target) }) => { + if s.page != Some(page) { + s.clear(); + s.page = Some(page); + if let Some(single) = single(view, page) + && let Some(object) = objects.iter().find(|o| o.target == single) + { + s.selected.push(object.clone()); + } + } + match target.and_then(|t| objects.iter().find(|o| o.target == t)) { + Some(object) => { + if let Some(index) = s.selected.iter().position(|o| o.target == object.target) { + s.selected.remove(index); + } else if s.selected.len() < MAX_MOVE_OBJECTS { + s.selected.push(object.clone()); + } else { + s.notice = Some(tl!("Select at most 1000 objects at a time.").into()); + } + } + None => s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true }), + } + leave_single(view); + view.current = page; + return true; + } + // The selection moves. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Selection }) if !resp.double_clicked() => { + s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); + return true; + } + // The frame of the paragraph open for typing selects its box (for the arrow keys) and + // moves it. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Frame }) => { + if let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block) + && let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block }) + { + s.clear(); + s.page = Some(page); + s.selected.push(object.clone()); + s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); + leave_single(view); + view.current = page; return true; } - return false; } - }; - let additive = modified(ui); - // A page-sized background image must not hide editable labels or added notes. - // Within each kind, prefer the last object as the ordinary single-item tools do. - let hit = [ObjectKind::Added, ObjectKind::Text, ObjectKind::Image] - .into_iter() - .find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p))); - if additive { - if s.page != Some(page) { - s.clear(); - s.page = Some(page); - if let Some(target) = single(view, page) - && let Some(object) = objects.iter().find(|o| o.target == target) - { - s.selected.push(object.clone()); - } - } - if let Some(object) = hit { - if let Some(index) = s.selected.iter().position(|o| o.target == object.target) { - s.selected.remove(index); - } else if s.selected.len() < MAX_MOVE_OBJECTS { - s.selected.push(object.clone()); - } else { - s.notice = Some(tl!("Select at most 1000 objects at a time.").into()); - } - } else { - s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true }); - } - leave_single(view); - view.current = page; - return true; - } - // What the press was on ([`hit`]): the selection moves; the frame of the paragraph open for - // typing selects its box (for the arrow keys) and moves it. - let owner = view.router.owner(page); - if owner == Some(Owner::Objects { part: ObjectsPart::Selection }) && !resp.double_clicked() { - s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); - return true; - } - if owner == Some(Owner::Objects { part: ObjectsPart::Frame }) - && let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block) - && let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block }) - { - s.clear(); - s.page = Some(page); - s.selected.push(object.clone()); - s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); - leave_single(view); - view.current = page; - return true; + _ => {} } s.clear(); // A press on something (a paragraph, its edge reaching past its box #2276, a picture, added - // content) is its own; only one on the empty page starts an area selection. - if hit.is_some() || view.router.owner(page).is_some() { + // content) is its own; only one nobody holds starts an area selection. + if press != PagePress::Free { return false; } leave_single(view); diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index fc2a8e4fe7..54dcfa5f33 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -385,3 +385,56 @@ fn modifier_selection_picks_foreground_text_and_added_items_over_background_artw assert!(new.rect.iter().enumerate().all(|(i, value)| (value - old.rect[i] - shift[i % 2]).abs() < 0.01), "{old:?} -> {new:?}"); } } + +/// A press with Shift while a paragraph with changes is open is held until the button is up: it +/// says why, the paragraph keeps its text, and nothing under the press is moved or selected, not +/// even once the press turns into a drag. +#[test] +fn a_shift_click_with_a_changed_paragraph_open_shows_the_notice_and_moves_nothing() { + let mut h = harness(); + let before = objects(&h); + let p = at(&h, 55.0, 70.0); + click(&mut h, p, Modifiers::NONE); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "A draft still being edited".into(); + let (a, b) = (at(&h, 60.0, 145.0), at(&h, 120.0, 200.0)); + h.hover_at(a); + h.run_steps(2); + h.event_modifiers( + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }, + Modifiers::SHIFT, + ); + h.run_steps(2); + let notice = "Finish or discard the text being edited before selecting several objects."; + assert!(h.state().toast.as_ref().is_some_and(|t| t.0.contains(notice)), "the notice says why"); + for k in 1..=3 { + h.hover_at(a + (b - a) * (k as f32 / 3.0)); + h.run_steps(2); + } + h.drop_at(b); + h.run_steps(4); + assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("A draft still being edited")); + assert_eq!(objects(&h), before, "nothing moved"); + assert!(h.state().views[0].image_selection.is_none() && h.state().views[0].content.selected.is_none(), "nothing selected"); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); +} + +/// A press beside a picture, within the reach the objects selected together are found in but +/// outside the picture itself, is nobody's: it starts an area selection. It used to start nothing. +#[test] +fn a_press_just_outside_a_picture_starts_an_area_selection() { + let mut h = harness(); + // The figure is x 40..100, y 130..160 from the page's top; the added note x 130..230, y 130..144. + let (a, b) = (at(&h, 38.5, 140.0), at(&h, 235.0, 175.0)); + h.hover_at(a); + h.run_steps(2); + h.drag_at(a); + h.run_steps(2); + h.hover_at(b); + h.run_steps(2); + h.drop_at(b); + h.run_steps(4); + h.get_by_label("2 objects selected"); +}