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 {...@@ -1401,6 +1401,11 @@ impl CanvasEditor {
1401 self.active_outline().selection1401 self.active_outline().selection
1402 }1402 }
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
1404 pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> {1409 pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> {
1405 let outline = self.active_outline();1410 let outline = self.active_outline();
1406 if outline.layout.reserved_width.or(outline.layout.max_width) == Some(width)1411 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 {...@@ -372,7 +372,7 @@ impl State {
372 if self.modifiers.alt_key() {372 if self.modifiers.alt_key() {
373 position373 position
374 } else {374 } else {
375 snap_to_grid(position)375 snap_to_grid(position, self.editor.margin_origin())
376 },376 },
377 ))377 ))
378 }378 }
...@@ -398,7 +398,7 @@ impl State {...@@ -398,7 +398,7 @@ impl State {
398 let origin = if self.modifiers.alt_key() {398 let origin = if self.modifiers.alt_key() {
399 origin399 origin
400 } else {400 } else {
401 snap_to_grid(origin)401 snap_to_grid(origin, self.editor.margin_origin())
402 };402 };
403 Some((*id, origin, size))403 Some((*id, origin, size))
404 }404 }
...@@ -1028,7 +1028,7 @@ impl State {...@@ -1028,7 +1028,7 @@ impl State {
1028 };1028 };
1029 self.editor.place_caret(1029 self.editor.place_caret(
1030 &mut self.engine,1030 &mut self.engine,
1031 snap_to_grid(position),1031 snap_to_grid(position, self.editor.margin_origin()),
1032 DEFAULT_OUTLINE_WIDTH,1032 DEFAULT_OUTLINE_WIDTH,
1033 )?;1033 )?;
1034 self.set_object_focus(None);1034 self.set_object_focus(None);
...@@ -1571,7 +1571,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -1571,7 +1571,7 @@ impl ApplicationHandler<UserEvent> for App {
1571 let position = if state.modifiers.alt_key() {1571 let position = if state.modifiers.alt_key() {
1572 position1572 position
1573 } else {1573 } else {
1574 snap_to_grid(position)1574 snap_to_grid(position, state.editor.margin_origin())
1575 };1575 };
1576 state.editor.place_caret(1576 state.editor.place_caret(
1577 &mut state.engine,1577 &mut state.engine,
...@@ -2071,9 +2071,9 @@ fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) ->...@@ -2071,9 +2071,9 @@ fn drag_selection(anchor: Selection, target: Selection, unit: SelectionUnit) ->
2071 }2071 }
2072}2072}
20732073
2074fn snap_to_grid(point: [f32; 2]) -> [f32; 2] {2074fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] {
2075 std::array::from_fn(|axis| {2075 std::array::from_fn(|axis| {
2076 let offset = [0.0, 14.4][axis];2076 let offset = margin[axis];
2077 let cell = (point[axis] - offset) / 18.0;2077 let cell = (point[axis] - offset) / 18.0;
2078 let nearest = cell.round();2078 let nearest = cell.round();
2079 // Native midpoints remain free; account for the source coordinate's float precision.2079 // Native midpoints remain free; account for the source coordinate's float precision.
...@@ -2816,6 +2816,17 @@ mod tests {...@@ -2816,6 +2816,17 @@ mod tests {
2816 );2816 );
2817 }2817 }
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
2819 #[test]2830 #[test]
2820 fn native_grid_matches_drag_offsets_midpoints_and_zoom() {2831 fn native_grid_matches_drag_offsets_midpoints_and_zoom() {
2821 for (pixels, points) in [2832 for (pixels, points) in [
...@@ -2839,13 +2850,16 @@ mod tests {...@@ -2839,13 +2850,16 @@ mod tests {
2839 ] {2850 ] {
2840 let origin = [486.0, 230.40001];2851 let origin = [486.0, 230.40001];
2841 let proposed = origin.map(|v| v + pixels * 72.0 / 96.0);2852 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);
2843 for axis in 0..2 {2854 for axis in 0..2 {
2844 assert!((result[axis] - origin[axis] - points).abs() < 0.00004);2855 assert!((result[axis] - origin[axis] - points).abs() < 0.00004);
2845 }2856 }
2846 }2857 }
2847 for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] {2858 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 );
2849 assert!((result[0] - 504.0).abs() < 0.00004);2863 assert!((result[0] - 504.0).abs() < 0.00004);
2850 assert!((result[1] - 248.4).abs() < 0.00004);2864 assert!((result[1] - 248.4).abs() < 0.00004);
2851 }2865 }
...@@ -2858,14 +2872,14 @@ mod tests {...@@ -2858,14 +2872,14 @@ mod tests {
2858 let origin = [36.0, 90.0];2872 let origin = [36.0, 90.0];
2859 let proposed =2873 let proposed =
2860 std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr));2874 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);
2862 for axis in 0..2 {2876 for axis in 0..2 {
2863 assert!((result[axis] - expected[axis]).abs() < 0.00004);2877 assert!((result[axis] - expected[axis]).abs() < 0.00004);
2864 }2878 }
2865 }2879 }
2866 }2880 }
2867 for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] {2881 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);
2869 }2883 }
2870 }2884 }
28712885
...@@ -2888,7 +2902,10 @@ mod tests {...@@ -2888,7 +2902,10 @@ mod tests {
2888 scale: scale * dpr,2902 scale: scale * dpr,
2889 };2903 };
2890 let point = viewport.document_point([x * dpr, y * dpr]);2904 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 );
2892 for axis in 0..2 {2909 for axis in 0..2 {
2893 assert!((result[axis] - expected[axis]).abs() < 0.00004);2910 assert!((result[axis] - expected[axis]).abs() < 0.00004);
2894 }2911 }
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...@@ -68,7 +68,7 @@ Arimo 1.341 and Carlito 1.104 match all 14 recoverable native ASCII wrap control
6868
69`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.69`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
73Core 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.73Core 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