authorclover caruso <git@paperclover.net> 2026-10-15 23:28:56-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 23:54:24-07:00
log8bafe305be064627aa2527be1b4c4b45efb185b0
tree1c96dbb11610539a987a1e50a577659b09835659
parentf2300b7f791d89840114e52f007f185d6c858e89
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

feat(mobile): leave drawings out of the Reading View

A page with drawings on it is now offered the Reading View too, unless drawings cover most of it. The drawings stay out of the column, and a small grey line at its end says the page has them and how to see them. They cannot be picked, moved or deleted from the column, and any picked in the page view let go when it opens. Handwriting inside a text box still flows with its text where it fits. Refs #128 Assisted-by: claude-opus-5.5

7 files changed, 295 insertions(+), 23 deletions(-)

crates/canvas/src/gpu/page.rs+7
......@@ -741,10 +741,16 @@ impl PageScene {
741741 }
742742 },
743743 Content::File { .. } => Some((object.file()?.1, true)),
744 Content::Ink(_) if column.is_some() => None,
744745 Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)),
745746 Content::ReadOnly(object) => Some((object.rect(), true)),
746747 Content::Editable(_) => None,
747748 })
749 .chain(
750 column
751 .and_then(crate::reading::Column::drawings_rect)
752 .map(|rect| (rect, true)),
753 )
748754 }
749755
750756 pub(crate) fn header_text<'a>(
......@@ -1043,6 +1049,7 @@ impl PageScene {
10431049 outline(*id, offset, primitives)?;
10441050 continue;
10451051 }
1052 Content::Ink(_) if column.is_some() => continue,
10461053 Content::Ink(ink) => {
10471054 let [x, y] = crate::origin(&ink.layout);
10481055 let [dx, dy] = editor.map_or([0.0; 2], |editor| editor.ink_drag_offset(ink.id));
crates/canvas/src/interaction/accessibility.rs+4-1
......@@ -358,7 +358,10 @@ impl Accessibility {
358358 self.read_only.push((id, node));
359359 }
360360 if let Some(column) = column {
361 for (text, bounds) in scene.header_text(editor, column) {
361 let note = column
362 .drawings_rect()
363 .map(|bounds| (crate::reading::DRAWINGS_NOTE.to_owned(), bounds));
364 for (text, bounds) in scene.header_text(editor, column).chain(note) {
362365 let index = self.read_only.len();
363366 let mut node = Node::new(Role::Label);
364367 node.set_value(text);
crates/canvas/src/interaction/ink.rs+6
......@@ -149,6 +149,9 @@ impl PageView {
149149
150150 /// A press at page point `point`, when a drawing tool takes it or it picks ink.
151151 pub(super) fn ink_pressed(&mut self, point: [f32; 2]) -> Result<Option<Response>> {
152 if self.column.is_some() {
153 return Ok(None);
154 }
152155 let selected = !self.ink.selection.is_empty()
153156 && self
154157 .editor
......@@ -282,6 +285,9 @@ impl PageView {
282285 /// Delete and Backspace delete picked drawings; Escape lets them go, then leaves a
283286 /// drawing tool for Select & Type, as OneNote's Escape does.
284287 pub(super) fn ink_key(&mut self, key: &Key) -> Result<Option<Response>> {
288 if self.column.is_some() {
289 return Ok(None);
290 }
285291 match key {
286292 Key::Named(NamedKey::Backspace | NamedKey::Delete)
287293 if !self.ink.selection.is_empty() =>
crates/canvas/src/interaction/mod.rs+12-1
......@@ -1022,9 +1022,11 @@ impl PageView {
10221022 .ok()
10231023 .flatten()
10241024 });
1025 // The column shows pictures only; one picked in the page view lets go.
1025 // The column only shows pictures and leaves drawings out; what the page view
1026 // picked lets go.
10261027 if self.column.is_some() {
10271028 self.set_object_focus(None);
1029 self.leave_ink();
10281030 }
10291031 self.column.is_some()
10301032 }
......@@ -2985,6 +2987,15 @@ fn page_primitives<'a>(
29852987 &mut primitives,
29862988 )?;
29872989 }
2990 if let Some(([x, y], note)) = column.and_then(|column| column.drawings.as_ref()) {
2991 let [dx, dy] = scene.map_or([0.0; 2], |(_, offset)| *offset);
2992 primitives.push(Primitive::Text {
2993 clip: None,
2994 text: note,
2995 origin: [x + dx, y + dy],
2996 ink: paint.colors.paper.shade(crate::gpu::colorref(0x00767676)),
2997 });
2998 }
29882999 if let Some(ObjectFocus::Image(id)) = object_focus {
29893000 let (origin, size) = match preview {
29903001 Some(PointerFeedback::Image(moving, origin, size)) if moving == id => (origin, size),
crates/canvas/src/interaction/tests.rs+124
......@@ -4055,6 +4055,130 @@ fn reader_shrinks_pictures_to_the_column_in_reading_order_and_leaves_them_alone(
40554055 assert!(!view.set_reading(None) && view.column.is_none());
40564056}
40574057
4058#[test]
4059fn reader_leaves_drawings_out_and_ends_with_a_note_that_the_page_has_them() {
4060 use onestore::page::{Ink, InkStroke, PageObject, ink::ShapeKind};
4061 // A mostly drawn page is a canvas, not offered.
4062 let drawn = scene_view(
4063 include_bytes!("../../../../corpus/ink-edit/drawing/candidate/ink.one"),
4064 "Beside the drawing",
4065 );
4066 assert!(!drawn.reading_status(200.0).0);
4067 let (mut page, _) = reader_columns_page();
4068 let pen = InkStroke {
4069 id: onestore::page::text::new_id().unwrap(),
4070 points: Vec::new(),
4071 width: 2.0,
4072 height: 2.0,
4073 color: None,
4074 transparency: None,
4075 pen_tip: None,
4076 raster_operation: None,
4077 pressure: Vec::new(),
4078 };
4079 let ink = Ink::drawn(ShapeKind::Ellipse, [320.0, 380.0], [400.0, 420.0], &pen).unwrap();
4080 let ink_id = ink.id;
4081 page.objects.push(PageObject::Ink(ink));
4082 let mut view = reader_view(page);
4083 view.viewport.size = [800, 900];
4084 let original = view.editor.page().unwrap();
4085 let strokes = |view: &PageView| {
4086 view.primitives(COLORS)
4087 .unwrap()
4088 .iter()
4089 .filter(|primitive| {
4090 matches!(
4091 primitive,
4092 Primitive::Segment { .. } | Primitive::Taper { .. }
4093 )
4094 })
4095 .count()
4096 };
4097 assert!(strokes(&view) > 0);
4098 // A drawing picked in the page view lets go in the column.
4099 let offset = view.scene.as_ref().unwrap().1;
4100 click(&mut view, [400.0, 400.0], Instant::now());
4101 assert_eq!(view.ink_selection(), [ink_id]);
4102 assert!(view.set_reading(Some(200.0)));
4103 assert!(view.ink_selection().is_empty());
4104 assert_eq!(strokes(&view), 0);
4105 // Where the drawing lies on the page, the column can neither pick, move nor delete it.
4106 for point in [
4107 [400.0, 400.0],
4108 [360.0, 380.0],
4109 [400.0 + offset[0], 400.0 + offset[1]],
4110 ] {
4111 let _ = view.pointer_moved(point).unwrap();
4112 let _ = view.pointer_pressed(Instant::now()).unwrap();
4113 assert!(view.ink_selection().is_empty());
4114 let _ = view
4115 .pointer_moved([point[0] + 30.0, point[1] + 30.0])
4116 .unwrap();
4117 let _ = view.pointer_released().unwrap();
4118 assert!(view.ink_selection().is_empty());
4119 }
4120 let _ = view.key(&Key::Named(NamedKey::Delete), None).unwrap();
4121 let drawing = |page: &Page| {
4122 page.objects
4123 .iter()
4124 .find(|object| object.id() == ink_id)
4125 .cloned()
4126 };
4127 assert!(drawing(&view.editor.page().unwrap()).is_some());
4128 assert_eq!(drawing(&view.editor.page().unwrap()), drawing(&original));
4129 let column = view.column.as_ref().unwrap();
4130 let note = column.drawings_rect().unwrap();
4131 let last = column
4132 .outlines
4133 .iter()
4134 .map(|(_, origin, shaped)| origin[1] + shaped.size[1])
4135 .fold(f32::NEG_INFINITY, f32::max);
4136 assert!(note[1] >= last + 18.0);
4137 assert!(note[2] <= 36.0 + 200.0);
4138 let (_, text) = column.drawings.as_ref().unwrap();
4139 assert!(
4140 view.primitives(COLORS)
4141 .unwrap()
4142 .iter()
4143 .any(|primitive| matches!(
4144 primitive,
4145 Primitive::Text { text: drawn, origin, .. }
4146 if painted_layout(*drawn).id() == text.id()
4147 && *origin == [note[0] + offset[0], note[1] + offset[1]]
4148 ))
4149 );
4150 let bounds: Vec<_> = view
4151 .scene
4152 .as_ref()
4153 .unwrap()
4154 .0
4155 .content_bounds(&view.editor, view.column.as_ref())
4156 .map(|(rect, _)| rect)
4157 .collect();
4158 assert_eq!(
4159 bounds
4160 .iter()
4161 .map(|rect| rect[3])
4162 .fold(f32::NEG_INFINITY, f32::max),
4163 note[3]
4164 );
4165 let mut access = accessibility::Accessibility::default();
4166 let mut tree = None;
4167 let nodes = accessibility::tests::nodes(accessibility::tests::apply(
4168 &mut tree,
4169 access.update(&view, view.viewport, "Reader").unwrap(),
4170 ));
4171 assert!(
4172 nodes
4173 .iter()
4174 .any(|node| node.role() == accesskit::Role::Label
4175 && node.data().value() == Some(crate::reading::DRAWINGS_NOTE))
4176 );
4177 assert!(!view.set_reading(None));
4178 assert!(strokes(&view) > 0);
4179 assert_eq!(drawing(&view.editor.page().unwrap()), drawing(&original));
4180}
4181
40584182#[test]
40594183fn reader_consumers_share_title_header_date_and_body_geometry() {
40604184 let mut view = reader_view(reader_page());
crates/canvas/src/reading.rs+59-21
......@@ -30,10 +30,18 @@ pub(crate) struct Column {
3030 pub(crate) outlines: Vec<(ExGuid, [f32; 2], crate::outline::OutlineLayout)>,
3131 /// Page pictures as the column shows them, `[x0, y0, x1, y1]` in page points.
3232 pub(crate) pictures: Vec<(ExGuid, [f32; 4])>,
33 /// Where the note that the page has drawings, which the column leaves out, starts.
34 pub(crate) drawings: Option<([f32; 2], crate::layout::TextLayout)>,
3335}
3436
3537#[cfg(feature = "gpu")]
3638impl Column {
39 /// The note's `[x0, y0, x1, y1]` in page points.
40 pub(crate) fn drawings_rect(&self) -> Option<[f32; 4]> {
41 let ([x, y], note) = self.drawings.as_ref()?;
42 Some([*x, *y, x + note.shaped.width(), y + note.height()])
43 }
44
3745 /// Where the column shows page picture `id`; None for one it leaves out.
3846 pub(crate) fn picture(&self, id: ExGuid) -> Option<[f32; 4]> {
3947 self.pictures
......@@ -109,6 +117,7 @@ impl Column {
109117 ..
110118 }
111119 | Content::Image(_)
120 | Content::Ink(_)
112121 )
113122 }) {
114123 return Ok(None);
......@@ -127,14 +136,14 @@ impl Column {
127136 .map(|outline| (outline.id, root_tops(outline)))
128137 .collect();
129138 // Emptied outlines, and one being started, keep their place while typing.
130 let ordered = order(
131 blocks(
132 editor,
133 editor.visible_outlines().chain(editor.caret_outline()),
134 ),
135 &tops,
136 &mut Vec::new(),
139 let blocks = blocks(
140 editor,
141 editor.visible_outlines().chain(editor.caret_outline()),
137142 );
143 if drawn_or_arranged(&blocks).is_some() {
144 return Ok(None);
145 }
146 let ordered = order(blocks, &tops, &mut Vec::new());
138147 let rank = |id: ExGuid| {
139148 ordered
140149 .iter()
......@@ -175,6 +184,7 @@ impl Column {
175184 width,
176185 outlines: Vec::new(),
177186 pictures: Vec::new(),
187 drawings: None,
178188 };
179189 let mut top = 14.4;
180190 if let Some(title) = title {
......@@ -212,6 +222,23 @@ impl Column {
212222 };
213223 top += height + GAP;
214224 }
225 if editor
226 .objects
227 .iter()
228 .any(|object| matches!(object, Content::Ink(_)))
229 {
230 let note = engine.layout(
231 &onestore::page::text::Paragraph::new(
232 DRAWINGS_NOTE.into(),
233 onestore::document::Format {
234 font_size: Some(10.0),
235 ..Default::default()
236 },
237 ),
238 width,
239 )?;
240 column.drawings = Some(([36.0, top], note));
241 }
215242 Ok(Some(column))
216243 }
217244}
......@@ -236,7 +263,7 @@ fn column_layout(
236263 let mut nodes = source.document().nodes().to_vec();
237264 for node in &mut nodes {
238265 match &mut node.content {
239 ParagraphContent::Text(_) => {}
266 ParagraphContent::Text(_) | ParagraphContent::Ink(_) => {}
240267 ParagraphContent::Image(image) => {
241268 let indent = crate::outline::indentation(node.level, &source.indents, width)?;
242269 shrink(image, width - indent);
......@@ -271,6 +298,9 @@ fn column_layout(
271298 Ok(fits.then_some(layout))
272299}
273300
301/// Ends a column that leaves out the page's drawings.
302pub(crate) const DRAWINGS_NOTE: &str = "This page has drawings. Turn off Reading View to see them.";
303
274304/// Points between blocks in the column: OneNote's grid row.
275305const GAP: f32 = 18.0;
276306/// Strokes this near one another are one drawing, as handwriting is many strokes.
......@@ -390,19 +420,8 @@ pub fn read(page: &Page, column: f32, engine: &mut TextEngine) -> Result<Reading
390420 page: None,
391421 });
392422 }
393 let area = |kind: Option<Kind>| -> f64 {
394 blocks
395 .iter()
396 .filter(|b| kind.is_none_or(|kind| b.kind == kind))
397 .map(|b| f64::from(b.bounds[2] - b.bounds[0]) * f64::from(b.bounds[3] - b.bounds[1]))
398 .sum()
399 };
400 let total = area(None).max(1.0);
401 if area(Some(Kind::Drawing)) / total >= CANVAS_SHARE {
402 return Ok(refused(Refusal::Drawn, blocks));
403 }
404 if area(Some(Kind::Group)) / total >= CANVAS_SHARE {
405 return Ok(refused(Refusal::Arranged, blocks));
423 if let Some(refusal) = drawn_or_arranged(&blocks) {
424 return Ok(refused(refusal, blocks));
406425 }
407426 let mut kept = Vec::new();
408427 if blocks.iter().any(|b| b.kind == Kind::Group) {
......@@ -438,6 +457,25 @@ pub fn read(page: &Page, column: f32, engine: &mut TextEngine) -> Result<Reading
438457 })
439458}
440459
460/// Why `blocks` make a canvas rather than a document: mostly ink, or mostly overlapping.
461fn drawn_or_arranged(blocks: &[Block]) -> Option<Refusal> {
462 let area = |kind: Option<Kind>| -> f64 {
463 blocks
464 .iter()
465 .filter(|b| kind.is_none_or(|kind| b.kind == kind))
466 .map(|b| f64::from(b.bounds[2] - b.bounds[0]) * f64::from(b.bounds[3] - b.bounds[1]))
467 .sum()
468 };
469 let total = area(None).max(1.0);
470 if area(Some(Kind::Drawing)) / total >= CANVAS_SHARE {
471 Some(Refusal::Drawn)
472 } else if area(Some(Kind::Group)) / total >= CANVAS_SHARE {
473 Some(Refusal::Arranged)
474 } else {
475 None
476 }
477}
478
441479/// Where `outline`'s lines, table cells and pictures lie, in page points.
442480fn marks(outline: &TextOutline) -> Vec<[f32; 4]> {
443481 let [x, y] = outline.origin();
crates/mobile/src/tests.rs+83
......@@ -3482,6 +3482,89 @@ fn reader_shows_a_corpus_pages_picture_no_wider_than_the_column() {
34823482 assert!(pictures[0][2] - pictures[0][0] <= 240.0 / POINT + 0.01);
34833483}
34843484
3485#[test]
3486fn reader_leaves_a_small_drawing_out_and_refuses_a_mostly_drawn_page() {
3487 let reading = |view: &View| {
3488 let query = sb_view_reading(view, 240.0);
3489 let status = serde_json::from_str::<serde_json::Value>(&string(query)).unwrap();
3490 unsafe { sb_string_free(query) };
3491 status
3492 };
3493 let (_directory, _section, drawn) =
3494 reader_corpus_view("ink-edit/drawing/candidate/ink.one", "Beside the drawing");
3495 assert_eq!(reading(&drawn)["offered"], false);
3496 let (directory, section) = features();
3497 let space = section.new_page(None, "Mon", "6:14 AM").unwrap();
3498 let (page, read_only) = section.shared.page(space).unwrap();
3499 let mut view = View {
3500 canvas: ManuallyDrop::new(Canvas::new(space, page, [720, 1280], 2.0).unwrap()),
3501 presentation: None,
3502 frame: Arc::default(),
3503 section: Arc::clone(&section.shared),
3504 read_only,
3505 reload_pending: false,
3506 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
3507 };
3508 view.canvas.set_transform(1.0, [0.0; 2]);
3509 assert!(sb_view_focus_title(&mut view));
3510 assert!(unsafe { sb_insert(&mut view, c"Notes with a doodle".as_ptr()) });
3511 assert!(unsafe { sb_insert(&mut view, c"\n".as_ptr()) });
3512 assert!(unsafe {
3513 sb_insert(
3514 &mut view,
3515 c"A paragraph long enough to fill several lines of the column on a phone, with a small drawing beside it."
3516 .as_ptr(),
3517 )
3518 });
3519 sb_view_set_tool(&mut view, 1, 0, 0);
3520 let stroke =
3521 [[300.0, 400.0], [320.0, 410.0], [340.0, 400.0]].map(|[x, y]| [x * POINT, y * POINT]);
3522 assert!(sb_view_press(&mut view, stroke[0][0], stroke[0][1]));
3523 for [x, y] in &stroke[1..] {
3524 sb_view_drag(&mut view, *x, *y);
3525 }
3526 sb_view_release(&mut view);
3527 sb_view_set_tool(&mut view, 0, 0, 0);
3528 let strokes = |view: &View| {
3529 view.canvas
3530 .page
3531 .primitives(view.canvas.colors())
3532 .unwrap()
3533 .iter()
3534 .filter(|primitive| {
3535 matches!(
3536 primitive,
3537 draw::Primitive::Segment { .. } | draw::Primitive::Taper { .. }
3538 )
3539 })
3540 .count()
3541 };
3542 assert!(strokes(&view) > 0);
3543 assert_eq!(reading(&view)["offered"], true);
3544 let mut anchor = [0.0; 2];
3545 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
3546 assert_eq!(strokes(&view), 0);
3547 // A tap where the drawing lies on the page picks nothing to delete.
3548 assert!(sb_view_press(&mut view, stroke[1][0], stroke[1][1]));
3549 sb_view_release(&mut view);
3550 let mut rect = [0.0; 4];
3551 assert!(!sb_view_ink_selection(&view, &mut rect));
3552 assert!(!sb_view_set_reading(&mut view, 0.0, &mut anchor));
3553 assert!(strokes(&view) > 0);
3554 assert!(section.flush(Duration::from_secs(20)));
3555 let arena = onestore::Arena::default();
3556 let file = directory.path().join("Features.one");
3557 let stored = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
3558 assert!(
3559 stored
3560 .page(space)
3561 .unwrap()
3562 .objects
3563 .iter()
3564 .any(|object| matches!(object, onestore::page::PageObject::Ink(_)))
3565 );
3566}
3567
34853568#[test]
34863569fn reader_local_apply_fence_tracks_overlapping_replies_and_view_lifetime() {
34873570 use std::sync::mpsc;