From 0652e7f72fb572307e48af4f0aa3b3afa3cbce6b Mon Sep 17 00:00:00 2001 From: DevCoderMax Date: Sun, 11 Oct 2026 23:27:44 -0300 Subject: [PATCH] Pictures in headers/footers can be moved, resized and rotated (#591) * Pictures in headers/footers can be moved, resized and rotated Hit testing (layout.object_at_in), arrange.bounds/position/nudge/group and the canvas selection, drag, resize and rotation handles used to be body-only, so pictures in headers and footers could not be moved (#82). They now work within their own header/footer story: drag to move, handles to resize, rotation handle to turn, arrows to nudge. Regression test: header_pictures_can_be_resized_and_dragged. * Credit DevCoderMax (Gleyson do Nascimento Carvalho) in About --------- Co-authored-by: Brandon Thomas --- contributors/people.toml | 4 ++ crates/engine/src/cmd/draw.rs | 10 ++-- crates/engine/src/cmd/objects.rs | 95 +++++++++++++++++++++++--------- crates/layout/src/hit.rs | 40 +++++++++++++- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/objects.rs | 61 ++++++++++++++------ 6 files changed, 162 insertions(+), 50 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 1fd42c2..766bfd8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -41,5 +41,9 @@ sync_github_name = false [people.henno] sync_github_name = true +[people.DevCoderMax] +real_name = "Gleyson do Nascimento Carvalho" +display_name = "Gleyson Carvalho" +sync_github_name = false [people.alexanderalber] real_name = "Alexander Alber" diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 7c15c5b..e9cd535 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -470,11 +470,13 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult { /// under `anchor_y` (positioned from its column and top), or, where no paragraph starts on the /// page, to the nearest line and positioned on the page. Returns where its character went. fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor_y: f32) -> Result { - let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y) { + let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y, StoryRef::Body) { Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false), - None => { - (super::objects::nearest_line(layout, page, anchor_y).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true) - } + None => ( + super::objects::nearest_line(layout, page, anchor_y, StoryRef::Body) + .ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, + true, + ), }; s.doc.insert_object(&at, obj, &CharProps::default())?; let len = OBJ.len_utf8(); diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index e815aa6..fc658de 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -28,6 +28,16 @@ fn has_floating(s: &Session) -> Option<&'static str> { _ => Some("select a floating picture or shape first"), } } +/// Stories whose objects can be moved, resized and arranged: the body and headers/footers. +/// Objects in table cells (deeper paths), notes and text boxes stay where their text is. +fn movable_story(s: &Session, story: StoryRef) -> bool { + match story { + StoryRef::Body => true, + StoryRef::Part(id) => { + s.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer)) + } + } +} /// Charts and diagrams are not written back on save yet, so their size, position and wrapping stay /// as imported: they can be selected and deleted, not moved, resized or arranged. const FROZEN: &str = "charts and diagrams can't be moved or resized yet"; @@ -688,9 +698,10 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult { /// position afterwards. fn move_object(s: &mut Session, pos: Pos, page: Option, x: f32, y: f32, (w, h): (f32, f32)) -> Result { use wordcraft_doc::para::Anchor; - if pos.story != StoryRef::Body || pos.path.depth() > 0 { - return Err(CmdError::Disabled("only objects in the body text can be moved".into())); + if pos.path.depth() > 0 || !movable_story(s, pos.story) { + return Err(CmdError::Disabled("only objects in the body text, headers and footers can be moved".into())); } + let story = pos.story; let layout = s.layout(); let cur = layout.object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?; let page = page.map_or(cur.page, |n| usize::try_from(n).unwrap_or(usize::MAX)); @@ -698,19 +709,19 @@ fn move_object(s: &mut Session, pos: Pos, page: Option, x: f32, y: f32, (w, // Keep a corner of it on the page. let x = x.max(12.0 - w).min(pg.w - 12.0); let y = y.max(12.0 - h).min(pg.h.min(MAX_OFFSET) - 12.0); - let (anchor, on_page) = match anchor_paragraph(&layout, page, y) { - Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false), - None => (nearest_line(&layout, page, y).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true), + let (anchor, on_page) = match anchor_paragraph(&layout, page, y, story) { + Some(path) => (Pos { story, path, off: 0 }, false), + None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true), }; // Move its character to the anchor (unless it's already in that paragraph). - let at = if anchor.path == pos.path && !on_page { + let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page { pos } else { let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?; let len = wordcraft_doc::para::OBJ.len_utf8(); s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?; let mut at = anchor; - if at.path == pos.path && at.off > pos.off { + if at.story == pos.story && at.path == pos.path && at.off > pos.off { at.off = at.off.saturating_sub(len); } let at = s.doc.clamp(&at); @@ -798,31 +809,33 @@ fn move_with_text(s: &mut Session, with_text: bool) -> CmdResult { Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)})) } -/// The top-level body paragraph under `y` on `page`: the last one starting at or above it (else -/// the first starting on the page). -pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option { - let starts: Vec<(&wordcraft_doc::Path, f32)> = layout - .pages - .get(page)? - .items - .iter() +/// The top-level paragraph of `story` under `y` on `page`: the last one starting at or above +/// it (else the first starting on the page). Body paragraphs come from the page's items; +/// header/footer ones from its header and footer. +pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option { + let pg = layout.pages.get(page)?; + let items: Box + '_> = match story { + StoryRef::Body => Box::new(pg.items.iter()), + StoryRef::Part(_) => Box::new(pg.header.iter().chain(pg.footer.iter())), + }; + let starts: Vec<(&wordcraft_doc::Path, f32)> = items .filter_map(|it| match it { - wordcraft_layout::Placed::Lines { story: StoryRef::Body, path, l0: 0, y: top, .. } if path.depth() == 0 => Some((path, *top)), + wordcraft_layout::Placed::Lines { story: s, path, l0: 0, y: top, .. } if *s == story && path.depth() == 0 => Some((path, *top)), _ => None, }) .collect(); starts.iter().rev().find(|(_, top)| *top <= y + 0.5).or(starts.first()).map(|(p, _)| (*p).clone()) } -/// The start of the top-level body line on `page` nearest to `y`. -pub(crate) fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option { - let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, StoryRef::Body); +/// The start of the top-level line of `story` on `page` nearest to `y`. +pub(crate) fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option { + let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, story); let best = lines.iter().filter(|l| l.path.depth() == 0).min_by(|a, b| { let d = |l: &wordcraft_layout::hit::LineHit| if y < l.top { l.top - y } else { (y - l.bottom).max(0.0) }; d(a).total_cmp(&d(b)) })?; let off = best.para.lines.get(best.li)?.start; - Some(Pos { story: StoryRef::Body, path: best.path.clone(), off }) + Some(Pos { story, path: best.path.clone(), off }) } fn obj_size(o: &InlineObject) -> (f32, f32) { @@ -1103,7 +1116,7 @@ fn object_ref(s: &Session, v: &Value) -> Result { Some(n) => all_objects(s).into_iter().nth(usize::try_from(n).unwrap_or(usize::MAX)).map(|(p, _)| p), None => super::parse_pos(v), }; - pos.filter(|p| p.story == StoryRef::Body && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing)) + pos.filter(|p| movable_story(s, p.story) && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing)) .ok_or_else(|| CmdError::Params("no picture or shape there".into())) } @@ -1155,9 +1168,13 @@ fn group(s: &mut Session, _: &Value) -> CmdResult { picks.sort(); let layout = s.layout(); let mut found = Vec::new(); + let story = picks.first().cloned().map(|p| p.story).ok_or_else(|| CmdError::Failed("nothing to group".into()))?; + if picks.iter().any(|p| p.story != story) { + return Err(CmdError::Disabled("objects in different stories (body, header, footer) can't be grouped".into())); + } for pos in &picks { - if pos.story != StoryRef::Body || pos.path.depth() > 0 { - return Err(CmdError::Disabled("only objects in the body text (not in tables) can be grouped".into())); + if pos.path.depth() > 0 || !movable_story(s, pos.story) { + return Err(CmdError::Disabled("only objects in the body text, headers and footers (not in tables) can be grouped".into())); } let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?; if matches!(obj, InlineObject::Graphic { .. }) { @@ -1241,8 +1258,8 @@ fn ungroup(s: &mut Session, _: &Value) -> CmdResult { let InlineObject::Group { w, h, float, .. } = &obj else { return Err(CmdError::Disabled("select a group first".into())) }; // Its offsets, explicit: the members are placed from the same anchor. let pos = if float.wrap == Wrap::Inline { - if pos.story != StoryRef::Body || pos.path.depth() > 0 { - return Err(CmdError::Disabled("only groups in the body text can be ungrouped in line with text".into())); + if pos.path.depth() > 0 || !movable_story(s, pos.story) { + return Err(CmdError::Disabled("only groups in the body text, headers and footers can be ungrouped in line with text".into())); } let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?; move_object(s, pos, Some(hit.page as u64), hit.rect.x, hit.rect.y, (*w, *h))? @@ -1698,6 +1715,34 @@ mod tests { assert_eq!(crop, [0.1, 0.2, 0.05, 0.0]); } + /// A header picture resizes and drags like a body one: the move stays in the header story + /// and lands where dropped; the header hit test finds it. + #[test] + fn header_pictures_can_be_resized_and_dragged() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.editHeader", &json!({})).unwrap(); + let story = s.sel.focus.story; + assert!(matches!(story, StoryRef::Part(_)), "editing the header, got {story:?}"); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap(); + let (pos, _) = selected(&s).expect("a new header picture is selected"); + assert_eq!(pos.story, story); + s.run("arrange.bounds", &json!({"width": 160, "height": 90})).unwrap(); + let r = s.run("arrange.bounds", &json!({"page": 0, "x": 200, "y": 60})).unwrap(); + assert_eq!(r["object"]["float"]["wrap"], "square"); + let o = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out"); + assert_eq!(o.story, story); + assert!((o.rect.x - 200.0).abs() < 1.0 && (o.rect.y - 60.0).abs() < 1.0, "{:?}", o.rect); + let hit = s.layout().object_at_in(o.page, o.rect.x + 5.0, o.rect.y + 5.0, 4.0, story).expect("header hit"); + assert_eq!(hit.pos(), o.pos()); + s.run("arrange.nudge", &json!({"dx": 6, "dy": 4})).unwrap(); + let n = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out"); + assert!((n.rect.x - 206.0).abs() < 1.0 && (n.rect.y - 64.0).abs() < 1.0, "{:?}", n.rect); + s.run("edit.undo", &json!({})).unwrap(); + let u = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out"); + assert!((u.rect.x - 200.0).abs() < 1.0 && (u.rect.y - 60.0).abs() < 1.0, "{:?}", u.rect); + } + /// Shape Format › Text (#369): direction and alignment on the box the caret is in, then /// Create Link to an empty box and Break Link, each one undo step. #[test] diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index b78aff7..00f2fe7 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -137,6 +137,25 @@ fn items_of(page: &Page, story: StoryRef) -> Box + } } +/// Whether header/footer part `id` has text near (x, y) on the page (its header or footer +/// lines). Used so an object behind the header text is only grabbed where there's no text, +/// mirroring `text_at` for the body. +fn hf_text_at(p: &Page, id: u32, x: f32, y: f32) -> bool { + let story = StoryRef::Part(id); + p.header + .iter() + .chain(p.footer.iter()) + .filter_map(|it| match it { + Placed::Lines { story: s, para, l0, l1, x: lx, y: ly, .. } if *s == story => Some((para, l0, l1, lx, ly)), + _ => None, + }) + .any(|(para, l0, l1, lx, ly)| { + let (Some(first), Some(last)) = (para.lines.get(*l0), para.lines.get(l1.checked_sub(1).unwrap_or(0))) else { return false }; + let bottom = ly + last.top + last.height - first.top; + y >= *ly - 2.0 && y <= bottom + 2.0 && x >= *lx - 40.0 && x <= lx + last.right + 40.0 + }) +} + /// Every upright line on a page that belongs to `story` (body, or a header/footer part). /// Turned lines (see [`turned_hit`]) are left out. pub fn page_lines(page: &Page, story: StoryRef) -> Vec> { @@ -237,15 +256,32 @@ impl DocLayout { /// border (within `edge` points either side; inside is its text). Topmost first; objects /// behind the text only where there's no text. pub fn object_at(&self, page: usize, x: f32, y: f32, edge: f32) -> Option { + self.object_at_in(page, x, y, edge, StoryRef::Body) + } + + /// The object of `story` a press at (x, y) grabs. Body looks only at the page's own items + /// (as before); a header/footer part looks only at that part's objects in the page's + /// header and footer, so a header picture placed over the body never steals body clicks. + pub fn object_at_in(&self, page: usize, x: f32, y: f32, edge: f32, story: StoryRef) -> Option { let p = self.pages.get(page)?; // A rotated object is grabbed on its rotated shape: the point is taken into its frame. let grabs = |o: &ObjectHit| { let pt = o.unspin(x, y); o.rect.expand(edge).contains(pt) && (o.text_box.is_none() || !o.rect.expand(-edge).contains(pt) || o.rect.w.min(o.rect.h) <= edge * 3.0) }; - objects(p, page) + let cands: Vec = match story { + StoryRef::Body => objects(p, page).collect(), + StoryRef::Part(_) => all_objects(p, page).filter(|o| o.story == story).collect(), + }; + let text_here = match story { + StoryRef::Body => text_at(p, page, x, y).is_some(), + StoryRef::Part(id) => hf_text_at(p, id, x, y), + }; + cands + .iter() .find(|o| !o.behind() && grabs(o)) - .or_else(|| if text_at(p, page, x, y).is_some() { None } else { objects(p, page).find(|o| o.behind() && grabs(o)) }) + .or_else(|| if text_here { None } else { cands.iter().find(|o| o.behind() && grabs(o)) }) + .cloned() } /// The text box in the header or footer at (x, y) on `page`. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index dd3e306..3bbc132 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1567,7 +1567,7 @@ fn part_kind(app: &WordApp, s: StoryRef) -> Option { } /// The story a text box's story sits in (where the box is), when the caret is in a text box. -fn text_box_host(app: &WordApp, layout: &DocLayout) -> Option { +pub(crate) fn text_box_host(app: &WordApp, layout: &DocLayout) -> Option { match app.session.sel.focus.story { StoryRef::Part(id) if in_text_box(app) => layout.text_box(id, app.session.page_hint).map(|o| o.story), _ => None, diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 14c8111..044784b 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -72,9 +72,31 @@ fn connector_end_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f3 } } -/// The selected object: the selection is exactly one picture, shape or text box in the body. +/// The selected object: the selection is exactly one picture, shape or text box, in the body +/// or in a header/footer. pub fn selected(app: &WordApp) -> Option<(Pos, &InlineObject)> { - wordcraft_engine::cmd::objects::object_selection(&app.session).filter(|(p, _)| p.story == StoryRef::Body) + wordcraft_engine::cmd::objects::object_selection(&app.session).filter(|(p, _)| selectable_story(app, p.story)) +} + +/// Whether objects of `story` can be selected and dragged on the canvas: the body and +/// headers/footers. +fn selectable_story(app: &WordApp, story: StoryRef) -> bool { + match story { + StoryRef::Body => true, + StoryRef::Part(id) => app.session.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)), + } +} + +/// The story whose objects the canvas handles: the header/footer being edited, else the body. +fn active_story(app: &WordApp, layout: &DocLayout) -> StoryRef { + let story = crate::canvas::text_box_host(app, layout).unwrap_or(app.session.sel.focus.story); + match story { + StoryRef::Body => StoryRef::Body, + StoryRef::Part(id) if selectable_story(app, StoryRef::Part(id)) => StoryRef::Part(id), + // Notes and text-box text: no top-level objects live here, so grabs find nothing and + // the press falls through to text handling. + other => other, + } } /// Whether the object under `hit` keeps its place and size: charts and diagrams can be selected @@ -151,7 +173,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] return true; } let multi = resp.double_clicked() || resp.triple_clicked(); - if !(pressed || multi) || crate::canvas::editing_header_footer(app, layout) { + if !(pressed || multi) { return false; } // A selected connector's end: drag it to glue it. @@ -176,7 +198,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] None => return true, }; // Objects in table cells can be selected and resized, not moved; charts neither. - if !fixed(app, &object) && (grab != Grab::Move || movable(&object)) { + if !fixed(app, &object) && (grab != Grab::Move || movable(app, &object)) { app.canvas.obj_drag = Some(ObjectDrag { drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at), object, min, picture, end: None }); } @@ -221,9 +243,11 @@ pub fn select_at(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f true } -/// What a press at `at` would grab: a handle of the shown frame, else an object. +/// What a press at `at` would grab: a handle of the shown frame, else an object of the story +/// being edited (body, or the header/footer). fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<(ObjectHit, Grab)> { - let handle = active(app, layout).filter(|(o, _)| o.story == StoryRef::Body && !fixed(app, o)).and_then(|(o, _)| { + let story = active_story(app, layout); + let handle = active(app, layout).filter(|(o, _)| o.story == story && !fixed(app, o)).and_then(|(o, _)| { let f = screen(pages, scale, o.page, o.rect)?; if frame::rotate_handle_at(f, o.spin.deg, at) { return Some((o, Grab::Rotate)); @@ -232,14 +256,16 @@ fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Po }); handle.or_else(|| { let (page, x, y) = crate::canvas::page_at(pages, layout, scale, at)?; - // Selectable: body objects (table cells too); notes' objects aren't yet. - layout.object_at(page, x, y, EDGE / scale.max(0.01)).filter(|o| o.story == StoryRef::Body).map(|o| (o, Grab::Move)) + // Selectable: body objects (table cells too) or the header/footer's own objects; + // notes' objects aren't yet. + layout.object_at_in(page, x, y, EDGE / scale.max(0.01), story).map(|o| (o, Grab::Move)) }) } -/// Objects that can be dragged to a new place: those in the body's own paragraphs. -fn movable(o: &ObjectHit) -> bool { - o.story == StoryRef::Body && o.path.depth() == 0 +/// Objects that can be dragged to a new place: those in the own paragraphs of the body or of a +/// header/footer. +fn movable(app: &WordApp, o: &ObjectHit) -> bool { + o.path.depth() == 0 && selectable_story(app, o.story) } /// Commit a finished drag as one command. @@ -255,7 +281,9 @@ fn drop(app: &mut WordApp, d: &ObjectDrag) { } Grab::Move => json!({"page": d.drag.to_page, "x": r.x, "y": r.y}), // An inline object stays in the text; a floating one keeps its far edges put. - Grab::Resize(_) if d.object.floating() && movable(&d.object) => json!({"width": r.w, "height": r.h, "page": d.drag.page, "x": r.x, "y": r.y}), + Grab::Resize(_) if d.object.floating() && movable(app, &d.object) => { + json!({"width": r.w, "height": r.h, "page": d.drag.page, "x": r.x, "y": r.y}) + } Grab::Resize(_) => json!({"width": r.w, "height": r.h}), }; let _ = app.run("arrange.bounds", params); @@ -317,9 +345,9 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p if let Some((o, editing)) = active(app, layout) && let Some(f) = screen(pages, scale, o.page, o.rect) { - // Handles where it can be resized and turned (body objects, not charts): else a plain - // frame. - let handles = o.story == StoryRef::Body && !fixed(app, &o); + // Handles where it can be resized and turned (body and header/footer objects, + // not charts): else a plain frame. + let handles = selectable_story(app, o.story) && !fixed(app, &o); frame::paint(painter, t, f, o.spin.deg, editing, handles, handles); } } @@ -369,9 +397,6 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: if let Some(d) = &app.canvas.obj_drag { return Some(if d.end.is_some() { egui::CursorIcon::Crosshair } else { d.drag.grab.cursor() }); } - if crate::canvas::editing_header_footer(app, layout) { - return None; - } if connector_end_at(app, layout, pages, scale, at).is_some() { return Some(egui::CursorIcon::Crosshair); }