From f64d2bc8407f3de67c007ba86535f349dca676ca Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 13 Nov 2026 18:04:28 -0700 Subject: [PATCH 1/2] feat(ui-egui): Draw > Collect All gathers what drifted apart (#23) A Collect All button at the end of the Draw tab's Edit group pulls the boxes and strokes of ink that drifted apart on the open canvas back together. Along each axis, every empty stretch of the page wider than a small gap (24 pt), including the margin before the first item, is closed down to that gap. Nothing else moves. Items keep their order across and down the page, and nothing ends up on anything it was not already on. Ink drawn over a box moves with the box, so notes keep their annotations. One Undo puts the boxes and the ink back together. The new layout is written to the page like any other box move. collect.rs holds the geometry, with unit tests. containers::Moved now reports each box's frame in page points, and inkpad::Pad gains stroke_bounds and move_each. Closes #23 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y4im4nvZvQHZdShop1ff8w --- crates/ui-egui/src/app.rs | 41 +++++++++++ crates/ui-egui/src/collect.rs | 113 +++++++++++++++++++++++++++++++ crates/ui-egui/src/containers.rs | 21 ++++++ crates/ui-egui/src/inkpad.rs | 20 ++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ribbon.rs | 7 ++ crates/ui-egui/tests/ui.rs | 60 ++++++++++++++++ docs/gaps.md | 4 ++ i18n/ar-XB.tsv | 2 + i18n/en-XA.tsv | 2 + i18n/en.tsv | 2 + 11 files changed, 273 insertions(+) create mode 100644 crates/ui-egui/src/collect.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 055446fd..238cac05 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -279,6 +279,9 @@ fn shown(p: &Path) -> String { notecraft_store::tree::display_path(p) } +/// The widest empty stretch Collect All leaves between things on a page, in page points. +const COLLECT_GAP: f32 = 24.0; + fn notebook_for(path: &Path) -> Option { let mut current = path; loop { @@ -874,6 +877,8 @@ pub struct NoteCraftApp { canvas_pan: egui::Vec2, /// Last middle-button position while panning the canvas. canvas_drag: Option, + /// Collect All was asked for: the page gathers its boxes and ink when it is next drawn. + collect_pending: bool, /// The page's sideways scroll bar and its thumb, when one was drawn last frame (issue #188). page_hbar: Option<(egui::Rect, egui::Rect)>, /// What the pointer does on the page (the Draw tab). @@ -1037,6 +1042,7 @@ impl NoteCraftApp { stylus: crate::stylus::Stylus::default(), page_rect: None, canvas_pan: egui::Vec2::ZERO, + collect_pending: false, page_hbar: None, canvas_drag: None, pointer: ribbon::Pointer::Write, @@ -3838,6 +3844,15 @@ impl NoteCraftApp { self.tags_pane = Some(TagsPane { scope, unchecked, found: Vec::new() }); self.refresh_tags(); } + ribbon::Act::CollectAll => { + if self.editor.as_ref().is_none_or(|ed| !ed.writable) { + self.say(crate::tl_id!("ui.open_a_page_first")); + return; + } + self.collect_pending = true; + // The gathered page starts at its top left, so it is in view. + self.canvas_pan = egui::Vec2::ZERO; + } ribbon::Act::NotBuilt(what) => { self.status = crate::i18n::format_message_id("ui.name_is_not_built_yet_see_the_roadmap_for_what_is_coming", &[("name", crate::tl!(what))]); @@ -6399,6 +6414,7 @@ impl NoteCraftApp { let mut hbar = crate::hscroll::Shown::default(); // What the page's right-click menu asked for, run once the page's borrow is over. let mut page_acts: Vec = Vec::new(); + let collect_now = std::mem::take(&mut self.collect_pending); { let Some(ed) = self.editor.as_mut() else { return }; let missing_fonts = crate::fonts::ensure_page_fonts(ui.ctx(), &crate::fonts::page_families(&ed.blocks)); @@ -6572,6 +6588,31 @@ impl NoteCraftApp { ed.pending_format = None; } page_acts.append(&mut moved.acts); + // Collect All (issue #23): the boxes and the strokes of ink, each as the + // rectangle it covers, gathered so no empty stretch of the page between + // them is wider than a small gap. Nothing moves past anything else. + if collect_now { + let strokes = pad.as_ref().map(crate::inkpad::Pad::stroke_bounds).unwrap_or_default(); + let items: Vec = moved + .frames + .iter() + .copied() + .chain(strokes.iter().map(|[x0, y0, x1, y1]| egui::Rect::from_min_max(egui::pos2(*x0, *y0), egui::pos2(*x1, *y1)))) + .collect(); + let by = crate::collect::gather(&items, COLLECT_GAP); + for (i, d) in by.iter().take(moved.frames.len()).enumerate() { + if let Some(b) = ed.boxes.get_mut(i) + && *d != egui::Vec2::ZERO + { + *b = notecraft_model::container::Container::new(b.x + d.x, b.y + d.y, b.width, b.blocks); + moved.boxes = true; + } + } + let ink: Vec<(f32, f32)> = by.iter().skip(moved.frames.len()).map(|d| (d.x, d.y)).collect(); + if let Some(pad) = pad.as_mut() { + ink_changed |= pad.move_each(&ink); + } + } // The page's column, its boxes and its ink: what the sideways bar // has to reach. { diff --git a/crates/ui-egui/src/collect.rs b/crates/ui-egui/src/collect.rs new file mode 100644 index 00000000..4bb8f942 --- /dev/null +++ b/crates/ui-egui/src/collect.rs @@ -0,0 +1,113 @@ +//! Collect All (issue #23): pulls what has drifted apart on the page's open canvas back +//! together, boxes and ink alike. +//! +//! The page is looked at as bands. Along each axis, the stretches of the page that hold nothing +//! at all, from one edge of the page to the other, are the gaps; every gap wider than `gap` is +//! closed down to `gap`, and the empty margin before the first thing is closed down to `gap` as +//! well. Nothing else moves: two things that touched or overlapped still do, everything keeps +//! its order across and down the page, and since only empty bands are taken out, nothing ends +//! up on top of anything it was not already on top of. + +use egui::{Rect, Vec2, vec2}; + +/// How far each of `items` (rectangles in page points) moves, in the same order. +pub fn gather(items: &[Rect], gap: f32) -> Vec { + let gap = if gap.is_finite() { gap.max(0.0) } else { 0.0 }; + let xs = axis(&items.iter().map(|r| (r.min.x, r.max.x)).collect::>(), gap); + let ys = axis(&items.iter().map(|r| (r.min.y, r.max.y)).collect::>(), gap); + xs.into_iter().zip(ys).map(|(x, y)| vec2(x, y)).collect() +} + +/// Along one axis: how far each span moves when every empty stretch wider than `gap` is +/// closed to `gap`, the stretch before the first span included. +fn axis(spans: &[(f32, f32)], gap: f32) -> Vec { + let clean: Vec<(f32, f32)> = spans.iter().map(|&(a, b)| if a.is_finite() && b.is_finite() { (a.min(b), a.max(b)) } else { (0.0, 0.0) }).collect(); + // The occupied stretches, merged where they touch or overlap. + let mut order: Vec = (0..clean.len()).collect(); + order.sort_by(|&a, &b| clean[a].0.total_cmp(&clean[b].0)); + let mut bands: Vec<(f32, f32)> = Vec::new(); + for &i in &order { + let (a, b) = clean[i]; + match bands.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => bands.push((a, b)), + } + } + // Where each band starts once the gaps before it are closed. + let mut shift = Vec::with_capacity(bands.len()); + let mut end = 0.0_f32; + for (n, &(a, b)) in bands.iter().enumerate() { + let before = if n == 0 { a.max(0.0) } else { a - end }; + let start = if n == 0 { a.min(gap).max(a.min(0.0)) } else { end + before.min(gap) }; + shift.push(start - a); + end = start + (b - a); + } + clean.iter().map(|&(a, _)| bands.iter().position(|&(lo, hi)| a >= lo && a <= hi).and_then(|n| shift.get(n).copied()).unwrap_or(0.0)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::pos2; + + fn r(x0: f32, y0: f32, x1: f32, y1: f32) -> Rect { + Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)) + } + + fn moved(items: &[Rect], gap: f32) -> Vec { + items.iter().zip(gather(items, gap)).map(|(r, d)| r.translate(d)).collect() + } + + #[test] + fn things_far_apart_come_together_and_keep_their_order() { + let items = [r(0.0, 0.0, 100.0, 50.0), r(2000.0, 3000.0, 2100.0, 3040.0)]; + let after = moved(&items, 24.0); + assert_eq!(after[0], items[0], "the first thing, at the corner, stays"); + assert_eq!(after[1], r(124.0, 74.0, 224.0, 114.0), "the far one comes to within the gap, across and down"); + } + + #[test] + fn small_gaps_and_overlaps_are_left_as_they_are() { + let items = [r(0.0, 0.0, 100.0, 100.0), r(110.0, 20.0, 200.0, 80.0), r(50.0, 90.0, 150.0, 140.0)]; + assert!(gather(&items, 24.0).iter().all(|d| *d == Vec2::ZERO), "nothing is more than the gap apart"); + } + + #[test] + fn a_drawing_that_drifted_off_to_the_side_comes_back_beside_the_rest() { + // A note at the top, and a sketch of two strokes far to the right of it. + let items = [r(40.0, 20.0, 360.0, 200.0), r(5000.0, 60.0, 5100.0, 120.0), r(5080.0, 100.0, 5200.0, 180.0)]; + let after = moved(&items, 24.0); + assert_eq!(after[0], r(24.0, 20.0, 344.0, 200.0), "the margin before the first thing closes to the gap"); + assert_eq!(after[1].min.x, 344.0 + 24.0, "the sketch comes to the gap's width beside the note"); + assert_eq!(after[2].min.x - after[1].min.x, 80.0, "and its strokes keep their places to each other"); + assert_eq!((after[1].min.y, after[2].min.y), (60.0, 100.0), "nothing needed to move down the page"); + } + + #[test] + fn nothing_ends_up_on_anything_it_was_not_on_before() { + let items = [ + r(0.0, 0.0, 50.0, 50.0), + r(900.0, 0.0, 950.0, 50.0), + r(0.0, 900.0, 50.0, 950.0), + r(900.0, 900.0, 950.0, 950.0), + r(400.0, 400.0, 600.0, 600.0), + ]; + let after = moved(&items, 10.0); + for i in 0..after.len() { + for j in (i + 1)..after.len() { + assert!(!after[i].intersects(after[j]) || items[i].intersects(items[j]), "{i} and {j}: {:?} {:?}", after[i], after[j]); + } + } + // The middle one is still between the others, both ways. + assert!(after[4].min.x > after[0].max.x && after[4].max.x < after[1].min.x); + assert!(after[4].min.y > after[0].max.y && after[4].max.y < after[2].min.y); + } + + #[test] + fn nothing_and_junk_move_nothing() { + assert!(gather(&[], 24.0).is_empty()); + let junk = [r(f32::NAN, 0.0, 10.0, 10.0), r(0.0, 0.0, 10.0, 10.0)]; + assert!(gather(&junk, 24.0).iter().all(|d| d.x.is_finite() && d.y.is_finite())); + assert_eq!(gather(&[r(500.0, 500.0, 510.0, 510.0)], f32::NAN), vec![vec2(-500.0, -500.0)], "a junk gap is no gap"); + } +} diff --git a/crates/ui-egui/src/containers.rs b/crates/ui-egui/src/containers.rs index 43e92fd6..c535068a 100644 --- a/crates/ui-egui/src/containers.rs +++ b/crates/ui-egui/src/containers.rs @@ -211,6 +211,9 @@ pub struct Moved { pub justified: bool, /// How far down the page, in page points, the containers reach. pub bottom: f32, + /// Where each box was drawn, in page points from the page's top left, handle and all: what + /// Collect All (issue #23) gathers. + pub frames: Vec, /// How far right of the page's left edge, in page points, the containers reach: the page's /// sideways scroll bar (issue #188) reaches that far. pub right: f32, @@ -1491,6 +1494,7 @@ pub fn show( } }); out.bottom = page_points_below(area, filled_bottom, zoom); + out.frames = page_frames(area, boxes, &frames, zoom); out.right = page_points_right(area, right, zoom); out } @@ -1516,6 +1520,7 @@ pub fn show_ink_view( *boxes = container::fit(boxes, blocks.len(), (area.width() / zoom - 2.0 * PAD).max(container::MIN_WIDTH)); let mut bottom = area.min.y; let mut right = area.min.x; + let mut ink_frames: Vec = Vec::with_capacity(boxes.len()); for (container_index, container) in boxes.iter().copied().enumerate() { let range = container::blocks_of(boxes, container_index); @@ -1548,6 +1553,10 @@ pub fn show_ink_view( let used = child.min_rect().height().max(18.0); bottom = bottom.max(area.min.y + container.y * zoom + HANDLE * zoom + used + PAD * zoom); right = right.max(child.min_rect().max.x.max(inner.max.x) + PAD * zoom); + ink_frames.push(Rect::from_min_max( + pos2(inner.min.x - PAD * zoom, inner.min.y), + pos2(child.min_rect().max.x.max(inner.max.x) + PAD * zoom, inner.min.y + used + PAD * zoom), + )); } // Child UIs do not advance the parent cursor; reserve their complete page-space extent @@ -1555,9 +1564,21 @@ pub fn show_ink_view( ui.allocate_rect(Rect::from_min_max(area.min, pos2(area.max.x, bottom)), Sense::hover()); out.bottom = page_points_below(area, bottom, zoom); out.right = page_points_right(area, right, zoom); + out.frames = page_frames(area, boxes, &ink_frames, zoom); out } +/// The boxes' frames as drawn on screen, back in page points from the page's top left, each +/// starting at its box's own corner so the move handle above the frame is counted in. +fn page_frames(area: Rect, boxes: &[Container], frames: &[Rect], zoom: f32) -> Vec { + let z = zoom.max(0.01); + boxes + .iter() + .zip(frames) + .map(|(b, f)| Rect::from_min_max(pos2(b.x, b.y), pos2(((f.max.x - area.min.x) / z).max(b.x), ((f.max.y - area.min.y) / z).max(b.y)))) + .collect() +} + /// How far down the page the boxes reach, in page points, from `bottom` on screen: what /// [`Moved::bottom`] reports, so a box added later (a printout) can be placed under them. fn page_points_below(area: Rect, bottom: f32, zoom: f32) -> f32 { diff --git a/crates/ui-egui/src/inkpad.rs b/crates/ui-egui/src/inkpad.rs index ce75e4d7..2334dbaa 100644 --- a/crates/ui-egui/src/inkpad.rs +++ b/crates/ui-egui/src/inkpad.rs @@ -362,6 +362,26 @@ impl Pad { (shift != (0.0, 0.0)).then_some(shift) } + /// The box round each stroke, in page points, in the drawing's order. + pub fn stroke_bounds(&self) -> Vec<[f32; 4]> { + (0..self.drawing.strokes.len()).map(|i| pick::bounds(&self.drawing, &[i]).map_or([0.0; 4], |(x0, y0, x1, y1)| [x0, y0, x1, y1])).collect() + } + + /// Moves each stroke by its own amount, in page points, in the drawing's order (Collect All, + /// issue #23). True when any moved. + pub fn move_each(&mut self, by: &[(f32, f32)]) -> bool { + let mut moved = false; + for (i, (dx, dy)) in by.iter().enumerate().take(self.drawing.strokes.len()) { + if *dx != 0.0 || *dy != 0.0 { + pick::move_by(&mut self.drawing, &[i], *dx, *dy); + moved = true; + } + } + self.dirty |= moved; + self.touched |= moved; + moved + } + /// The box round all the ink on the page, in page points. pub fn bounds(&self) -> Option<[f32; 4]> { let all: Vec = (0..self.drawing.strokes.len()).collect(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20d0f517..718f9b6d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,6 +15,7 @@ pub mod backstage; pub mod bidi; pub mod clipboard; pub mod codeview; +pub mod collect; pub mod combo; pub mod containers; pub mod control; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 5054c053..eded979e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -103,6 +103,8 @@ pub enum Act { Tag(Tag), /// Open the Tags Summary: the tagged notes, and a way to each of them (issue #166). FindTags, + /// Gather what has drifted apart on the page, boxes and ink, back together (issue #23). + CollectAll, /// Something OneNote has that NoteCraft does not: say so plainly. NotBuilt(&'static str), /// Change the shape of the table the caret is in. @@ -2634,6 +2636,11 @@ Space", // OneNote's Format Background is this tab's way to the paper, and we have the // paper, so it goes to the same gallery the View tab opens. rule_lines_menu(ui, view, t, acts); + // Everything on the canvas that drifted apart, boxes and ink, gathered back together + // (issue #23). Last in the group, so the controls before it keep their places. + if big(ui, Icon::Shapes, crate::tl_id!("ui.collect_all"), crate::tl_id!("ui.collect_all_tip"), t).clicked() { + acts.push(Act::CollectAll); + } }); shapes_group(ui, view, t, acts); group(ui, crate::tl_id!("ui.shapes"), t, |ui| { diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index fe41b0d7..3a2a3240 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -9377,3 +9377,63 @@ fn content_wider_than_the_view_gets_a_horizontal_scroll_bar() { assert_eq!(state.pointer("/ink/strokes").and_then(Value::as_u64), Some(0), "the bar took the press, not the pen: {state}"); } } + +/// Issue #23: Draw > Collect All brings boxes and ink that drifted apart on the canvas back +/// together, keeps their order and does not lay one over another; one Undo puts them all back. +#[test] +fn collect_all_gathers_boxes_and_ink_that_drifted_apart() { + let (_t, mut app, s) = sample_app("ui-collect-all"); + let page = s.md_page.to_string_lossy().to_string(); + app.open_path(&page); + let mut h = sized_harness(app, LIGHT, egui::vec2(1400.0, 1400.0)); + settle(&mut h); + let blocks = ask(&mut h, "ui.inspect", json!({})).expect("inspect").pointer("/page/blocks").and_then(Value::as_u64).unwrap_or(0); + ask( + &mut h, + "engine.execute", + json!({"command": "page.containers", "params": {"path": page.clone(), "boxes": format!("40,20,320,3;3000,2500,320,{}", blocks - 3)}}), + ) + .expect("two boxes far apart"); + ask(&mut h, "ui.open", json!({"path": page.clone()})).expect("open"); + settle(&mut h); + let boxes = |h: &mut Harness<'_, NoteCraftApp>| { + let v = ask(h, "ui.inspect", json!({})).expect("inspect"); + let n = |p: &str| v.pointer(p).and_then(Value::as_f64).unwrap_or(-1.0); + ((n("/page/containers/0/x"), n("/page/containers/0/y")), (n("/page/containers/1/x"), n("/page/containers/1/y"))) + }; + assert_eq!(boxes(&mut h), ((40.0, 20.0), (3000.0, 2500.0))); + + // A stroke drawn on its own, far down the page past the first box. + h.get_by_label("Draw").click(); + settle(&mut h); + h.get_by_label("Pen red").click(); + settle(&mut h); + let origin = page_origin(&mut h); + drag_by(&mut h, origin + egui::vec2(700.0, 800.0), egui::vec2(60.0, 0.0)); + let ink = |h: &mut Harness<'_, NoteCraftApp>| { + let v = ask(h, "ui.inspect", json!({})).expect("inspect"); + let n = |i: usize| v.pointer(&format!("/ink/bounds/{i}")).and_then(Value::as_f64).unwrap_or(-1.0); + (n(0), n(1)) + }; + let (ink_x, ink_y) = ink(&mut h); + assert!(ink_y > 700.0, "the stroke is far down: {ink_y}"); + + h.get_by_label("Collect All").click(); + settle(&mut h); + let ((x0, y0), (x1, y1)) = boxes(&mut h); + assert_eq!((x0, y0), (24.0, 20.0), "the first box keeps its place but for the margin closing"); + assert!(x1 < 1200.0 && y1 < 1200.0, "the far box came back: ({x1}, {y1})"); + assert!(x1 > x0 + 320.0 && y1 > y0, "and is still after the first box, not on it: ({x1}, {y1})"); + let (gx, gy) = ink(&mut h); + assert!(gy < ink_y && gx <= ink_x, "the stroke came closer too: ({ink_x}, {ink_y}) to ({gx}, {gy})"); + review(&mut h, "collect_all"); + let kept = ask(&mut h, "engine.execute", json!({"command": "page.containers", "params": {"path": page.clone()}})).expect("layout"); + assert!(!kept.as_str().unwrap_or_default().contains("3000,2500"), "the page keeps the gathered places: {kept}"); + + // Undo takes the boxes back where they were. + ask(&mut h, "ui.undo", json!({})).expect("undo"); + settle(&mut h); + assert_eq!(boxes(&mut h).1, (3000.0, 2500.0), "undo puts the far box back"); + // The same Undo puts the ink back too: Collect All was one step. + assert_eq!(ink(&mut h), (ink_x, ink_y), "and the stroke, in the same step"); +} diff --git a/docs/gaps.md b/docs/gaps.md index b47cc360..eaedae95 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -388,6 +388,10 @@ Playground hosts with the Files API continue using their existing persistence pa sit between. A drawn shape is ink like any other stroke, so the eraser, Select and Undo Stroke all work on it; it is not kept as a shape that can be turned back into a perfect circle later, and the file does not say which shape it was. + Draw > Collect All gathers boxes and strokes that drifted apart on the open canvas: every + empty stretch of the page wider than a small gap, across or down, is closed to that gap, so + things keep their order and nothing ends up on anything it was not already on. Ink over a + box moves with the box. One Undo puts the boxes and the ink back. **Not built:** automatic shapes; the ruler and other stencils; ink replay; ink to text and ink to math; laser pointer; More Colours and the eyedropper in the tool editor. Pressure is real and measured on Windows (hardest 0.930 over 1223 diff --git a/i18n/ar-XB.tsv b/i18n/ar-XB.tsv index f7028928..81881b5e 100644 --- a/i18n/ar-XB.tsv +++ b/i18n/ar-XB.tsv @@ -874,3 +874,5 @@ @id ui.no_tagged_notes_here [[RLI]]$$$No tagged notes here.@@@[[PDI]] @id ui.tags_empty_line [[RLI]]$$$(an empty line)@@@[[PDI]] @id ui.scroll_the_page_sideways [[RLI]]$$$Scroll the page sideways@@@[[PDI]] +@id ui.collect_all [[RLI]]$$$Collect@@@[[PDI]]\n[[RLI]]$$$All@@@[[PDI]] +@id ui.collect_all_tip [[RLI]]$$$Bring the boxes and ink that drifted apart on the page back together@@@[[PDI]] diff --git a/i18n/en-XA.tsv b/i18n/en-XA.tsv index 1c3d7ca0..edebf50a 100644 --- a/i18n/en-XA.tsv +++ b/i18n/en-XA.tsv @@ -874,3 +874,5 @@ @id ui.no_tagged_notes_here $$$No tagged notes here.@@@ @id ui.tags_empty_line $$$(an empty line)@@@ @id ui.scroll_the_page_sideways $$$Scroll the page sideways@@@ +@id ui.collect_all $$$Collect@@@\n$$$All@@@ +@id ui.collect_all_tip $$$Bring the boxes and ink that drifted apart on the page back together@@@ diff --git a/i18n/en.tsv b/i18n/en.tsv index ce5591ae..3e0be52a 100644 --- a/i18n/en.tsv +++ b/i18n/en.tsv @@ -874,3 +874,5 @@ @id ui.no_tagged_notes_here No tagged notes here. @id ui.tags_empty_line (an empty line) @id ui.scroll_the_page_sideways Scroll the page sideways +@id ui.collect_all Collect\nAll +@id ui.collect_all_tip Bring the boxes and ink that drifted apart on the page back together From b81f6d23dadbbb24530ad13314e18128c2df862c Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Sat, 14 Nov 2026 02:51:49 -0700 Subject: [PATCH 2/2] Fix formatting of move_each closing brace (cargo fmt) --- crates/ui-egui/src/inkpad.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/inkpad.rs b/crates/ui-egui/src/inkpad.rs index 4a642025..46164a6b 100644 --- a/crates/ui-egui/src/inkpad.rs +++ b/crates/ui-egui/src/inkpad.rs @@ -380,7 +380,7 @@ impl Pad { self.dirty |= moved; self.touched |= moved; moved - } + } /// Insert Space (issue #164): every stroke whose top is at or below `y`, in page points, /// moves down by `dy`, or up when `dy` is negative. True when any moved. pub fn shift_below(&mut self, y: f32, dy: f32) -> bool {