diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0e4a689..3391a40 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -279,6 +279,8 @@ 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; /// The Insert Space tool's own layer over the visible page (issue #164): a vertical-resize /// pointer, a guide at the pointer's height, and while it is dragged, the band of room being /// added (below the start) or taken away (above it). Returns where the drag began and how far it @@ -928,6 +930,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)>, /// True while Insert Space is in hand: the next drag on the page adds or takes away room. @@ -1094,6 +1098,7 @@ impl NoteCraftApp { stylus: crate::stylus::Stylus::default(), page_rect: None, canvas_pan: egui::Vec2::ZERO, + collect_pending: false, page_hbar: None, space_tool: false, canvas_drag: None, @@ -3934,6 +3939,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))]); @@ -6704,6 +6718,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); // Insert Space: in hand this frame, and whether it was used or put down. let space_on = self.space_tool && self.pointer == ribbon::Pointer::Write; let mut space_over = false; @@ -6880,6 +6895,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 0000000..4bb8f94 --- /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 43e92fd..c535068 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 b343e3e..46164a6 100644 --- a/crates/ui-egui/src/inkpad.rs +++ b/crates/ui-egui/src/inkpad.rs @@ -362,6 +362,25 @@ 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 + } /// 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 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d37535c..be36467 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 99660c8..d5918ad 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -105,6 +105,8 @@ pub enum Act { InsertSpaceTool, /// 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, /// Open the Page Templates pane: new pages from templates, and this page kept as one /// (issue #173). PageTemplates, @@ -2720,6 +2722,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 aba5562..312d07f 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -9552,6 +9552,23 @@ fn content_wider_than_the_view_gets_a_horizontal_scroll_bar() { } } +/// 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"); +} /// Issue #173: Insert > Page Templates lists the templates by category; one click adds a page /// to the open section from a template and opens it, and the open page can be kept as a /// template of its own, which then makes pages too. No page already there is written over. diff --git a/docs/gaps.md b/docs/gaps.md index 042a77d..9a20bea 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -392,7 +392,12 @@ Playground hosts with the Files API continue using their existing persistence pa ellipse, circle, diamond, triangle and star - drawn by dragging out the two corners they 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. Alt+1 to Alt+9 pick up the + 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. + Alt+1 to Alt+9 pick up the pen in that place on the rack, from anywhere, so a tablet's express keys can be bound to pens; Alt+0 puts the pen down and goes back to typing. OneNote has no such keys. **Not built:** automatic shapes; the ruler and other stencils; diff --git a/i18n/ar-XB.tsv b/i18n/ar-XB.tsv index f8dbccd..9ca07f3 100644 --- a/i18n/ar-XB.tsv +++ b/i18n/ar-XB.tsv @@ -882,6 +882,8 @@ @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]] @id ui.page_templates [[RLI]]$$$Page@@@[[PDI]]\n[[RLI]]$$$Templates@@@[[PDI]] @id ui.page_templates_tip [[RLI]]$$$Start a new page from a template, or keep this page as one@@@[[PDI]] @id ui.page_templates_title [[RLI]]$$$Page Templates@@@[[PDI]] diff --git a/i18n/en-XA.tsv b/i18n/en-XA.tsv index d9073f7..d7f77aa 100644 --- a/i18n/en-XA.tsv +++ b/i18n/en-XA.tsv @@ -882,6 +882,8 @@ @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@@@ @id ui.page_templates $$$Page@@@\n$$$Templates@@@ @id ui.page_templates_tip $$$Start a new page from a template, or keep this page as one@@@ @id ui.page_templates_title $$$Page Templates@@@ diff --git a/i18n/en.tsv b/i18n/en.tsv index a1dd18f..c02b95b 100644 --- a/i18n/en.tsv +++ b/i18n/en.tsv @@ -882,6 +882,8 @@ @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 @id ui.page_templates Page\nTemplates @id ui.page_templates_tip Start a new page from a template, or keep this page as one @id ui.page_templates_title Page Templates