Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions crates/ui-egui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -928,6 +930,8 @@ pub struct NoteCraftApp {
canvas_pan: egui::Vec2,
/// Last middle-button position while panning the canvas.
canvas_drag: Option<egui::Pos2>,
/// 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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))]);
Expand Down Expand Up @@ -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<ribbon::Act> = 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;
Expand Down Expand Up @@ -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<egui::Rect> = 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.
{
Expand Down
113 changes: 113 additions & 0 deletions crates/ui-egui/src/collect.rs
Original file line number Diff line number Diff line change
@@ -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<Vec2> {
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::<Vec<_>>(), gap);
let ys = axis(&items.iter().map(|r| (r.min.y, r.max.y)).collect::<Vec<_>>(), 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<f32> {
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<usize> = (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<Rect> {
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");
}
}
21 changes: 21 additions & 0 deletions crates/ui-egui/src/containers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Rect>,
/// 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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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<Rect> = Vec::with_capacity(boxes.len());

for (container_index, container) in boxes.iter().copied().enumerate() {
let range = container::blocks_of(boxes, container_index);
Expand Down Expand Up @@ -1548,16 +1553,32 @@ 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
// so scrolling and the following ink-layer allocation match the editable page.
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<Rect> {
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 {
Expand Down
19 changes: 19 additions & 0 deletions crates/ui-egui/src/inkpad.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions crates/ui-egui/src/ribbon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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| {
Expand Down
17 changes: 17 additions & 0 deletions crates/ui-egui/tests/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion docs/gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions i18n/ar-XB.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
2 changes: 2 additions & 0 deletions i18n/en-XA.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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@@@
Expand Down
2 changes: 2 additions & 0 deletions i18n/en.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading