refactor(ui): a press with Shift or ⌘ in Edit text is the objects', by their hit

`object_ui` took a Shift- or ⌘-press before anyone else, whoever owned it,
through its own inventory test at the press point, and declined a plain
press where that test found an object the router didn't (just outside a
picture's edge), so such a press started nothing at all.

- The page view reads the press's modifiers from `PagePointer`
  (`modifiers`: those held at the frame's first primary press). With
  Edit text, a press that adds (Shift, Ctrl or ⌘) is the objects' whole:
  `object_ui::modified_hit` gives `Objects { Toggle(target) }` for the
  object under it, or none (an area selection that adds), `Held` while
  text being typed is open, and `Refused` on a page whose objects can't
  be selected together. Only the press asks: hover and cursors are as
  before.
- `object_ui` is told what the press is to its page (`PagePress`) and acts
  on its own parts; a press nobody holds starts the area selection. The
  inventory test and its decline go, and so does `blocked_page`: the held
  press keeps the editor open until the button is up through its capture.
- A plain press beside a picture, within the 2 px the objects are found
  in but outside the picture, now starts an area selection.
- The guard: `object_ui::input` no longer reads the router.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehelandClaude Opus 5.5 committed 2026-11-04 19:37:46 +01:00
1 parent 97094a1c86
commit 6789b25956
6 files changed
+234 -112

No files matched your search

+30 -10
View File
@@ -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<crate::page_router::Hit> {
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<crate::page_router::Hit> {
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<Vec<pdfcraft_engine::EditableObject>, String>>) -> Vec<crate::page_router::Hit> {
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<crate::page_router::Hit> {
fn hits(
&self,
view: &DocView,
p: Pos2,
context: bool,
objects: Option<&Result<Vec<pdfcraft_engine::EditableObject>, String>>,
) -> Vec<crate::page_router::Hit> {
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();
+16 -4
View File
@@ -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<Pos2> {
})
}
/// The modifier keys held at the first primary press among `events`.
fn press_modifiers(events: &[Event]) -> Option<Modifiers> {
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<Pos2> {
events.iter().rev().find_map(|e| match e {
@@ -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::<Vec<_>>().join("\n");
if !routed_press_handler(&body) {
+10
View File
@@ -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<ObjectTarget>),
/// 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.
+119 -89
View File
@@ -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<Vec<EditableObject>>,
pub notice: Option<String>,
pub hold_editor: bool,
blocked_page: Option<usize>,
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<Vec<EditableObject>, 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<Vec<EditableObject>, 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<ObjectTarget> {
@@ -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<ObjectTarget> {
[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<Vec<EditableObject>, String>,
clip: Rect,
p: Pos2,
) -> Option<Hit> {
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);
@@ -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");
}