authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 02:42:51-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
log22ec560def0e7769c2cfd79da312576382966e1e
tree75eeac9fc31d0630dc42582ee08793bffe57647c
parent1916e882c31374800ac83f9c8bc6a362c71320ca
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: snap to the grid anchored at the page margin origin

OneNote's 18 pt placement grid runs through the page's margin origin rather than the default 36 pt. On pages like "av: casual night in the trees" (origin 36.75) every drag, move and new caret landed 0.75 pt off; a 22.5 x 15 pt picture drag now lands where OneNote 2010 puts it. Assisted-by: claude-opus-5.5

3 files changed, 34 insertions(+), 12 deletions(-)

crates/canvas/src/editor.rs+5
......@@ -1401,6 +1401,11 @@ impl CanvasEditor {
14011401 self.active_outline().selection
14021402 }
14031403
1404 /// OneNote's 18 pt placement grid passes through this point.
1405 pub fn margin_origin(&self) -> [f32; 2] {
1406 self.header.margin_origin
1407 }
1408
14041409 pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> {
14051410 let outline = self.active_outline();
14061411 if outline.layout.reserved_width.or(outline.layout.max_width) == Some(width)
crates/snowbound/src/main.rs+28-11
......@@ -372,7 +372,7 @@ impl State {
372372 if self.modifiers.alt_key() {
373373 position
374374 } else {
375 snap_to_grid(position)
375 snap_to_grid(position, self.editor.margin_origin())
376376 },
377377 ))
378378 }
......@@ -398,7 +398,7 @@ impl State {
398398 let origin = if self.modifiers.alt_key() {
399399 origin
400400 } else {
401 snap_to_grid(origin)
401 snap_to_grid(origin, self.editor.margin_origin())
402402 };
403403 Some((*id, origin, size))
404404 }
......@@ -1028,7 +1028,7 @@ impl State {
10281028 };
10291029 self.editor.place_caret(
10301030 &mut self.engine,
1031 snap_to_grid(position),
1031 snap_to_grid(position, self.editor.margin_origin()),
10321032 DEFAULT_OUTLINE_WIDTH,
10331033 )?;
10341034 self.set_object_focus(None);
......@@ -1571,7 +1571,7 @@ impl ApplicationHandler<UserEvent> for App {
15711571 let position = if state.modifiers.alt_key() {
15721572 position
15731573 } else {
1574 snap_to_grid(position)
1574 snap_to_grid(position, state.editor.margin_origin())
15751575 };
15761576 state.editor.place_caret(
15771577 &mut state.engine,
......@@ -2071,9 +2071,9 @@ fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) ->
20712071 }
20722072}
20732073
2074fn snap_to_grid(point: [f32; 2]) -> [f32; 2] {
2074fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] {
20752075 std::array::from_fn(|axis| {
2076 let offset = [0.0, 14.4][axis];
2076 let offset = margin[axis];
20772077 let cell = (point[axis] - offset) / 18.0;
20782078 let nearest = cell.round();
20792079 // Native midpoints remain free; account for the source coordinate's float precision.
......@@ -2816,6 +2816,17 @@ mod tests {
28162816 );
28172817 }
28182818
2819 const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4];
2820
2821 #[test]
2822 fn native_grid_follows_the_page_margin_origin() {
2823 // "av: casual night in the trees": margin 36.75; a 22.5 x 15 pt drag lands one cell on.
2824 let margin = [36.75, 14.4];
2825 let result = snap_to_grid([468.75 + 22.5, 86.4 + 15.0], margin);
2826 assert!((result[0] - 486.75).abs() < 0.0001);
2827 assert!((result[1] - 104.4).abs() < 0.0001);
2828 }
2829
28192830 #[test]
28202831 fn native_grid_matches_drag_offsets_midpoints_and_zoom() {
28212832 for (pixels, points) in [
......@@ -2839,13 +2850,16 @@ mod tests {
28392850 ] {
28402851 let origin = [486.0, 230.40001];
28412852 let proposed = origin.map(|v| v + pixels * 72.0 / 96.0);
2842 let result = snap_to_grid(proposed);
2853 let result = snap_to_grid(proposed, DEFAULT_MARGIN);
28432854 for axis in 0..2 {
28442855 assert!((result[axis] - origin[axis] - points).abs() < 0.00004);
28452856 }
28462857 }
28472858 for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] {
2848 let result = snap_to_grid([491.25 + pixels * 0.75, 235.65 + pixels * 0.75]);
2859 let result = snap_to_grid(
2860 [491.25 + pixels * 0.75, 235.65 + pixels * 0.75],
2861 DEFAULT_MARGIN,
2862 );
28492863 assert!((result[0] - 504.0).abs() < 0.00004);
28502864 assert!((result[1] - 248.4).abs() < 0.00004);
28512865 }
......@@ -2858,14 +2872,14 @@ mod tests {
28582872 let origin = [36.0, 90.0];
28592873 let proposed =
28602874 std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr));
2861 let result = snap_to_grid(proposed);
2875 let result = snap_to_grid(proposed, DEFAULT_MARGIN);
28622876 for axis in 0..2 {
28632877 assert!((result[axis] - expected[axis]).abs() < 0.00004);
28642878 }
28652879 }
28662880 }
28672881 for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] {
2868 assert_eq!(snap_to_grid(point), point);
2882 assert_eq!(snap_to_grid(point, DEFAULT_MARGIN), point);
28692883 }
28702884 }
28712885
......@@ -2888,7 +2902,10 @@ mod tests {
28882902 scale: scale * dpr,
28892903 };
28902904 let point = viewport.document_point([x * dpr, y * dpr]);
2891 let result = snap_to_grid([point[0], point[1] - 7.0 * dpr / viewport.scale]);
2905 let result = snap_to_grid(
2906 [point[0], point[1] - 7.0 * dpr / viewport.scale],
2907 DEFAULT_MARGIN,
2908 );
28922909 for axis in 0..2 {
28932910 assert!((result[axis] - expected[axis]).abs() < 0.00004);
28942911 }
tools/canvas/README.md+1-1
......@@ -68,7 +68,7 @@ Arimo 1.341 and Carlito 1.104 match all 14 recoverable native ASCII wrap control
6868
6969`CanvasEditor::from_page` owns the working page: text outlines, date fields, image payloads and positions, and retained unsupported objects. GPU support is optional. Text has one visible projection and layout per paragraph, with source UTF-16 selections and affinity at wrap boundaries. Title growth shifts eligible body objects across columns in the same undo transaction; shrinking keeps their positions, while undo and IME cancellation restore them. Background images remain fixed. `CanvasEditor::from_outlines` takes owned outlines and their list/tag definitions, preserving source coordinates, widths, indentation and identities. Rendering and editing share `ParagraphLayout` shaping and outline arrangement, including list-marker height and adjacent spacing. Paragraph spacing contributes only between visible paragraphs; leading and trailing spacing properties remain stored without expanding the outline boundary. Collapsed descendants retain source nodes while navigation, hit-testing and selection geometry use visible paragraphs. LF insertion splits flat paragraphs; boundary Backspace/Delete joins them. Replacements stage every affected paragraph's layout before publishing the edit. Undo restores full affected nodes, including their identities, empty styles and reversed selections; untouched paragraphs retain their layouts. Resizing keeps source selection and history while recomputing geometry at the new width.
7070
71The macOS host uses this same editor for typing, pointer/keyboard selection, clipboard text, IME, split/join and undo. Click blank canvas or press Cmd-Shift-N to place a provisional caret with an automatic width capped at 468 pt; the keyboard command places it beside the focused outline or read-only object. The first committed input creates an outline in one undo step; placement alone preserves history and does not dirty the page. Drag an outline's top bar to move it on the default page grid. Hold Option for free pointer placement or dragging; Escape cancels a drag. Use Cmd-Option-arrows (1 pt; Shift makes it 10 pt). Ctrl-Tab and Ctrl-Shift-Tab cycle text outlines and read-only placeholders. A drag previews placement and commits one undo step on release; Escape or loss of window focus cancels the preview. Creation, movement and text edits share one history, while untouched outlines retain their text layouts. Composition can provisionally replace a selection spanning paragraphs; cancellation restores the original paragraphs, selection and redo, while commitment creates one undo step. Vertical navigation retains its preferred column across short or empty paragraphs. Horizontal navigation enters the neighboring paragraph at its visual edge, including when text direction changes.
71The macOS host uses this same editor for typing, pointer/keyboard selection, clipboard text, IME, split/join and undo. Click blank canvas or press Cmd-Shift-N to place a provisional caret with an automatic width capped at 468 pt; the keyboard command places it beside the focused outline or read-only object. The first committed input creates an outline in one undo step; placement alone preserves history and does not dirty the page. Drag an outline's top bar to move it on the page's 18 pt grid, which OneNote anchors at the page's margin origin. Hold Option for free pointer placement or dragging; Escape cancels a drag. Use Cmd-Option-arrows (1 pt; Shift makes it 10 pt). Ctrl-Tab and Ctrl-Shift-Tab cycle text outlines and read-only placeholders. A drag previews placement and commits one undo step on release; Escape or loss of window focus cancels the preview. Creation, movement and text edits share one history, while untouched outlines retain their text layouts. Composition can provisionally replace a selection spanning paragraphs; cancellation restores the original paragraphs, selection and redo, while commitment creates one undo step. Vertical navigation retains its preferred column across short or empty paragraphs. Horizontal navigation enters the neighboring paragraph at its visual edge, including when text direction changes.
7272
7373Core tests compare 220 Unicode range/replacement combinations with plain-text replacement, and compare incremental layout with full recomputation through a 64-edit structural history and an 80-step multi-outline history followed by complete undo/redo. They also exercise hidden-field mapping, paragraph-separator selection, grapheme deletion, multiline IME and failed-layout atomicity. Live macOS captures verify Enter, Backspace join, undo/redo, Option-E dead-key commit and the close guard. Native Pinyin input verifies Escape cancellation over single- and cross-paragraph selections: original text, selection and redo survive. Accepting a candidate across paragraphs commits one undo step. Input-event logs distinguish empty preedit cancellation from candidate commitment. Escape commits the spacing acute accent in both this host and a native TextEdit control; that dead-key case is not evidence of composition cancellation.
7474