authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 01:13:25-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
log72cf5607adf5f82934a103defa655f845b225b4a
treedfad6b90aad65883804f0f53b2824902d3ad5d62
parentac71f3406bb294c83e6ad73ba9661192c9c04b99
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: keep an outline's width handle reachable beside the outline above it

On "av: casual night in the trees" the second outline starts 7.5 pt right of the first, so its header and left padding covered the first outline's whole width handle and a press there moved the second outline instead. OneNote 2010 resolves the same overlap by paint order too, but its width grip is wider: SizeWE from 15 px inside to 6 px outside the header's right end, and 6 px either side of the right border along the body. Grips and text now resolve front to back before any outline's padding, because OneNote shows the lower outline's border grip through the upper outline's left padding. Assisted-by: claude-opus-5

2 files changed, 89 insertions(+), 16 deletions(-)

crates/snowbound/src/main.rs+88-15
...@@ -1596,8 +1596,17 @@ fn page_hit_test(...@@ -1596,8 +1596,17 @@ fn page_hit_test(
1596 point: [f32; 2],1596 point: [f32; 2],
1597 pixel: f32,1597 pixel: f32,
1598) -> Option<Hit> {1598) -> Option<Hit> {
1599 let hit = |outline: &TextOutline, offset: [f32; 2], below: bool| {1599 /// Grips and text resolve front to back before any outline's padding, as a native
1600 if below {1600 /// width handle stays reachable under the next outline's left padding; the typing room
1601 /// below an outline comes last.
1602 #[derive(Clone, Copy, PartialEq)]
1603 enum Layer {
1604 Grips,
1605 Body,
1606 Below,
1607 }
1608 let hit = |outline: &TextOutline, offset: [f32; 2], layer: Layer| {
1609 if layer == Layer::Below {
1601 let outline = editor1610 let outline = editor
1602 .outlines()1611 .outlines()
1603 .iter()1612 .iter()
...@@ -1614,20 +1623,34 @@ fn page_hit_test(...@@ -1614,20 +1623,34 @@ fn page_hit_test(
1614 let (bounds, body_top) = outline_chrome(outline, pixel);1623 let (bounds, body_top) = outline_chrome(outline, pixel);
1615 let local = [point[0] - offset[0], point[1] - offset[1]];1624 let local = [point[0] - offset[0], point[1] - offset[1]];
1616 let [x, y] = local;1625 let [x, y] = local;
1617 if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top {1626 if layer == Layer::Grips {
1618 if x >= bounds[2] - 9.0 * pixel {1627 // Width handles: 15 px inside to 6 px outside the header's right end, and 6 px
1628 // either side of the right border below it.
1629 let inside = if y < body_top { 15.0 } else { 6.0 };
1630 if !outline.title
1631 && (bounds[2] - inside * pixel..=bounds[2] + 6.0 * pixel).contains(&x)
1632 && (bounds[1]..=bounds[3]).contains(&y)
1633 {
1619 return Some(Hit::Resize {1634 return Some(Hit::Resize {
1620 id: outline.id,1635 id: outline.id,
1621 grab: point[0] - outline.bounds().x1 as f32,1636 grab: point[0] - outline.bounds().x1 as f32,
1622 });1637 });
1623 }1638 }
1624 return Some(Hit::Handle {1639 if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top {
1625 id: outline.id,1640 return Some(Hit::Handle {
1626 grab: [1641 id: outline.id,
1627 point[0] - outline.origin()[0],1642 grab: [
1628 point[1] - outline.origin()[1],1643 point[0] - outline.origin()[0],
1629 ],1644 point[1] - outline.origin()[1],
1630 });1645 ],
1646 });
1647 }
1648 let text = outline.bounds();
1649 if !(text.x0..=text.x1).contains(&f64::from(x))
1650 || !(text.y0..=text.y1).contains(&f64::from(y))
1651 {
1652 return None;
1653 }
1631 }1654 }
1632 if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3])1655 if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3])
1633 || outline.layouts().any(|(_, paragraph)| {1656 || outline.layouts().any(|(_, paragraph)| {
...@@ -1650,12 +1673,12 @@ fn page_hit_test(...@@ -1650,12 +1673,12 @@ fn page_hit_test(
1650 }1673 }
1651 None1674 None
1652 };1675 };
1653 let pass = |below| {1676 let pass = |layer| {
1654 if let Some(hit) = editor1677 if let Some(hit) = editor
1655 .visible_outlines()1678 .visible_outlines()
1656 .rev()1679 .rev()
1657 .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id))1680 .filter(|outline| scene.is_none() || !editor.has_page_outline(outline.id))
1658 .find_map(|outline| hit(outline, [0.0; 2], below))1681 .find_map(|outline| hit(outline, [0.0; 2], layer))
1659 {1682 {
1660 return Some(hit);1683 return Some(hit);
1661 }1684 }
...@@ -1667,7 +1690,7 @@ fn page_hit_test(...@@ -1667,7 +1690,7 @@ fn page_hit_test(
1667 editor1690 editor
1668 .visible_outlines()1691 .visible_outlines()
1669 .find(|outline| outline.id == id)1692 .find(|outline| outline.id == id)
1670 .and_then(|outline| hit(outline, *offset, below))1693 .and_then(|outline| hit(outline, *offset, layer))
1671 },1694 },
1672 )? {1695 )? {
1673 canvas::gpu::page::SceneHit::Outline(hit) => Some(hit),1696 canvas::gpu::page::SceneHit::Outline(hit) => Some(hit),
...@@ -1675,7 +1698,9 @@ fn page_hit_test(...@@ -1675,7 +1698,9 @@ fn page_hit_test(
1675 canvas::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),1698 canvas::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),
1676 }1699 }
1677 };1700 };
1678 pass(false).or_else(|| pass(true))1701 [Layer::Grips, Layer::Body, Layer::Below]
1702 .into_iter()
1703 .find_map(pass)
1679}1704}
16801705
1681fn page_primitives<'a>(1706fn page_primitives<'a>(
...@@ -2427,6 +2452,54 @@ mod tests {...@@ -2427,6 +2452,54 @@ mod tests {
2427 );2452 );
2428 }2453 }
24292454
2455 #[test]
2456 fn a_covered_width_handle_stays_reachable_beside_the_outline_above_it() {
2457 let mut engine = TextEngine::default();
2458 let pixel = 0.75;
2459 let mut first = TextOutline::new(
2460 &mut engine,
2461 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
2462 100.0,
2463 [36.0, 68.4],
2464 )
2465 .unwrap();
2466 let editor =
2467 CanvasEditor::from_text_outlines(vec![first.clone()], Default::default(), None)
2468 .unwrap();
2469 first = editor.preview_resize(&mut engine, 226.5).unwrap();
2470 let (frame, body_top) = outline_chrome(&first, pixel);
2471 // The casual night page: the next outline starts 7.5 pt right of the first one.
2472 let second = TextOutline::new(
2473 &mut engine,
2474 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
2475 100.0,
2476 [first.bounds().x1 as f32 + 7.5, 68.4],
2477 )
2478 .unwrap();
2479 let (first_id, second_id) = (first.id, second.id);
2480 let editor =
2481 CanvasEditor::from_text_outlines(vec![first, second], Default::default(), None)
2482 .unwrap();
2483 let header = (frame[1] + body_top) / 2.0;
2484 let hit = |x| page_hit_test(&editor, None, [x, header], pixel);
2485 assert!(
2486 matches!(hit(frame[2] - 12.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id)
2487 );
2488 assert!(
2489 matches!(hit(frame[2] - 2.0 * pixel), Some(Hit::Handle { id, .. }) if id == second_id)
2490 );
2491 let body = |x| page_hit_test(&editor, None, [x, body_top + 20.0], pixel);
2492 assert!(
2493 matches!(body(frame[2] - 4.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id)
2494 );
2495 assert!(
2496 matches!(body(frame[2] - 8.0 * pixel), Some(Hit::Text { id, .. }) if id == second_id)
2497 );
2498 assert!(
2499 matches!(body(frame[2] - 12.0 * pixel), Some(Hit::Text { id, .. }) if id == first_id)
2500 );
2501 }
2502
2430 #[test]2503 #[test]
2431 fn grouped_drag_keeps_the_initial_word_when_reversing_direction() {2504 fn grouped_drag_keeps_the_initial_word_when_reversing_direction() {
2432 let selection = |start, end| {2505 let selection = |start, end| {
tools/canvas/README.md+1-1
...@@ -129,7 +129,7 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA...@@ -129,7 +129,7 @@ CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVA
129129
130Unsupported top-level objects, outlines or titles render as read-only placeholders in source order. Missing image payloads or dimensions produce an image-unavailable placeholder. The editor owns the imported source values for those objects; the importer’s `Unsupported` record contains identity, class and layout metadata, not an opaque copy of unparsed notebook bytes. Supported body outlines still enter the editor. If none exist, a provisional caret appears to the right of the placeholders; typing creates an annotation outline.130Unsupported top-level objects, outlines or titles render as read-only placeholders in source order. Missing image payloads or dimensions produce an image-unavailable placeholder. The editor owns the imported source values for those objects; the importer’s `Unsupported` record contains identity, class and layout metadata, not an opaque copy of unparsed notebook bytes. Supported body outlines still enter the editor. If none exist, a provisional caret appears to the right of the placeholders; typing creates an annotation outline.
131131
132Placeholders use source positions and at least 160 pt width, expanding their height to contain the status text. This is an explicit substitute for unavailable rendering, not a geometric reconstruction. Pointer presses focus placeholders, hide the previous text caret/selection and retire its IME composition. Typing, deletion, paste, text-selection commands, undo/redo and outline movement are suppressed while a placeholder has focus. Escape restores the previous text focus; Ctrl-Tab cycles through text outlines and placeholders. Zoom remains available, and Cmd-Shift-N creates an editable annotation beside the focused object. AccessKit exposes placeholder focus, status and bounds without text-edit actions; identities persist across editor updates and viewport changes. Focus reveals offscreen placeholders and suspends caret-blink scheduling. Hit-testing follows paint order for overlapping editable outlines and placeholders: new annotations draw last and receive hits first, while imported objects resolve from front to back. Each body outline’s header, text and tag gutters participate at its own position in that order. Supported titles expose editable text without move/resize handles; decoded images have no editable hit target. Invalid geometry, malformed image data and resource-budget failures remain errors.132Placeholders use source positions and at least 160 pt width, expanding their height to contain the status text. This is an explicit substitute for unavailable rendering, not a geometric reconstruction. Pointer presses focus placeholders, hide the previous text caret/selection and retire its IME composition. Typing, deletion, paste, text-selection commands, undo/redo and outline movement are suppressed while a placeholder has focus. Escape restores the previous text focus; Ctrl-Tab cycles through text outlines and placeholders. Zoom remains available, and Cmd-Shift-N creates an editable annotation beside the focused object. AccessKit exposes placeholder focus, status and bounds without text-edit actions; identities persist across editor updates and viewport changes. Focus reveals offscreen placeholders and suspends caret-blink scheduling. Hit-testing follows paint order for overlapping editable outlines and placeholders: new annotations draw last and receive hits first, while imported objects resolve from front to back. Each body outline’s header, width grips and text participate at its own position in that order before any outline’s padding or tag gutters, so a width handle stays reachable under the left padding of the outline above it. Supported titles expose editable text without move/resize handles; decoded images have no editable hit target. Invalid geometry, malformed image data and resource-budget failures remain errors.
133133
134Document and layout values are read-only outside the editor; edits update their retained geometry together. `CanvasEditor::from_outlines` remains available for text-only construction.134Document and layout values are read-only outside the editor; edits update their retained geometry together. `CanvasEditor::from_outlines` remains available for text-only construction.
135135