1use super::*;
2use crate::document::{TextDocument, TextPosition};
3use crate::editor::Toggle;
4use crate::gpu::painted_layout;
5use onestore::document::Format;
6use onestore::page::Page;
7use onestore::page::text::Paragraph;
8use std::collections::BTreeSet;
9use std::sync::Arc;
10
11const COLORS: TextColors = TextColors {
12 caret: [0.1, 0.3, 0.9, 1.0],
13 selection: [0.7, 0.8, 1.0, 1.0],
14 paper: crate::gpu::Paper::WHITE,
15};
16
17static NO_ART: std::sync::LazyLock<crate::gpu::TagArt> = std::sync::LazyLock::new(Default::default);
18
19/// Paint for a view at `scale` device pixels per point on a display of `display_scale`.
20pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint<'static> {
21 Paint {
22 caret: f32::from(u8::from(show_caret)),
23 scale,
24 device_origin: [0.0; 2],
25 pixel: display_scale / scale,
26 colors: COLORS,
27 visible: [f32::NEG_INFINITY, f32::INFINITY],
28 chrome: true,
29 found: &[],
30 played: None,
31 spelling: None,
32 tag_art: &NO_ART,
33 }
34}
35
36#[test]
37fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() {
38 let mut engine = TextEngine::default();
39 let mut fields = [vec!["Header"], vec!["Monday", "6:14 AM"]].map(|text| {
40 TextOutline::new(
41 &mut engine,
42 TextDocument::new(
43 text.into_iter()
44 .map(|text| Paragraph::new(text.into(), Default::default()))
45 .collect(),
46 )
47 .unwrap(),
48 468.0,
49 [0.0; 2],
50 )
51 .unwrap()
52 .snapshot()
53 });
54 fields[0].title = true;
55 let page = Page {
56 identity: None,
57 title: "Header".into(),
58 created: Some(1),
59 margin_origin: [36.0, 14.4],
60 rtl: false,
61 color: None,
62 rule_lines: None,
63 definitions: Default::default(),
64 objects: vec![onestore::page::PageObject::Title(onestore::page::Title {
65 id: onestore::ExGuid::default(),
66 date: Some(fields[1].id),
67 layout: Default::default(),
68 outlines: fields.into(),
69 })],
70 };
71 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
72 let scene = (scene, [20.0, 40.0]);
73 let viewport = Viewport {
74 size: [1000, 700],
75 scale: 2.0,
76 origin: [-30.0, -50.0],
77 };
78 let mut access = accessibility::Accessibility::default();
79 let mut tree = None;
80 let mut identities = Vec::new();
81 for phase in 0..3 {
82 if phase == 1 {
83 editor
84 .insert(&mut engine, &"A wrapped title ".repeat(25))
85 .unwrap();
86 }
87 if phase == 2 {
88 editor
89 .change_date(&mut engine, 2, ["Wednesday".into(), "8:40 AM".into()])
90 .unwrap();
91 }
92 let update = access
93 .update_with(&editor, Some(&scene), viewport, "Header", None, None, None)
94 .unwrap();
95 let buttons: Vec<_> =
96 accessibility::tests::nodes(accessibility::tests::apply(&mut tree, update))
97 .into_iter()
98 .filter(|node| node.role() == accesskit::Role::Button)
99 .collect();
100 assert_eq!(buttons.len(), 2);
101 for (index, node) in buttons.iter().enumerate() {
102 let id = node.locate().0;
103 let field = [DateField::Date, DateField::Time][index];
104 assert_eq!(access.date_for_node(id), Some(field));
105 assert!(node.data().supports_action(accesskit::Action::Click));
106 let rect = node.bounding_box().unwrap();
107 let point = viewport.document_point([
108 ((rect.x0 + rect.x1) * 0.5) as f32,
109 ((rect.y0 + rect.y1) * 0.5) as f32,
110 ]);
111 assert_eq!(
112 page_hit_test(&editor, Some(&scene), point, 0.5),
113 Some(Hit::Date(field))
114 );
115 if phase == 0 {
116 identities.push(id);
117 } else {
118 assert_eq!(id, identities[index]);
119 }
120 }
121 if phase == 2 {
122 assert_eq!(buttons[1].data().value(), Some("8:40 AM"));
123 }
124 }
125}
126
127#[test]
128fn title_chrome_selects_text_and_exposes_a_named_editable_field() {
129 let mut engine = TextEngine::default();
130 let mut source = TextOutline::new(
131 &mut engine,
132 TextDocument::new(vec![Paragraph::new(
133 "Header".into(),
134 Format {
135 font_size: Some(8.0),
136 line_spacing: Some(20.751953),
137 ..Default::default()
138 },
139 )])
140 .unwrap(),
141 468.0,
142 [36.0, 14.4],
143 )
144 .unwrap()
145 .snapshot();
146 source.title = true;
147 source.min_width = Some(162.0);
148 source.layout.max_width = None;
149 source.layout.width_set_by_user = None;
150 source.layout.max_height = Some(21.6);
151 let outline = TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap();
152 let editor = CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
153 assert_eq!(editor.active_outline().bounds().width(), 162.0);
154 assert_eq!(editor.active_outline().snapshot().min_width, Some(162.0));
155 let (native_rect, _) = outline_chrome(editor.active_outline(), 0.75);
156 // Unlike a body outline's, the title's box scales with zoom (OneNote 2010 at 200%).
157 assert_eq!(
158 outline_chrome(editor.active_outline(), 0.375).0,
159 native_rect
160 );
161 for (axis, (actual, expected)) in native_rect
162 .iter()
163 .zip([85.0, 98.0, 315.0, 134.0])
164 .enumerate()
165 {
166 let origin = if axis % 2 == 0 { 48.0 } else { 83.0 };
167 assert!((actual / 0.75 + origin - expected).abs() < 1.5);
168 }
169 let id = editor.active_outline().id;
170 let (rect, _) = outline_chrome(editor.active_outline(), 1.0);
171 for point in [
172 [rect[0] + 1.0, rect[1] + 1.0],
173 [rect[2] - 1.0, rect[1] + 1.0],
174 [40.0, 20.0],
175 ] {
176 assert!(
177 matches!(page_hit_test(&editor, None, point, 1.0), Some(Hit::Text { id: hit, .. }) if hit == id)
178 );
179 }
180 let primitives =
181 page_primitives(&editor, None, None, None, None, paint(false, 1.0, 1.0)).unwrap();
182 let borders = primitives
183 .iter()
184 .filter_map(|p| match p {
185 Primitive::RoundedRect { stroke, .. } => Some(*stroke),
186 _ => None,
187 })
188 .collect::<Vec<_>>();
189 assert_eq!(borders, [Some(Stroke::Dashed(1.0))]);
190 assert!(primitives.iter().any(|primitive| matches!(primitive, Primitive::RoundedRect { rect, radius, stroke: Some(Stroke::Dashed(_)), .. } if radius[0] == 4.5 && radius[1] * 2.0 == rect[3] - rect[1])));
191 let mut access = accessibility::Accessibility::default();
192 let update = access
193 .update_with(
194 &editor,
195 None,
196 Viewport {
197 size: [800, 600],
198 scale: 1.0,
199 origin: [0.0; 2],
200 },
201 "Header",
202 None,
203 None,
204 None,
205 )
206 .unwrap();
207 assert!(update.nodes.iter().any(|(_, node)| node.role()
208 == accesskit::Role::MultilineTextInput
209 && node.label() == Some("Page title")));
210}
211
212#[test]
213fn title_frame_shows_unfocused_and_its_pen_scales_only_when_zoomed_in() {
214 let mut engine = TextEngine::default();
215 let mut outline = |text: &str, origin, title| {
216 let mut source = TextOutline::new(
217 &mut engine,
218 TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap(),
219 468.0,
220 origin,
221 )
222 .unwrap()
223 .snapshot();
224 source.title = title;
225 TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap()
226 };
227 let title = outline("Title", [36.0, 14.4], true);
228 let body = outline("Body", [36.0, 120.0], false);
229 let body_id = body.id;
230 let mut editor =
231 CanvasEditor::from_text_outlines(vec![title, body], Default::default(), None).unwrap();
232 editor.focus_outline(body_id).unwrap();
233 // OneNote 2010: a 4 px dash period at 100% and below, 4 px times the zoom above it.
234 for (zoom, pen) in [(0.5, 1.5), (1.0, 0.75), (2.0, 0.75), (3.0, 0.75)] {
235 let scale = zoom * 96.0 / 72.0;
236 let primitives =
237 page_primitives(&editor, None, None, None, None, paint(false, scale, 1.0)).unwrap();
238 let dashed = primitives
239 .iter()
240 .filter_map(|primitive| match primitive {
241 Primitive::RoundedRect {
242 stroke: Some(Stroke::Dashed(width)),
243 ..
244 } => Some(*width),
245 _ => None,
246 })
247 .collect::<Vec<_>>();
248 assert!(
249 matches!(dashed[..], [width] if (width - pen).abs() < 1e-5),
250 "zoom {zoom}: {dashed:?}"
251 );
252 }
253}
254
255#[test]
256fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
257 let mut engine = TextEngine::default();
258 let mut editor = CanvasEditor::new(
259 &mut engine,
260 TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(),
261 240.0,
262 )
263 .unwrap();
264 let id = editor.active_outline().id;
265 for _ in 0..2 {
266 editor
267 .move_selection(&mut engine, Movement::Down, false)
268 .unwrap();
269 }
270 let primitives =
271 page_primitives(&editor, None, None, None, None, paint(true, 1.0, 1.0)).unwrap();
272 assert_eq!(
273 primitives
274 .iter()
275 .filter(|p| matches!(p, Primitive::Text { .. }))
276 .count(),
277 3
278 );
279 let caret = editor.caret(1.0).unwrap();
280 assert!(
281 matches!(page_hit_test(&editor, None, [caret.x0 as f32, ((caret.y0 + caret.y1) * 0.5) as f32], 1.0), Some(Hit::Text { id: hit, .. }) if hit == id)
282 );
283 let mut access = accessibility::Accessibility::default();
284 let update = access
285 .update_with(
286 &editor,
287 None,
288 Viewport {
289 size: [800, 600],
290 scale: 1.0,
291 origin: [0.0; 2],
292 },
293 "Test",
294 None,
295 None,
296 None,
297 )
298 .unwrap();
299 let fields = update
300 .nodes
301 .iter()
302 .filter(|(_, node)| node.role() == accesskit::Role::MultilineTextInput)
303 .collect::<Vec<_>>();
304 assert_eq!(fields.len(), 1);
305 assert_eq!(fields[0].1.value(), Some("body\n\n"));
306 assert_eq!(editor.outlines()[0].document().nodes().len(), 1);
307 editor
308 .place_caret(&mut engine, [300.0, 200.0], 240.0)
309 .unwrap();
310 assert_eq!(
311 editor
312 .visible_outlines()
313 .next()
314 .unwrap()
315 .document()
316 .nodes()
317 .len(),
318 1
319 );
320}
321
322#[test]
323fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
324 let mut engine = TextEngine::default();
325 let mut editor = CanvasEditor::new(
326 &mut engine,
327 TextDocument::new(vec![Paragraph::new(String::new(), Default::default())]).unwrap(),
328 240.0,
329 )
330 .unwrap();
331 editor
332 .place_caret(&mut engine, [120.0, 100.0], 240.0)
333 .unwrap();
334 let mut access = accessibility::Accessibility::default();
335 let viewport = Viewport {
336 size: [1000, 720],
337 scale: 96.0 / 72.0,
338 origin: [48.0; 2],
339 };
340 let initial = access
341 .update_with(&editor, None, viewport, "Page", None, None, None)
342 .unwrap();
343 let id = editor.active_outline().id;
344 assert_eq!(access.outline_for_node(initial.focus), Some(id));
345 let rectangles = |editor: &CanvasEditor| {
346 page_primitives(
347 editor,
348 None,
349 None,
350 None,
351 None,
352 paint(true, viewport.scale, 1.0),
353 )
354 .unwrap()
355 .into_iter()
356 .filter_map(|p| match p {
357 Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => {
358 Some((rect, color))
359 }
360 _ => None,
361 })
362 .collect::<Vec<_>>()
363 };
364 assert_eq!(rectangles(&editor).len(), 1);
365 assert_eq!(
366 page_hit_test(&editor, None, [120.0, 100.0], 1.0 / viewport.scale),
367 None
368 );
369 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
370 assert!(editor.outlines().is_empty());
371 assert!(
372 rectangles(&editor)
373 .iter()
374 .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0] || *color == COLORS.caret)
375 );
376 let preedit = access
377 .update_with(&editor, None, viewport, "Page", None, None, None)
378 .unwrap();
379 assert_eq!(preedit.focus, initial.focus);
380 editor.commit_text(&mut engine, "日本".into()).unwrap();
381 let committed = access
382 .update_with(&editor, None, viewport, "Page", None, None, None)
383 .unwrap();
384 assert_eq!(committed.focus, initial.focus);
385 assert!(rectangles(&editor).len() > 1);
386 editor.undo(&mut engine).unwrap();
387 assert!(editor.outlines().is_empty());
388 assert_eq!(rectangles(&editor).len(), 1);
389 let empty = access
390 .update_with(&editor, None, viewport, "Page", None, None, None)
391 .unwrap();
392 assert_eq!(empty.focus, committed.focus);
393 assert_eq!(access.outline_for_node(committed.focus), Some(id));
394 editor.redo(&mut engine).unwrap();
395 editor.select_all().unwrap();
396 editor.delete(&mut engine, true).unwrap();
397 assert!(editor.outlines().is_empty());
398 assert_eq!(rectangles(&editor).len(), 1);
399 let retired = access
400 .update_with(&editor, None, viewport, "Page", None, None, None)
401 .unwrap();
402 assert_eq!(retired.focus, committed.focus);
403 assert_eq!(access.outline_for_node(retired.focus), Some(id));
404 editor.undo(&mut engine).unwrap();
405 assert!(rectangles(&editor).len() > 1);
406 let restored = access
407 .update_with(&editor, None, viewport, "Page", None, None, None)
408 .unwrap();
409 assert_eq!(restored.focus, committed.focus);
410 editor.redo(&mut engine).unwrap();
411 assert_eq!(rectangles(&editor).len(), 1);
412 assert!(editor.caret_outline().unwrap().is_empty());
413 editor.insert(&mut engine, " ").unwrap();
414 assert!(editor.caret_outline().unwrap().is_empty());
415 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
416 assert!(!editor.caret_outline().unwrap().is_empty());
417}
418
419#[test]
420#[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
421fn imported_page_widgets_and_accessibility_follow_edits() {
422 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
423 let page = {
424 let store = onestore::Store::parse(&bytes).unwrap();
425 let index = onestore::RevisionIndex::parse(&store).unwrap();
426 Page::from_document(
427 &onestore::document::Document::parse(&index).unwrap(),
428 &std::env::var("CANVAS_TEST_PAGE").unwrap(),
429 )
430 .unwrap()
431 };
432 drop(bytes);
433 let mut engine = TextEngine::default();
434 if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") {
435 engine
436 .register_substitute(parley::fontique::Blob::new(Arc::new(
437 std::fs::read(font).unwrap(),
438 )))
439 .unwrap();
440 }
441 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
442 let scene = (scene, [0.0; 2]);
443 let viewport = Viewport {
444 size: [1000, 720],
445 scale: 96.0 / 72.0,
446 origin: [48.0; 2],
447 };
448 let mut tag_hits = 0;
449 for outline in editor.outlines() {
450 for (_, paragraph) in outline.layouts() {
451 for tag in &paragraph.tags {
452 let point = [
453 outline.origin()[0] + tag.origin[0] + crate::outline::ParagraphTag::SIZE / 2.0,
454 outline.origin()[1]
455 + paragraph.origin[1]
456 + tag.origin[1]
457 + crate::outline::ParagraphTag::SIZE / 2.0,
458 ];
459 assert!(
460 matches!(page_hit_test(&editor, Some(&scene), point, 1.0), Some(Hit::Text { id, .. }) if id == outline.id),
461 "Tag center {point:?} did not target outline {:?}",
462 outline.id
463 );
464 tag_hits += 1;
465 }
466 }
467 }
468 eprintln!("Verified {tag_hits} imported tag-gutter targets");
469 let mut access = accessibility::Accessibility::default();
470 let mut tree = None;
471 let tags = |editor: &CanvasEditor| {
472 page_primitives(
473 editor,
474 Some(&scene),
475 None,
476 None,
477 None,
478 paint(true, viewport.scale, 1.0),
479 )
480 .unwrap()
481 .into_iter()
482 .filter_map(|primitive| match primitive {
483 Primitive::Icon {
484 sources,
485 origin,
486 tint,
487 ..
488 } => Some(((sources, tint[3]), origin)),
489 _ => None,
490 })
491 .collect::<Vec<_>>()
492 };
493 let original_tags = tags(&editor);
494 let ids: Vec<_> = editor.outlines().iter().map(|outline| outline.id).collect();
495 for id in ids {
496 editor.focus_outline(id).unwrap();
497 let original = editor.active_outline().document().clone();
498 editor
499 .select(
500 [crate::document::TextPosition {
501 paragraph: 0,
502 offset: 0,
503 }; 2]
504 .into(),
505 )
506 .unwrap();
507 let before = access
508 .update_with(&editor, None, viewport, "Test", None, None, None)
509 .unwrap();
510 let before = accessibility::tests::apply(&mut tree, before)
511 .focus()
512 .unwrap()
513 .document_range()
514 .text();
515 editor.insert(&mut engine, "CANVAS CHECK ").unwrap();
516 {
517 let update = access
518 .update_with(&editor, None, viewport, "Test", None, None, None)
519 .unwrap();
520 assert_eq!(
521 accessibility::tests::apply(&mut tree, update)
522 .focus()
523 .unwrap()
524 .document_range()
525 .text(),
526 format!("CANVAS CHECK {before}")
527 );
528 let primitives = page_primitives(
529 &editor,
530 Some(&scene),
531 None,
532 None,
533 None,
534 paint(true, viewport.scale, 1.0),
535 )
536 .unwrap();
537 assert!(!primitives.is_empty());
538 let edited_tags = tags(&editor);
539 assert_eq!(edited_tags.len(), original_tags.len());
540 for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) {
541 assert_eq!(edited, original);
542 }
543 }
544 editor.undo(&mut engine).unwrap();
545 assert_eq!(editor.active_outline().document(), &original);
546 assert_eq!(tags(&editor), original_tags);
547 let update = access
548 .update_with(&editor, None, viewport, "Test", None, None, None)
549 .unwrap();
550 assert_eq!(
551 accessibility::tests::apply(&mut tree, update)
552 .focus()
553 .unwrap()
554 .document_range()
555 .text(),
556 before
557 );
558 }
559 eprintln!(
560 "Rendered and restored {} imported outline widgets and accessibility fields",
561 editor.outlines().len()
562 );
563}
564
565#[test]
566fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {
567 let mut engine = TextEngine::default();
568 let upper = TextOutline::new(
569 &mut engine,
570 TextDocument::new(vec![Paragraph::new("Upper".into(), Default::default())]).unwrap(),
571 240.0,
572 [36.0, 90.0],
573 )
574 .unwrap();
575 let bottom = upper.bounds().y1 as f32;
576 let id = upper.id;
577 let lower = TextOutline::new(
578 &mut engine,
579 TextDocument::new(vec![Paragraph::new("Lower".into(), Default::default())]).unwrap(),
580 240.0,
581 [36.0, bottom + 20.0],
582 )
583 .unwrap();
584 let lower_id = lower.id;
585 let source = upper.snapshot();
586 let mut editor =
587 CanvasEditor::from_text_outlines(vec![lower, upper], Default::default(), None).unwrap();
588 assert!(
589 matches!(page_hit_test(&editor, None, [60.0, bottom + 22.0], 0.75), Some(Hit::Text { id, .. }) if id == lower_id)
590 );
591 editor.move_outline(lower_id, [600.0, 500.0]).unwrap();
592 for pixel in [0.375, 0.75, 1.5] {
593 let hit = page_hit_test(&editor, None, [60.0, bottom + 26.0], pixel).unwrap();
594 assert!(
595 matches!(hit, Hit::Text { id: hit_id, point } if hit_id == id && (point[1] - (bottom + 26.0 - 90.0)).abs() < 0.001)
596 );
597 assert_eq!(
598 page_hit_test(&editor, None, [60.0, bottom + 30.0], pixel),
599 None
600 );
601 }
602 let page = onestore::page::Page {
603 identity: None,
604 title: String::new(),
605 created: None,
606 margin_origin: [0.0; 2],
607 rtl: false,
608 color: None,
609 rule_lines: None,
610 definitions: Default::default(),
611 objects: vec![onestore::page::PageObject::Outline(source)],
612 };
613 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
614 let offset = [50.0, 300.0];
615 assert!(
616 matches!(page_hit_test(&editor, Some(&(scene, offset)), [60.0 + offset[0], bottom + 26.0 + offset[1]], 0.75), Some(Hit::Text { id: hit_id, .. }) if hit_id == id)
617 );
618}
619
620#[test]
621fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() {
622 let mut engine = TextEngine::default();
623 let outlines = [[0.0, 0.0], [220.0, 0.0]]
624 .into_iter()
625 .map(|origin| {
626 TextOutline::new(
627 &mut engine,
628 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
629 100.0,
630 origin,
631 )
632 .unwrap()
633 })
634 .collect();
635 let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap();
636 let second = editor.outlines()[1].id;
637 let borders = |feedback| {
638 page_primitives(&editor, None, None, feedback, None, paint(false, 1.0, 1.0))
639 .unwrap()
640 .into_iter()
641 .filter_map(|p| match p {
642 Primitive::RoundedRect {
643 rect,
644 stroke: Some(Stroke::Solid(1.0)),
645 ..
646 } => Some(rect),
647 _ => None,
648 })
649 .collect::<Vec<_>>()
650 };
651 assert_eq!(borders(None).len(), 1);
652 let hovered = borders(Some(PointerFeedback::Hover(second)));
653 assert_eq!(hovered.len(), 2);
654 let moved = borders(Some(PointerFeedback::Move(second, [20.0, 50.0])));
655 assert_eq!(moved[1][0] - hovered[1][0], 20.0);
656 assert_eq!(moved[1][1] - hovered[1][1], 50.0);
657 assert!(
658 matches!(page_hit_test(&editor, None, [225.0, 5.0], 1.0), Some(Hit::Text { id, .. }) if id == second)
659 );
660}
661
662#[test]
663fn a_covered_width_handle_stays_reachable_beside_the_outline_above_it() {
664 let mut engine = TextEngine::default();
665 let pixel = 0.75;
666 let mut first = TextOutline::new(
667 &mut engine,
668 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
669 100.0,
670 [36.0, 68.4],
671 )
672 .unwrap();
673 let editor =
674 CanvasEditor::from_text_outlines(vec![first.clone()], Default::default(), None).unwrap();
675 first = editor.preview_resize(&mut engine, 226.5).unwrap();
676 let (frame, body_top) = outline_chrome(&first, pixel);
677 // The casual night page: the next outline starts 7.5 pt right of the first one.
678 let second = TextOutline::new(
679 &mut engine,
680 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
681 100.0,
682 [first.bounds().x1 as f32 + 7.5, 68.4],
683 )
684 .unwrap();
685 let (first_id, second_id) = (first.id, second.id);
686 let editor =
687 CanvasEditor::from_text_outlines(vec![first, second], Default::default(), None).unwrap();
688 let header = (frame[1] + body_top) / 2.0;
689 let hit = |x| page_hit_test(&editor, None, [x, header], pixel);
690 assert!(matches!(hit(frame[2] - 12.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id));
691 assert!(matches!(hit(frame[2] - 2.0 * pixel), Some(Hit::Handle { id, .. }) if id == second_id));
692 let body = |x| page_hit_test(&editor, None, [x, body_top + 20.0], pixel);
693 assert!(matches!(body(frame[2] - 4.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id));
694 assert!(matches!(body(frame[2] - 8.0 * pixel), Some(Hit::Text { id, .. }) if id == second_id));
695 assert!(matches!(body(frame[2] - 12.0 * pixel), Some(Hit::Text { id, .. }) if id == first_id));
696}
697
698#[test]
699fn a_picture_in_an_outline_takes_the_click_over_its_text() {
700 use onestore::page::{Image, Page, PageObject, ParagraphContent};
701 let mut engine = TextEngine::default();
702 let mut source = TextOutline::new(
703 &mut engine,
704 TextDocument::new(vec![
705 Paragraph::new("Before".into(), Default::default()),
706 Paragraph::new("After".into(), Default::default()),
707 ])
708 .unwrap(),
709 240.0,
710 [36.0, 36.0],
711 )
712 .unwrap()
713 .snapshot();
714 let mut picture = source.paragraphs[0].clone();
715 picture.id = onestore::page::text::new_id().unwrap();
716 let id = onestore::page::text::new_id().unwrap();
717 picture.content = ParagraphContent::Image(Image {
718 size: Some([40.0, 30.0]),
719 id,
720 layout: Default::default(),
721 bytes: None,
722 display: None,
723 alt: None,
724 background: false,
725 printout: None,
726 tags: Vec::new(),
727 link: None,
728 text: None,
729 });
730 source.paragraphs.insert(1, picture);
731 let editor = CanvasEditor::from_page(
732 Page {
733 title: String::new(),
734 identity: None,
735 created: None,
736 margin_origin: [36.0, 14.4],
737 rtl: false,
738 color: None,
739 rule_lines: None,
740 definitions: Default::default(),
741 objects: vec![PageObject::Outline(source)],
742 },
743 &mut engine,
744 )
745 .unwrap();
746 let (origin, size) = editor.image_placement(id).unwrap();
747 let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0];
748 assert_eq!(
749 page_hit_test(&editor, None, center, 1.0),
750 Some(Hit::Image { id, handle: [0, 0] })
751 );
752 assert!(matches!(
753 page_hit_test(&editor, None, [origin[0] + 2.0, origin[1] - 4.0], 1.0),
754 Some(Hit::Text { .. })
755 ));
756}
757
758#[test]
759fn picture_handles_resize_as_onenote_does() {
760 // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees".
761 let (origin, size) = ([468.0, 86.4], [333.0, 200.1]);
762 let (corner_origin, corner) = resize_image(origin, size, [1, -1], [-75.0, 30.75]);
763 assert!((corner[0] - 281.827).abs() < 0.01 && (corner[1] - 169.351).abs() < 0.01);
764 assert!((corner_origin[1] - 117.15).abs() < 0.01 && corner_origin[0] == 468.0);
765 assert_eq!(
766 resize_image(origin, size, [1, 0], [46.5, 10.0]),
767 (origin, [379.5, 200.1])
768 );
769 let (left_origin, left) = resize_image(origin, size, [-1, 0], [400.0, 0.0]);
770 assert_eq!(left, [1.0, 200.1]);
771 assert_eq!(left_origin[0], 468.0 + 333.0 - 1.0);
772 let pixel = 0.75;
773 let rect = image_rect(origin, size);
774 assert_eq!(
775 image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75], 0.0),
776 Some([-1, -1])
777 );
778 assert_eq!(
779 image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75], 0.0),
780 Some([0, 1])
781 );
782 assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0], 0.0), None);
783}
784
785const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4];
786
787#[test]
788fn native_grid_follows_the_page_margin_origin() {
789 // "av: casual night in the trees": margin 36.75; a 22.5 x 15 pt drag lands one cell on.
790 let margin = [36.75, 14.4];
791 let result = snap_to_grid([468.75 + 22.5, 86.4 + 15.0], margin);
792 assert!((result[0] - 486.75).abs() < 0.0001);
793 assert!((result[1] - 104.4).abs() < 0.0001);
794}
795
796#[test]
797fn native_grid_matches_drag_offsets_midpoints_and_zoom() {
798 for (pixels, points) in [
799 (5.0, 0.0),
800 (6.0, 0.0),
801 (7.0, 0.0),
802 (8.0, 0.0),
803 (11.0, 0.0),
804 (12.0, 9.0),
805 (13.0, 18.0),
806 (17.0, 18.0),
807 (18.0, 18.0),
808 (19.0, 18.0),
809 (25.0, 18.0),
810 (37.0, 36.0),
811 (-6.0, 0.0),
812 (-7.0, 0.0),
813 (-12.0, -9.0),
814 (-13.0, -18.0),
815 (-19.0, -18.0),
816 ] {
817 let origin = [486.0, 230.40001];
818 let proposed = origin.map(|v| v + pixels * 72.0 / 96.0);
819 let result = snap_to_grid(proposed, DEFAULT_MARGIN);
820 for axis in 0..2 {
821 assert!((result[axis] - origin[axis] - points).abs() < 0.00004);
822 }
823 }
824 for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] {
825 let result = snap_to_grid(
826 [491.25 + pixels * 0.75, 235.65 + pixels * 0.75],
827 DEFAULT_MARGIN,
828 );
829 assert!((result[0] - 504.0).abs() < 0.00004);
830 assert!((result[1] - 248.4).abs() < 0.00004);
831 }
832 for (scale, delta, expected) in [
833 (96.0 / 72.0, [27.0, 31.0], [54.0, 104.4]),
834 (96.0 / 72.0, [13.0, 17.0], [54.0, 104.4]),
835 (192.0 / 72.0, [13.0, 17.0], [36.0, 104.4]),
836 ] {
837 for dpr in [1.0, 2.0] {
838 let origin = [36.0, 90.0];
839 let proposed =
840 std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr));
841 let result = snap_to_grid(proposed, DEFAULT_MARGIN);
842 for axis in 0..2 {
843 assert!((result[axis] - expected[axis]).abs() < 0.00004);
844 }
845 }
846 }
847 for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] {
848 assert_eq!(snap_to_grid(point, DEFAULT_MARGIN), point);
849 }
850}
851
852#[test]
853fn native_fresh_click_grid_uses_screen_offset_at_both_zoom_levels() {
854 for (x, y, scale, expected) in [
855 (900.0, 500.0, 96.0 / 72.0, [639.0, 302.4]),
856 (901.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
857 (906.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
858 (913.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
859 (900.0, 501.0, 96.0 / 72.0, [639.0, 302.4]),
860 (900.0, 506.0, 96.0 / 72.0, [639.0, 320.4]),
861 (900.0, 520.0, 96.0 / 72.0, [639.0, 320.4]),
862 (900.0, 500.0, 192.0 / 72.0, [324.0, 158.4]),
863 ] {
864 for dpr in [1.0, 2.0] {
865 let viewport = Viewport {
866 size: [(1600.0 * dpr) as u32, (900.0 * dpr) as u32],
867 origin: [48.0 * dpr, 83.0 * dpr],
868 scale: scale * dpr,
869 };
870 let point = viewport.document_point([x * dpr, y * dpr]);
871 let result = snap_to_grid(
872 [point[0], point[1] - 7.0 * dpr / viewport.scale],
873 DEFAULT_MARGIN,
874 );
875 for axis in 0..2 {
876 assert!((result[axis] - expected[axis]).abs() < 0.00004);
877 }
878 }
879 }
880}
881
882#[test]
883fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
884 let mut engine = TextEngine::default();
885 let mut editor = CanvasEditor::new(
886 &mut engine,
887 TextDocument::new(vec![Paragraph::new(
888 "M".into(),
889 Format {
890 font_size: Some(130.0),
891 highlight: Some(0xffff),
892 ..Default::default()
893 },
894 )])
895 .unwrap(),
896 180.0,
897 )
898 .unwrap();
899 editor
900 .move_selection(&mut engine, Movement::LineEnd, false)
901 .unwrap();
902 editor.tab(&mut engine, false).unwrap();
903 editor.insert(&mut engine, "R").unwrap();
904 // Columns dragged narrower than the glyphs they hold.
905 let table = editor.active_outline().shaped().tables[0].id;
906 for column in 0..2 {
907 editor
908 .resize_column(&mut engine, table, column, 37.11)
909 .unwrap();
910 }
911 editor.tab(&mut engine, true).unwrap();
912 let mut primitives = Vec::new();
913 append_outline(
914 Some(&editor),
915 false,
916 crate::reading::geometry(editor.active_outline(), None).0,
917 [24.0, 48.0],
918 Paint {
919 caret: 0.0,
920 scale: 1.0,
921 device_origin: [0.0; 2],
922 pixel: 1.0,
923 colors: COLORS,
924 visible: [f32::NEG_INFINITY, f32::INFINITY],
925 chrome: true,
926 found: &[],
927 played: None,
928 spelling: None,
929 tag_art: &NO_ART,
930 },
931 &mut primitives,
932 )
933 .unwrap();
934 let bounds = editor.active_outline().shaped().tables[0]
935 .cells
936 .iter()
937 .map(|cell| {
938 let [left, top, right, bottom] = cell.text_bounds();
939 [left + 24.0, top + 48.0, right + 24.0, bottom + 48.0]
940 })
941 .collect::<Vec<_>>();
942 let text = primitives
943 .iter()
944 .filter_map(|primitive| match primitive {
945 Primitive::Text { clip, .. } => Some(clip.unwrap()),
946 _ => None,
947 })
948 .collect::<Vec<_>>();
949 assert_eq!(text, bounds);
950 let highlights = primitives
951 .iter()
952 .filter_map(|primitive| match primitive {
953 Primitive::Rect { rect, color } if *color == crate::gpu::colorref(0xffff) => {
954 Some(*rect)
955 }
956 _ => None,
957 })
958 .collect::<Vec<_>>();
959 assert_eq!(highlights.len(), 2);
960 for (rect, bounds) in highlights.iter().zip(&bounds) {
961 assert_eq!(rect[2], bounds[2]);
962 assert!(rect[0] >= bounds[0] && rect[1] >= bounds[1] && rect[3] <= bounds[3]);
963 }
964 let selection = primitives
965 .iter()
966 .find_map(|primitive| match primitive {
967 Primitive::Rect { rect, color } if *color == COLORS.selection => Some(*rect),
968 _ => None,
969 })
970 .unwrap();
971 assert_eq!(selection[2], bounds[0][2]);
972 assert!(editor.caret(1.0).unwrap().x0 + 24.0 > f64::from(bounds[0][2]));
973}
974
975#[test]
976fn editable_tables_paint_borders_before_selection_and_cell_text() {
977 let mut engine = TextEngine::default();
978 let mut editor = CanvasEditor::new(
979 &mut engine,
980 TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(),
981 180.0,
982 )
983 .unwrap();
984 editor
985 .move_selection(&mut engine, Movement::LineEnd, false)
986 .unwrap();
987 editor.tab(&mut engine, false).unwrap();
988 editor.insert(&mut engine, "Right").unwrap();
989 editor.tab(&mut engine, true).unwrap();
990 let mut primitives = Vec::new();
991 append_outline(
992 Some(&editor),
993 false,
994 crate::reading::geometry(editor.active_outline(), None).0,
995 [24.0, 48.0],
996 Paint {
997 caret: 0.0,
998 scale: 1.0,
999 device_origin: [0.0; 2],
1000 pixel: 1.0,
1001 colors: COLORS,
1002 visible: [f32::NEG_INFINITY, f32::INFINITY],
1003 chrome: true,
1004 found: &[],
1005 played: None,
1006 spelling: None,
1007 tag_art: &NO_ART,
1008 },
1009 &mut primitives,
1010 )
1011 .unwrap();
1012 let color = crate::gpu::colorref(0x00a3a3a3);
1013 assert!(matches!(primitives[0], Primitive::RoundedRect {
1014 stroke: Some(draw::Stroke::Solid(0.75)), color: actual, ..
1015 } if actual == color));
1016 assert!(matches!(primitives[1], Primitive::Rect { color: actual, .. } if actual == color));
1017 assert!(matches!(primitives[2], Primitive::Rect { color, .. } if color == COLORS.selection));
1018 let painted = primitives
1019 .iter()
1020 .filter_map(|primitive| match primitive {
1021 Primitive::Text { text, origin, .. } => Some((painted_layout(*text).id(), *origin)),
1022 _ => None,
1023 })
1024 .collect::<Vec<_>>();
1025 let expected = editor
1026 .active_outline()
1027 .layouts()
1028 .map(|(_, paragraph)| {
1029 (
1030 paragraph.text.id(),
1031 [paragraph.origin[0] + 24.0, paragraph.origin[1] + 48.0],
1032 )
1033 })
1034 .collect::<Vec<_>>();
1035 assert_eq!(painted, expected);
1036 assert_eq!(painted.len(), 2);
1037}
1038
1039#[test]
1040fn resize_preview_paints_reflowed_text_without_changing_the_editor() {
1041 let mut engine = TextEngine::default();
1042 let editor = CanvasEditor::new(
1043 &mut engine,
1044 TextDocument::new(vec![Paragraph::new(
1045 "A paragraph with enough words to wrap when the resize handle moves inward.".into(),
1046 Format::default(),
1047 )])
1048 .unwrap(),
1049 240.0,
1050 )
1051 .unwrap();
1052 let original = editor.active_outline().snapshot();
1053 let original_layout = editor
1054 .active_outline()
1055 .paragraph_layout(0)
1056 .unwrap()
1057 .text
1058 .id();
1059 let resized = editor.preview_resize(&mut engine, 72.0).unwrap();
1060 let primitives = page_primitives(
1061 &editor,
1062 None,
1063 None,
1064 Some(PointerFeedback::Resize(&resized)),
1065 None,
1066 paint(false, 96.0 / 72.0, 1.0),
1067 )
1068 .unwrap();
1069 let painted = primitives
1070 .iter()
1071 .find_map(|primitive| match primitive {
1072 Primitive::Text { text, .. } => Some(painted_layout(*text)),
1073 _ => None,
1074 })
1075 .unwrap();
1076 assert_eq!(painted.id(), resized.paragraph_layout(0).unwrap().text.id());
1077 assert!(
1078 painted.lines().count()
1079 > editor
1080 .active_outline()
1081 .paragraph_layout(0)
1082 .unwrap()
1083 .text
1084 .lines()
1085 .count()
1086 );
1087 assert_eq!(editor.active_outline().layout(), &original.layout);
1088 assert_eq!(
1089 editor
1090 .active_outline()
1091 .paragraph_layout(0)
1092 .unwrap()
1093 .text
1094 .id(),
1095 original_layout
1096 );
1097 assert!(primitives.iter().any(
1098 |primitive| matches!(primitive, Primitive::RoundedRect { stroke, .. } if stroke.is_some())
1099 ));
1100}
1101
1102#[test]
1103fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and_dpi() {
1104 let mut engine = TextEngine::default();
1105 let format = Format {
1106 font: Some("Arial".into()),
1107 font_size: Some(11.0),
1108 ..Default::default()
1109 };
1110 let mut editor = CanvasEditor::new(&mut engine, TextDocument::new(vec![
1111 Paragraph::new("HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), format.clone()),
1112 Paragraph::new("Second paragraph abcdefghijklmnopqrstuvwxyz.".into(), format),
1113 ]).unwrap(), 220.0).unwrap();
1114 let id = editor.active_outline().id;
1115 editor.move_outline(id, [36.0, 90.0]).unwrap();
1116 let document = editor.active_outline().document().clone();
1117 let layout_ids: Vec<_> = editor
1118 .active_outline()
1119 .layouts()
1120 .map(|(_, p)| p.text.id())
1121 .collect();
1122 for (zoom, expected) in [
1123 (0.5, [62.0, 134.0, 223.0, 219.0]),
1124 (1.0, [81.0, 189.0, 394.0, 348.0]),
1125 (1.5, [101.0, 245.0, 565.0, 479.0]),
1126 (2.0, [120.0, 300.0, 736.0, 608.0]),
1127 ] {
1128 for dpr in [1.0, 2.0] {
1129 let scale = zoom * (96.0 / 72.0) * dpr;
1130 let pixel = dpr / scale;
1131 let (frame, body_top) = outline_chrome(editor.active_outline(), pixel);
1132 for (index, coordinate) in frame.iter().enumerate() {
1133 let origin = if index % 2 == 0 { 48.0 } else { 83.0 };
1134 let screen = coordinate * scale / dpr + origin;
1135 assert!(
1136 (screen - expected[index]).abs() <= 1.5,
1137 "zoom={zoom} dpr={dpr} edge={index}: {screen} vs {}",
1138 expected[index]
1139 );
1140 }
1141 let header = [36.0, (frame[1] + body_top) / 2.0];
1142 assert!(
1143 matches!(page_hit_test(&editor, None, header, pixel), Some(Hit::Handle {id: hit, ..}) if hit == id)
1144 );
1145 assert!(matches!(
1146 page_hit_test(&editor, None, [frame[2] - 4.0 * pixel, header[1]], pixel),
1147 Some(Hit::Resize { id: hit, .. }) if hit == id
1148 ));
1149 let padding = [frame[0] + pixel, 90.0];
1150 assert!(
1151 matches!(page_hit_test(&editor, None, padding, pixel), Some(Hit::Text {id: hit, ..}) if hit == id)
1152 );
1153 assert_eq!(
1154 page_hit_test(&editor, None, [frame[0] - pixel, 90.0], pixel),
1155 None
1156 );
1157 let primitives =
1158 page_primitives(&editor, None, None, None, None, paint(false, scale, dpr)).unwrap();
1159 assert!(
1160 matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top])
1161 );
1162 assert!(
1163 primitives
1164 .iter()
1165 .any(|p| matches!(p, Primitive::Text {origin, ..} if *origin == [36.0, 90.0]))
1166 );
1167 }
1168 }
1169 assert_eq!(editor.active_outline().document(), &document);
1170 assert_eq!(
1171 editor
1172 .active_outline()
1173 .layouts()
1174 .map(|(_, p)| p.text.id())
1175 .collect::<Vec<_>>(),
1176 layout_ids
1177 );
1178}
1179
1180#[test]
1181fn a_later_text_body_takes_priority_over_an_earlier_drag_handle() {
1182 let mut engine = TextEngine::default();
1183 let mut editor = CanvasEditor::new(
1184 &mut engine,
1185 TextDocument::new(vec![Paragraph::new("first".into(), Default::default())]).unwrap(),
1186 240.0,
1187 )
1188 .unwrap();
1189 let first = editor.active_outline().id;
1190 editor.move_outline(first, [0.0, 20.0]).unwrap();
1191 assert_eq!(
1192 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1193 Some(Hit::Handle {
1194 id: first,
1195 grab: [5.0, -8.0]
1196 })
1197 );
1198 let second = editor
1199 .create_outline(&mut engine, [0.0, 10.0], 240.0)
1200 .unwrap();
1201 assert_eq!(
1202 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1203 Some(Hit::Text {
1204 id: second,
1205 point: [5.0, 2.0]
1206 })
1207 );
1208 editor.undo(&mut engine).unwrap();
1209 assert_eq!(
1210 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1211 Some(Hit::Handle {
1212 id: first,
1213 grab: [5.0, -8.0]
1214 })
1215 );
1216}
1217
1218#[test]
1219fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
1220 use onestore::page::{Outline, PageObject, Unsupported};
1221 for readonly_on_top in [false, true] {
1222 let mut engine = TextEngine::default();
1223 let document = TextDocument::new(vec![Paragraph::new(
1224 "Imported text".into(),
1225 Default::default(),
1226 )])
1227 .unwrap();
1228 let id = onestore::ExGuid {
1229 guid: [5; 16],
1230 n: 1,
1231 };
1232 let mut objects = vec![
1233 PageObject::Outline(Outline {
1234 id,
1235 title: false,
1236 min_width: None,
1237 layout: onestore::document::Layout {
1238 x: Some(30.0),
1239 y: Some(40.0),
1240 max_width: Some(180.0),
1241 ..Default::default()
1242 },
1243 indents: vec![18.0, 0.0],
1244 paragraphs: document.nodes().to_vec(),
1245 unsupported: Vec::new(),
1246 }),
1247 PageObject::Unsupported(Unsupported {
1248 id: Default::default(),
1249 jcid: 0xdead,
1250 layout: onestore::document::Layout {
1251 x: Some(20.0),
1252 y: Some(20.0),
1253 max_width: Some(200.0),
1254 max_height: Some(100.0),
1255 ..Default::default()
1256 },
1257 }),
1258 ];
1259 if !readonly_on_top {
1260 objects.reverse();
1261 }
1262 let page = Page {
1263 identity: None,
1264 created: None,
1265 title: String::new(),
1266 margin_origin: [0.0; 2],
1267 rtl: false,
1268 color: None,
1269 rule_lines: None,
1270 definitions: Default::default(),
1271 objects,
1272 };
1273 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
1274 let scene = (scene, [0.0; 2]);
1275 let imported_layout = editor
1276 .active_outline()
1277 .layouts()
1278 .next()
1279 .unwrap()
1280 .1
1281 .text
1282 .id();
1283 let texts: Vec<_> = page_primitives(
1284 &editor,
1285 Some(&scene),
1286 None,
1287 None,
1288 None,
1289 paint(false, 1.0, 1.0),
1290 )
1291 .unwrap()
1292 .into_iter()
1293 .filter_map(|p| match p {
1294 Primitive::Text { text, .. } => Some(painted_layout(text).id()),
1295 _ => None,
1296 })
1297 .collect();
1298 assert_eq!(texts.len(), 2);
1299 assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout);
1300 let expected = if readonly_on_top {
1301 Hit::ReadOnly(0)
1302 } else {
1303 Hit::Text {
1304 id,
1305 point: [10.0, 5.0],
1306 }
1307 };
1308 assert_eq!(
1309 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1310 Some(expected)
1311 );
1312 let header = page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0);
1313 if readonly_on_top {
1314 assert_eq!(header, Some(Hit::ReadOnly(0)));
1315 } else {
1316 assert_eq!(
1317 header,
1318 Some(Hit::Handle {
1319 id,
1320 grab: [10.0, -8.0]
1321 })
1322 );
1323 }
1324 let annotation = editor
1325 .create_outline(&mut engine, [30.0, 40.0], 180.0)
1326 .unwrap();
1327 assert_eq!(
1328 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1329 Some(Hit::Text {
1330 id: annotation,
1331 point: [10.0, 5.0]
1332 })
1333 );
1334 assert_eq!(
1335 page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0),
1336 Some(Hit::Handle {
1337 id: annotation,
1338 grab: [10.0, -8.0]
1339 })
1340 );
1341 let texts: Vec<_> = page_primitives(
1342 &editor,
1343 Some(&scene),
1344 None,
1345 None,
1346 None,
1347 paint(false, 1.0, 1.0),
1348 )
1349 .unwrap()
1350 .into_iter()
1351 .filter_map(|p| match p {
1352 Primitive::Text { text, .. } => Some(painted_layout(text).id()),
1353 _ => None,
1354 })
1355 .collect();
1356 assert_eq!(
1357 *texts.last().unwrap(),
1358 editor
1359 .active_outline()
1360 .layouts()
1361 .next()
1362 .unwrap()
1363 .1
1364 .text
1365 .id()
1366 );
1367 editor.move_outline(annotation, [500.0, 500.0]).unwrap();
1368 assert!(
1369 !matches!(page_hit_test(&editor, Some(&scene), [40.0,45.0], 1.0), Some(Hit::Text { id, .. }) if id == annotation)
1370 );
1371 editor.undo(&mut engine).unwrap();
1372 assert_eq!(
1373 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1374 Some(Hit::Text {
1375 id: annotation,
1376 point: [10.0, 5.0]
1377 })
1378 );
1379 editor.undo(&mut engine).unwrap();
1380 assert_eq!(editor.outlines().len(), 1);
1381 assert_eq!(editor.active_outline().document(), &document);
1382 assert_eq!(
1383 page_hit_test(&editor, Some(&scene), [10.0, 10.0], 1.0),
1384 None
1385 );
1386 }
1387}
1388
1389#[test]
1390fn read_only_focus_accepts_navigation_without_edit_shortcuts() {
1391 for modifiers in [
1392 Modifiers::default(),
1393 Modifiers {
1394 shift: true,
1395 ..Modifiers::default()
1396 },
1397 Modifiers {
1398 option: true,
1399 ..Modifiers::default()
1400 },
1401 Modifiers {
1402 command: true,
1403 ..Modifiers::default()
1404 },
1405 Modifiers {
1406 command: true,
1407 shift: true,
1408 ..Modifiers::default()
1409 },
1410 Modifiers {
1411 command: true,
1412 option: true,
1413 ..Modifiers::default()
1414 },
1415 ] {
1416 for key in [
1417 Key::Character("a".into()),
1418 Key::Character("v".into()),
1419 Key::Character("c".into()),
1420 Key::Character("x".into()),
1421 Key::Character("z".into()),
1422 Key::Character("🌳".into()),
1423 Key::Named(NamedKey::Backspace),
1424 Key::Named(NamedKey::Delete),
1425 Key::Named(NamedKey::Enter),
1426 Key::Named(NamedKey::ArrowLeft),
1427 Key::Named(NamedKey::ArrowRight),
1428 Key::Named(NamedKey::ArrowUp),
1429 Key::Named(NamedKey::ArrowDown),
1430 ] {
1431 assert!(
1432 !read_only_shortcut(&key, modifiers),
1433 "{key:?} {modifiers:?}"
1434 );
1435 }
1436 }
1437 for (key, modifiers) in [
1438 (Key::Named(NamedKey::Escape), Modifiers::default()),
1439 (
1440 Key::Named(NamedKey::Tab),
1441 Modifiers {
1442 control: true,
1443 ..Modifiers::default()
1444 },
1445 ),
1446 (
1447 Key::Named(NamedKey::Tab),
1448 Modifiers {
1449 control: true,
1450 shift: true,
1451 ..Modifiers::default()
1452 },
1453 ),
1454 ] {
1455 assert!(read_only_shortcut(&key, modifiers));
1456 }
1457 assert!(!read_only_shortcut(
1458 &Key::Named(NamedKey::Tab),
1459 Modifiers::default()
1460 ));
1461}
1462
1463#[test]
1464fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
1465 use onestore::page::{PageObject, Unsupported};
1466 let mut engine = TextEngine::default();
1467 let mut editor = CanvasEditor::new(
1468 &mut engine,
1469 TextDocument::new(vec![Paragraph::new("Draft".into(), Default::default())]).unwrap(),
1470 240.0,
1471 )
1472 .unwrap();
1473 let page = Page {
1474 identity: None,
1475 created: None,
1476 title: String::new(),
1477 margin_origin: [0.0; 2],
1478 rtl: false,
1479 color: None,
1480 rule_lines: None,
1481 definitions: Default::default(),
1482 objects: vec![PageObject::Unsupported(Unsupported {
1483 id: Default::default(),
1484 jcid: 0xdead,
1485 layout: onestore::document::Layout {
1486 x: Some(100.0),
1487 y: Some(80.0),
1488 max_width: Some(180.0),
1489 max_height: Some(80.0),
1490 ..Default::default()
1491 },
1492 })],
1493 };
1494 let scene = (PageScene::new(page, &mut engine).unwrap(), [20.0, 30.0]);
1495 let original = editor.active_outline().document().clone();
1496 let rectangles = |primitives: Vec<Primitive<'_>>| {
1497 primitives
1498 .into_iter()
1499 .filter_map(|p| match p {
1500 Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => {
1501 Some((rect, color))
1502 }
1503 _ => None,
1504 })
1505 .collect::<Vec<_>>()
1506 };
1507 for scale in [1.0, 2.0] {
1508 let text = rectangles(
1509 page_primitives(
1510 &editor,
1511 Some(&scene),
1512 None,
1513 None,
1514 None,
1515 paint(true, scale, 1.0),
1516 )
1517 .unwrap(),
1518 );
1519 assert!(text.iter().any(|(_, color)| *color == COLORS.caret));
1520 let selected = rectangles(
1521 page_primitives(
1522 &editor,
1523 Some(&scene),
1524 None,
1525 None,
1526 Some(ObjectFocus::ReadOnly(0)),
1527 paint(true, scale, 1.0),
1528 )
1529 .unwrap(),
1530 );
1531 assert!(!selected.iter().any(|(_, color)| *color == COLORS.caret));
1532 assert_eq!(
1533 selected,
1534 rectangles(
1535 page_primitives(
1536 &editor,
1537 Some(&scene),
1538 None,
1539 None,
1540 Some(ObjectFocus::ReadOnly(0)),
1541 paint(false, scale, 1.0)
1542 )
1543 .unwrap()
1544 )
1545 );
1546 assert_eq!(
1547 selected.last().unwrap().0,
1548 [300.0 - 2.0 / scale, 110.0, 300.0, 190.0]
1549 );
1550 }
1551 editor.select_all().unwrap();
1552 let text = rectangles(
1553 page_primitives(
1554 &editor,
1555 Some(&scene),
1556 None,
1557 None,
1558 None,
1559 paint(false, 1.0, 1.0),
1560 )
1561 .unwrap(),
1562 );
1563 assert!(text.iter().any(|(_, color)| *color == COLORS.selection));
1564 let selected = rectangles(
1565 page_primitives(
1566 &editor,
1567 Some(&scene),
1568 None,
1569 None,
1570 Some(ObjectFocus::ReadOnly(0)),
1571 paint(true, 1.0, 1.0),
1572 )
1573 .unwrap(),
1574 );
1575 assert!(!selected.iter().any(|(_, color)| *color == COLORS.selection));
1576 assert_eq!(editor.active_outline().document(), &original);
1577}
1578
1579/// A view at 100% on a 1x display over one outline holding "Before", a 40 x 30 pt picture and
1580/// "After" at (36, 36), with the picture's identity.
1581fn picture_view() -> (PageView, onestore::ExGuid) {
1582 use onestore::page::{Image, PageObject, ParagraphContent};
1583 let mut engine = TextEngine::default();
1584 let mut source = TextOutline::new(
1585 &mut engine,
1586 TextDocument::new(vec![
1587 Paragraph::new("Before".into(), Default::default()),
1588 Paragraph::new("After".into(), Default::default()),
1589 ])
1590 .unwrap(),
1591 240.0,
1592 [36.0, 36.0],
1593 )
1594 .unwrap()
1595 .snapshot();
1596 let mut picture = source.paragraphs[0].clone();
1597 picture.id = onestore::page::text::new_id().unwrap();
1598 let id = onestore::page::text::new_id().unwrap();
1599 picture.content = ParagraphContent::Image(Image {
1600 size: Some([40.0, 30.0]),
1601 id,
1602 layout: Default::default(),
1603 bytes: None,
1604 display: None,
1605 alt: None,
1606 background: false,
1607 printout: None,
1608 tags: Vec::new(),
1609 link: None,
1610 text: None,
1611 });
1612 source.paragraphs.insert(1, picture);
1613 let editor = CanvasEditor::from_page(
1614 Page {
1615 title: String::new(),
1616 identity: None,
1617 created: None,
1618 margin_origin: [36.0, 14.4],
1619 rtl: false,
1620 color: None,
1621 rule_lines: None,
1622 definitions: Default::default(),
1623 objects: vec![PageObject::Outline(source)],
1624 },
1625 &mut engine,
1626 )
1627 .unwrap();
1628 let mut view = PageView::new(
1629 editor,
1630 engine,
1631 None,
1632 [800, 600],
1633 1.0,
1634 Duration::from_millis(500),
1635 );
1636 view.viewport.scale = 1.0;
1637 view.viewport.origin = [0.0; 2];
1638 (view, id)
1639}
1640
1641fn pasted_picture_view() -> PageView {
1642 let (mut view, _) = picture_view();
1643 let (scene, editor) =
1644 PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap();
1645 view.editor = editor;
1646 view.scene = Some((scene, [0.0; 2]));
1647 let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png");
1648 let _ = view.insert_picture(bytes.to_vec(), [40.0, 30.0]).unwrap();
1649 let (scene, _) = view.scene.as_mut().unwrap();
1650 scene.settle(Some(&view.editor), 1.0, COLORS.paper);
1651 view
1652}
1653
1654#[test]
1655fn a_picture_pasted_into_an_open_outline_reaches_the_scene() {
1656 let view = pasted_picture_view();
1657 assert!(view.primitives(COLORS).unwrap().iter().any(|primitive| {
1658 matches!(primitive, Primitive::Image { rect, .. }
1659 if (rect[2] - rect[0] - 40.0).abs() < 0.01
1660 && (rect[3] - rect[1] - 30.0).abs() < 0.01)
1661 }));
1662}
1663
1664#[test]
1665#[ignore = "requires a native GPU adapter"]
1666fn a_pasted_inline_picture_is_drawn_on_the_gpu() {
1667 let view = pasted_picture_view();
1668 let primitives = view.primitives(COLORS).unwrap();
1669 let rect = primitives
1670 .iter()
1671 .find_map(|primitive| match primitive {
1672 Primitive::Image { rect, .. } => Some(*rect),
1673 _ => None,
1674 })
1675 .unwrap();
1676 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1677 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
1678 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
1679 let size = [512, 256];
1680 let texture = device.create_texture(&wgpu::TextureDescriptor {
1681 label: Some("Pasted picture"),
1682 size: wgpu::Extent3d {
1683 width: size[0],
1684 height: size[1],
1685 depth_or_array_layers: 1,
1686 },
1687 mip_level_count: 1,
1688 sample_count: 1,
1689 dimension: wgpu::TextureDimension::D2,
1690 format: wgpu::TextureFormat::Rgba8UnormSrgb,
1691 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1692 view_formats: &[],
1693 });
1694 let mut renderer = draw::Renderer::new(
1695 device.clone(),
1696 queue.clone(),
1697 wgpu::TextureFormat::Rgba8UnormSrgb,
1698 );
1699 renderer
1700 .draw(
1701 &texture.create_view(&Default::default()).into(),
1702 size,
1703 [1.0; 4],
1704 &[draw::Layer {
1705 scale: 1.0,
1706 origin: [0.0; 2],
1707 clip: None,
1708 backdrop: None,
1709 round: None,
1710 motion: None,
1711 primitives: &primitives,
1712 }],
1713 )
1714 .unwrap();
1715 let readback = device.create_buffer(&wgpu::BufferDescriptor {
1716 label: Some("Pasted picture pixels"),
1717 size: u64::from(size[0] * size[1] * 4),
1718 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1719 mapped_at_creation: false,
1720 });
1721 let mut encoder = device.create_command_encoder(&Default::default());
1722 encoder.copy_texture_to_buffer(
1723 texture.as_image_copy(),
1724 wgpu::TexelCopyBufferInfo {
1725 buffer: &readback,
1726 layout: wgpu::TexelCopyBufferLayout {
1727 offset: 0,
1728 bytes_per_row: Some(size[0] * 4),
1729 rows_per_image: Some(size[1]),
1730 },
1731 },
1732 texture.size(),
1733 );
1734 queue.submit([encoder.finish()]);
1735 let (sender, receiver) = std::sync::mpsc::channel();
1736 readback.map_async(wgpu::MapMode::Read, .., move |result| {
1737 sender.send(result).unwrap();
1738 });
1739 device
1740 .poll(wgpu::PollType::Wait {
1741 submission_index: None,
1742 timeout: Some(Duration::from_secs(5)),
1743 })
1744 .unwrap();
1745 receiver
1746 .recv_timeout(Duration::from_secs(5))
1747 .unwrap()
1748 .unwrap();
1749 let pixels = readback.get_mapped_range(..).unwrap();
1750 let colored = (rect[1].ceil() as usize..rect[3].floor() as usize)
1751 .flat_map(|y| {
1752 (rect[0].ceil() as usize..rect[2].floor() as usize)
1753 .map(move |x| (y * size[0] as usize + x) * 4)
1754 })
1755 .filter(|&at| {
1756 let rgb = &pixels[at..at + 3];
1757 rgb.iter().max().unwrap() - rgb.iter().min().unwrap() > 30
1758 })
1759 .count();
1760 assert!(
1761 colored > 100,
1762 "picture rectangle contained only {colored} colored pixels"
1763 );
1764 if let Some(path) = std::env::var_os("SNOWBOUND_PICTURE_CAPTURE") {
1765 let mut encoder = png::Encoder::new(std::fs::File::create(path).unwrap(), size[0], size[1]);
1766 encoder.set_color(png::ColorType::Rgba);
1767 encoder.set_depth(png::BitDepth::Eight);
1768 encoder
1769 .write_header()
1770 .unwrap()
1771 .write_image_data(&pixels)
1772 .unwrap();
1773 }
1774}
1775
1776#[test]
1777fn a_picture_context_copies_and_cuts_the_picture_instead_of_hidden_text() {
1778 use onestore::page::{PageObject, ParagraphContent};
1779 for floating in [false, true] {
1780 let (mut view, _) = picture_view();
1781 let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png");
1782 let id = if floating {
1783 let _ = view
1784 .drop_picture([400.0, 200.0], bytes.to_vec(), [40.0, 30.0])
1785 .unwrap();
1786 view.editor
1787 .page()
1788 .unwrap()
1789 .objects
1790 .iter()
1791 .find_map(|object| match object {
1792 PageObject::Image(image) => Some(image.id),
1793 _ => None,
1794 })
1795 .unwrap()
1796 } else {
1797 let image = crate::editor::picture(bytes.to_vec(), [40.0, 30.0]).unwrap();
1798 let id = image.id;
1799 view.editor.insert_picture(&mut view.engine, image).unwrap();
1800 id
1801 };
1802 let (scene, _) =
1803 PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap();
1804 view.scene = Some((scene, [0.0; 2]));
1805 let (origin, size) = view.editor.image_placement(id).unwrap();
1806 let _ = view
1807 .pointer_moved([origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0])
1808 .unwrap();
1809 let (_, context) = view.context().unwrap().unwrap();
1810 assert_eq!(context.image.as_ref().unwrap().id, id);
1811 assert!(context.selected);
1812 let copied = view.copy(false).unwrap();
1813 let Some(Request::Copy(clip)) = copied.request else {
1814 panic!("a picture copy");
1815 };
1816 assert_eq!(clip.paragraphs.len(), 1);
1817 let ParagraphContent::Image(picture) = &clip.paragraphs[0].content else {
1818 panic!("a picture");
1819 };
1820 assert_eq!(picture.id, id);
1821 assert_eq!([picture.layout.x, picture.layout.y], [None; 2]);
1822 assert_eq!(picture.bytes.as_deref(), Some(bytes.as_slice()));
1823 assert_eq!(clip.text(), "");
1824 assert!(view.editor.image_placement(id).is_some());
1825 let cut = view.copy(true).unwrap();
1826 assert!(cut.changed);
1827 assert!(matches!(cut.request, Some(Request::Copy(_))));
1828 assert!(view.editor.image_placement(id).is_none());
1829 assert!(view.undo(false).unwrap().changed);
1830 assert!(view.editor.image_placement(id).is_some());
1831 assert!(view.undo(true).unwrap().changed);
1832 assert!(view.editor.image_placement(id).is_none());
1833 view.editor
1834 .place_caret(&mut view.engine, [600.0, 400.0], 240.0)
1835 .unwrap();
1836 assert!(view.paste_clip(clip).unwrap().changed);
1837 let page = view.editor.page().unwrap();
1838 assert!(
1839 page.objects
1840 .iter()
1841 .filter_map(|object| match object {
1842 PageObject::Outline(outline) => Some(outline),
1843 _ => None,
1844 })
1845 .flat_map(|outline| &outline.paragraphs)
1846 .filter_map(|node| node.text())
1847 .any(|text| text.text.text() == "Before")
1848 );
1849 let pasted = view
1850 .editor
1851 .outlines()
1852 .iter()
1853 .flat_map(|outline| crate::document::descendants(outline.document().nodes(), None))
1854 .find_map(|(_, _, node)| match &node.content {
1855 ParagraphContent::Image(image) if image.bytes.is_some() => Some(image.id),
1856 _ => None,
1857 })
1858 .unwrap();
1859 let (scene, _) = view.scene.as_mut().unwrap();
1860 scene.settle(Some(&view.editor), 1.0, crate::gpu::Paper::WHITE);
1861 assert!(scene.image(pasted).is_some());
1862 }
1863}
1864
1865#[test]
1866fn restoring_a_pictures_original_size_is_undoable_in_an_outline_and_on_the_page() {
1867 for floating in [false, true] {
1868 let (mut view, _) = picture_view();
1869 let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png");
1870 let id = if floating {
1871 let _ = view
1872 .drop_picture([400.0, 200.0], bytes.to_vec(), [40.0, 30.0])
1873 .unwrap();
1874 view.editor
1875 .page()
1876 .unwrap()
1877 .objects
1878 .iter()
1879 .find_map(|object| match object {
1880 onestore::page::PageObject::Image(image) => Some(image.id),
1881 _ => None,
1882 })
1883 .unwrap()
1884 } else {
1885 let image = crate::editor::picture(bytes.to_vec(), [40.0, 30.0]).unwrap();
1886 let id = image.id;
1887 view.editor.insert_picture(&mut view.engine, image).unwrap();
1888 id
1889 };
1890 let (scene, _) =
1891 PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap();
1892 view.scene = Some((scene, [0.0; 2]));
1893 let (origin, _) = view.editor.image_placement(id).unwrap();
1894 view.editor
1895 .place_image(&mut view.engine, id, origin, [80.0, 60.0])
1896 .unwrap();
1897 let _ = view
1898 .pointer_moved([origin[0] + 10.0, origin[1] + 10.0])
1899 .unwrap();
1900 let (_, context) = view.context().unwrap().unwrap();
1901 assert_eq!(context.image.unwrap().id, id);
1902 assert!(view.restore_picture_size(id).unwrap().changed);
1903 assert_eq!(
1904 view.editor.image_placement(id),
1905 Some((origin, [40.0, 30.0]))
1906 );
1907 assert!(view.undo(false).unwrap().changed);
1908 assert_eq!(
1909 view.editor.image_placement(id),
1910 Some((origin, [80.0, 60.0]))
1911 );
1912 assert!(view.undo(true).unwrap().changed);
1913 assert_eq!(
1914 view.editor.image_placement(id),
1915 Some((origin, [40.0, 30.0]))
1916 );
1917 }
1918}
1919
1920#[test]
1921fn a_page_outline_emptied_by_backspace_still_draws() {
1922 let mut engine = TextEngine::default();
1923 let objects = [[36.0, 36.0], [36.0, 200.0]]
1924 .map(|origin| {
1925 let document =
1926 TextDocument::new(vec![Paragraph::new("Delete me".into(), Default::default())])
1927 .unwrap();
1928 let outline = TextOutline::new(&mut engine, document, 240.0, origin).unwrap();
1929 onestore::page::PageObject::Outline(outline.snapshot())
1930 })
1931 .into();
1932 let page = Page {
1933 title: String::new(),
1934 identity: None,
1935 created: None,
1936 margin_origin: [36.0, 14.4],
1937 rtl: false,
1938 color: None,
1939 rule_lines: None,
1940 definitions: Default::default(),
1941 objects,
1942 };
1943 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
1944 let other = editor.outlines()[1].id;
1945 let mut view = PageView::new(
1946 editor,
1947 engine,
1948 Some((scene, [0.0; 2])),
1949 [800, 600],
1950 1.0,
1951 Duration::from_millis(500),
1952 );
1953 view.editor.select_all().unwrap();
1954 assert!(
1955 view.key(&Key::Named(NamedKey::Backspace), None)
1956 .unwrap()
1957 .changed
1958 );
1959 assert!(view.editor.caret_outline().is_some());
1960 view.primitives(COLORS).unwrap();
1961 view.editor.focus_outline(other).unwrap();
1962 assert_eq!(view.editor.outlines().len(), 1);
1963 view.primitives(COLORS).unwrap();
1964}
1965
1966fn click(view: &mut PageView, point: [f32; 2], now: Instant) {
1967 let _ = view.pointer_moved(point).unwrap();
1968 assert!(view.pointer_pressed(now).unwrap().changed);
1969 assert!(view.pointer_released().unwrap().changed);
1970}
1971
1972#[test]
1973fn events_route_through_the_view_as_the_host_delivers_them() {
1974 let (mut view, picture) = picture_view();
1975 let now = Instant::now();
1976 let outline = view.editor.active_outline().id;
1977 let bounds = view.editor.active_outline().bounds();
1978
1979 // Typing at a clicked caret edits the outline and asks for nothing else.
1980 click(
1981 &mut view,
1982 [bounds.x0 as f32 + 2.0, bounds.y0 as f32 + 6.0],
1983 now,
1984 );
1985 assert_eq!(view.cursor(), Cursor::Text);
1986 let typed = view.key(&Key::Character("X".into()), Some("X")).unwrap();
1987 assert!(typed.changed && typed.request.is_none());
1988 assert!(
1989 view.editor
1990 .active_outline()
1991 .document()
1992 .paragraphs()
1993 .next()
1994 .unwrap()
1995 .text()
1996 .starts_with('X')
1997 );
1998
1999 // Copy asks the host to copy; the host's paste commits its text. The shortcut
2000 // modifier's chords are the host's, so the page takes none of them as typing.
2001 let _ = view
2002 .modifiers_changed(Modifiers {
2003 shift: true,
2004 ..Modifiers::default()
2005 })
2006 .unwrap();
2007 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
2008 assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some("B"));
2009 let _ = view
2010 .modifiers_changed(Modifiers {
2011 command: true,
2012 ..Modifiers::default()
2013 })
2014 .unwrap();
2015 assert_eq!(
2016 view.key(&Key::Character("v".into()), Some("v")).unwrap(),
2017 Response::default()
2018 );
2019 assert!(view.commit_text("line\nbreak".into()).unwrap().changed);
2020 assert_eq!(
2021 view.editor.active_outline().document().paragraphs().count(),
2022 3
2023 );
2024
2025 // Select All selects the caret's paragraph, then the whole outline.
2026 let caret = view.editor.selection().positions[1].paragraph;
2027 let select_all = |view: &mut PageView| {
2028 let _ = view.widen_selection().unwrap();
2029 view.editor
2030 .selection()
2031 .positions
2032 .map(|position| (position.paragraph, position.offset))
2033 };
2034 let last = view
2035 .editor
2036 .active_outline()
2037 .document()
2038 .paragraphs()
2039 .last()
2040 .unwrap();
2041 let whole = [(0, 0), (2, last.utf16_offset(last.text().len()).unwrap())];
2042 let paragraph = select_all(&mut view);
2043 assert!(
2044 paragraph[0] == (caret, 0) && paragraph != whole,
2045 "{paragraph:?}"
2046 );
2047 assert_eq!(select_all(&mut view), whole);
2048 let _ = view.modifiers_changed(Modifiers::default()).unwrap();
2049
2050 // A click on the picture selects it, stops text input, and Delete removes it.
2051 let (origin, size) = view.editor.image_placement(picture).unwrap();
2052 let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0];
2053 click(&mut view, center, now + Duration::from_secs(1));
2054 assert_eq!(view.object_focus(), Some(ObjectFocus::Image(picture)));
2055 assert!(!view.accepts_text());
2056 assert_eq!(view.cursor(), Cursor::Move);
2057 assert_eq!(
2058 view.commit_text("ignored".into()).unwrap(),
2059 Response::default()
2060 );
2061 assert!(
2062 view.key(&Key::Named(NamedKey::Delete), None)
2063 .unwrap()
2064 .changed
2065 );
2066 assert!(view.editor.image_placement(picture).is_none());
2067 assert!(view.accepts_text());
2068
2069 // The header drags the outline onto the grid anchored at the page margin.
2070 let bounds = view.editor.active_outline().bounds();
2071 let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0];
2072 let _ = view.pointer_moved(header).unwrap();
2073 let _ = view.pointer_pressed(now + Duration::from_secs(2)).unwrap();
2074 assert_eq!(view.cursor(), Cursor::Move);
2075 let _ = view
2076 .pointer_moved([header[0] + 30.0, header[1] + 20.0])
2077 .unwrap();
2078 assert_eq!(view.outline_preview().map(|(id, _)| id), Some(outline));
2079 let _ = view.pointer_released().unwrap();
2080 let origin = view.editor.active_outline().origin();
2081 assert_eq!(origin, [72.0, 50.4]);
2082
2083 // Dropped outside the view, as past the window's corner, it stays where it was.
2084 let bounds = view.editor.active_outline().bounds();
2085 let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0];
2086 let _ = view.pointer_moved(header).unwrap();
2087 let _ = view.pointer_pressed(now + Duration::from_secs(4)).unwrap();
2088 let _ = view.pointer_moved([-200.0, -150.0]).unwrap();
2089 assert!(view.outline_preview().is_some());
2090 let _ = view.pointer_released().unwrap();
2091 assert_eq!(view.editor.active_outline().origin(), origin);
2092
2093 // An empty spot places a new caret there, 7 px above the pointer on the grid.
2094 click(&mut view, [400.0, 300.0], now + Duration::from_secs(3));
2095 let caret = view.editor.caret_outline().unwrap().origin();
2096 assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4]));
2097
2098 // With Snap to Grid off it lands where the pointer is, as OneNote 2010 places it.
2099 view.snap_to_grid = false;
2100 click(&mut view, [410.0, 330.0], now + Duration::from_secs(5));
2101 let caret = view.editor.caret_outline().unwrap().origin();
2102 assert_eq!(caret, [410.0, 323.0]);
2103}
2104
2105#[test]
2106fn toolbar_commands_wait_for_composition_and_object_focus() {
2107 use crate::editor::{Formatting, Toggle};
2108 let mut engine = TextEngine::default();
2109 let editor = CanvasEditor::new(
2110 &mut engine,
2111 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
2112 240.0,
2113 )
2114 .unwrap();
2115 let mut view = PageView::new(
2116 editor,
2117 engine,
2118 None,
2119 [800, 600],
2120 1.0,
2121 Duration::from_millis(500),
2122 );
2123 let bold = Formatting::Toggle(Toggle::Bold);
2124 view.editor.select_all().unwrap();
2125 let _ = view.compose("に".into(), None).unwrap();
2126 assert_eq!(view.format(bold.clone()).unwrap(), Response::default());
2127 let _ = view.cancel_composition().unwrap();
2128 view.editor.select_all().unwrap();
2129 view.set_object_focus(Some(ObjectFocus::ReadOnly(0)));
2130 assert_eq!(view.format(bold.clone()).unwrap(), Response::default());
2131 view.set_object_focus(None);
2132 assert!(view.format(bold).unwrap().changed);
2133 assert_eq!(view.editor.format_state().unwrap().toggles, [Toggle::Bold]);
2134}
2135
2136#[test]
2137fn outline_chrome_grows_left_to_contain_its_tag_column() {
2138 use crate::editor::{Formatting, NoteTag};
2139 let mut engine = TextEngine::default();
2140 let outline = TextOutline::new(
2141 &mut engine,
2142 TextDocument::new(vec![
2143 Paragraph::new("Plain one".into(), Format::default()),
2144 Paragraph::new("Plain two".into(), Format::default()),
2145 ])
2146 .unwrap(),
2147 468.0,
2148 [288.0, 36.0],
2149 )
2150 .unwrap();
2151 let mut editor =
2152 CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
2153 // OneNote 2010 at 100%: the border sits 15 px left of the text, plus 16 px per tag slot,
2154 // and the text and right border stay put.
2155 let pixel = 0.75;
2156 let (plain, _) = outline_chrome(editor.active_outline(), pixel);
2157 let check = |editor: &CanvasEditor, slots: f32| {
2158 let outline = editor.active_outline();
2159 let (frame, _) = outline_chrome(outline, pixel);
2160 assert_eq!(outline.origin()[0], 288.0);
2161 assert!(((288.0 - frame[0]) / pixel - (15.0 + 16.0 * slots)).abs() < 0.01);
2162 assert_eq!(frame[2], plain[2]);
2163 let shaped = outline.shaped();
2164 for (_, paragraph) in outline.layouts() {
2165 for tag in &paragraph.tags {
2166 let x = outline.origin()[0] + shaped.tag_column_offset() + tag.origin[0];
2167 let y = outline.origin()[1] + paragraph.origin[1] + tag.origin[1];
2168 let size = crate::outline::ParagraphTag::SIZE;
2169 assert!(frame[0] < x && x + size < frame[2], "{frame:?} {x}");
2170 assert!(frame[1] < y && y + size < frame[3], "{frame:?} {y}");
2171 // OneNote 2010 shows an arrow over a check box, which a click toggles.
2172 let hit = page_hit_test(editor, None, [x + 1.0, y + 1.0], pixel);
2173 if tag.icon.checkable() {
2174 assert_eq!(
2175 hit,
2176 Some(Hit::Check {
2177 outline: outline.id,
2178 paragraph: paragraph.id,
2179 })
2180 );
2181 } else {
2182 assert!(matches!(hit, Some(Hit::Text { .. })));
2183 }
2184 }
2185 }
2186 };
2187 check(&editor, 0.0);
2188 editor
2189 .format(
2190 &mut engine,
2191 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
2192 )
2193 .unwrap();
2194 check(&editor, 1.0);
2195 editor
2196 .format(
2197 &mut engine,
2198 Formatting::Tag(NoteTag::defaults()[2].clone(), 2),
2199 )
2200 .unwrap();
2201 check(&editor, 2.0);
2202 editor
2203 .format(
2204 &mut engine,
2205 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
2206 )
2207 .unwrap();
2208 editor
2209 .format(
2210 &mut engine,
2211 Formatting::Tag(NoteTag::defaults()[2].clone(), 2),
2212 )
2213 .unwrap();
2214 check(&editor, 0.0);
2215}
2216
2217#[test]
2218fn a_click_on_a_check_box_toggles_it_under_an_arrow() {
2219 use crate::editor::{Formatting, NoteTag};
2220 let mut engine = TextEngine::default();
2221 let outline = TextOutline::new(
2222 &mut engine,
2223 TextDocument::new(vec![Paragraph::new("Task".into(), Format::default())]).unwrap(),
2224 468.0,
2225 [288.0, 36.0],
2226 )
2227 .unwrap();
2228 let mut editor =
2229 CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
2230 editor
2231 .format(
2232 &mut engine,
2233 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
2234 )
2235 .unwrap();
2236 let outline = editor.active_outline();
2237 let (_, paragraph) = outline.layouts().next().unwrap();
2238 let tag = &paragraph.tags[0];
2239 let centre = [
2240 outline.origin()[0] + outline.shaped().tag_column_offset() + tag.origin[0] + tag.size / 2.0,
2241 outline.origin()[1] + paragraph.origin[1] + tag.origin[1] + tag.size / 2.0,
2242 ];
2243 let mut view = PageView::new(
2244 editor,
2245 engine,
2246 None,
2247 [800, 600],
2248 1.0,
2249 Duration::from_millis(500),
2250 );
2251 let device = [0, 1].map(|axis| centre[axis] * view.viewport.scale + view.viewport.origin[axis]);
2252 let _ = view.pointer_moved(device).unwrap();
2253 assert_eq!(view.cursor(), Cursor::Default);
2254 let status = |view: &PageView| {
2255 view.editor.active_outline().document().nodes()[0]
2256 .text()
2257 .unwrap()
2258 .tags[0]
2259 .status
2260 };
2261 assert!(view.pointer_pressed(Instant::now()).unwrap().changed);
2262 let _ = view.pointer_released().unwrap();
2263 assert_eq!(status(&view), 1);
2264 let _ = view.pointer_moved([device[0] + 40.0, device[1]]).unwrap();
2265 assert_eq!(view.cursor(), Cursor::Text);
2266}
2267
2268/// OneNote 2010 moves an outline by the drag from its stored position and snaps it to the
2269/// nearest 18 pt step from the page's margin origin, (36, 14.4) until one is stored.
2270#[test]
2271fn a_dragged_outline_snaps_to_the_margin_grid_as_onenote_stores_it() {
2272 let margin = [36.0, 14.4];
2273 for (stored, drag, expected) in [
2274 ([72.0, 72.0], [15.0, 0.0], [90.0, 68.4]),
2275 ([90.0, 68.4], [5.25, 15.0], [90.0, 86.4]),
2276 ([0.0, 8.0], [30.0, 0.0], [36.0, 14.4]),
2277 ] {
2278 let point = [stored[0] + drag[0], stored[1] + drag[1]];
2279 let snapped = snap_to_grid(point, margin);
2280 assert!(
2281 (0..2).all(|axis| (snapped[axis] - expected[axis]).abs() < 1e-4),
2282 "{snapped:?} {expected:?}"
2283 );
2284 }
2285}
2286
2287/// OneNote 2010 at 75 to 150% opens a page scrolled fully left and up: 11 px beyond 7.5 pt
2288/// left of text, a list marker, 0.75 pt into a tag's slot, or 6 pt above an outline, never
2289/// right of or below the page origin, even when the only outline lies below the view.
2290#[test]
2291fn a_page_opens_where_onenote_places_the_view() {
2292 use crate::editor::{Formatting, NoteTag};
2293 let open = |origin: [f32; 2], tag: bool| {
2294 let mut engine = TextEngine::default();
2295 let outline = TextOutline::new(
2296 &mut engine,
2297 TextDocument::new(vec![Paragraph::new("Plain".into(), Format::default())]).unwrap(),
2298 468.0,
2299 origin,
2300 )
2301 .unwrap();
2302 let mut editor =
2303 CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
2304 if tag {
2305 editor
2306 .format(
2307 &mut engine,
2308 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
2309 )
2310 .unwrap();
2311 }
2312 let reached = reach(editor.active_outline());
2313 let view = PageView::new(
2314 editor,
2315 engine,
2316 None,
2317 [800, 600],
2318 1.0,
2319 Duration::from_millis(500),
2320 );
2321 (reached, view.viewport.origin)
2322 };
2323 // Native at 100%: page origin 21 px right and 20 px down, 74 and 60 px, 37 px and 0.
2324 for (origin, tag, reached, placed) in [
2325 ([0.0, 0.0], false, [-7.5, -6.0], [21.0, 19.0]),
2326 ([-40.0, -30.0], false, [-47.5, -36.0], [74.33, 59.0]),
2327 ([0.0, 40.0], true, [-19.5, 34.0], [37.0, 0.0]),
2328 ([72.0, 72.0], false, [64.5, 66.0], [0.0, 0.0]),
2329 ] {
2330 let (actual, view) = open(origin, tag);
2331 assert_eq!(actual, reached);
2332 assert!(
2333 (0..2).all(|axis| (view[axis] - placed[axis]).abs() < 0.01),
2334 "{origin:?} {view:?}"
2335 );
2336 }
2337 let (_, view) = open([50.0, 900.0], false);
2338 assert_eq!(view, [0.0, 0.0]);
2339}
2340
2341/// OneNote 2010 returns to a page at the scroll, in device pixels whatever the zoom, and the
2342/// selection it was left with, without revealing the selection.
2343#[test]
2344fn a_page_reopens_where_it_was_left() {
2345 use crate::document::TextPosition;
2346 let mut engine = TextEngine::default();
2347 let outlines: Vec<TextOutline> = [("First", [0.0, 0.0]), ("Far below", [50.0, 900.0])]
2348 .into_iter()
2349 .map(|(text, origin)| {
2350 let paragraph = Paragraph::new(text.into(), Format::default());
2351 let document = TextDocument::new(vec![paragraph]).unwrap();
2352 TextOutline::new(&mut engine, document, 468.0, origin).unwrap()
2353 })
2354 .collect();
2355 let editor = |count: usize| {
2356 CanvasEditor::from_text_outlines(outlines[..count].to_vec(), Default::default(), None)
2357 .unwrap()
2358 };
2359 let mut view = PageView::new(
2360 editor(2),
2361 engine,
2362 None,
2363 [800, 600],
2364 1.0,
2365 Duration::from_millis(500),
2366 );
2367 let far = outlines[1].id;
2368 view.editor.focus_outline(far).unwrap();
2369 let selection = Selection::from([2, 5].map(|offset| TextPosition {
2370 paragraph: 0,
2371 offset,
2372 }));
2373 view.editor.select(selection).unwrap();
2374 let _ = view.scroll_to(1, 300.0).unwrap();
2375 let place = view.place();
2376
2377 view.open(editor(2), None, None);
2378 assert_eq!(view.viewport.origin, [21.0, 19.0]);
2379 let _ = view.set_zoom(2.0).unwrap();
2380 view.open(editor(2), None, Some(place));
2381 assert_eq!(view.viewport.origin, [21.0, -300.0]);
2382 assert_eq!(view.editor.active_outline().id, far);
2383 assert_eq!(view.editor.selection(), selection);
2384
2385 // A place past the page's content is kept within it, here 6 pt above the outline at 200%.
2386 view.open(editor(1), None, Some(place));
2387 assert_eq!(view.viewport.origin, [21.0, 27.0]);
2388 assert_eq!(view.editor.active_outline().id, outlines[0].id);
2389}
2390
2391#[test]
2392fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() {
2393 let mut engine = TextEngine::default();
2394 let lines = (0..100)
2395 .map(|line| Paragraph::new(format!("Line {line}"), Default::default()))
2396 .collect();
2397 let outline = TextOutline::new(
2398 &mut engine,
2399 TextDocument::new(lines).unwrap(),
2400 240.0,
2401 [36.0, 36.0],
2402 )
2403 .unwrap();
2404 let editor = CanvasEditor::from_page(
2405 Page {
2406 title: String::new(),
2407 identity: None,
2408 created: None,
2409 margin_origin: [36.0, 14.4],
2410 rtl: false,
2411 color: None,
2412 rule_lines: None,
2413 definitions: Default::default(),
2414 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
2415 },
2416 &mut engine,
2417 )
2418 .unwrap();
2419 let mut view = PageView::new(
2420 editor,
2421 engine,
2422 None,
2423 [800, 200],
2424 1.0,
2425 Duration::from_millis(500),
2426 );
2427 view.viewport.scale = 1.0;
2428 let page_down = Key::Named(NamedKey::PageDown);
2429 let option = Modifiers {
2430 option: true,
2431 ..Modifiers::default()
2432 };
2433 for modifiers in [Modifiers::default(), option] {
2434 view.viewport.origin = [0.0; 2];
2435 view.editor
2436 .move_selection(&mut view.engine, Movement::DocumentStart, false)
2437 .unwrap();
2438 let _ = view.modifiers_changed(modifiers).unwrap();
2439 let caret = view.caret_area().unwrap()[1];
2440 let _ = view.key(&page_down, None).unwrap();
2441 let scrolled = -view.viewport.origin[1];
2442 let shown = view.caret_area().unwrap()[1];
2443 match Command::from_key(&page_down, modifiers) {
2444 Some(Command::ScrollPage { up: false }) => {
2445 assert_eq!(scrolled, 190.0, "a page less AppKit's ten points");
2446 assert_eq!(shown, caret - scrolled, "the caret stays in the text");
2447 }
2448 Some(Command::MovePage { up: false }) => {
2449 assert!(scrolled >= 200.0, "{scrolled}");
2450 assert_eq!(shown, caret, "the caret keeps its place on screen");
2451 }
2452 None => assert_eq!((scrolled, shown), (0.0, caret)),
2453 other => panic!("Page Down is {other:?}"),
2454 }
2455 }
2456}
2457
2458#[test]
2459fn an_empty_preedit_without_a_composition_changes_nothing() {
2460 let (mut view, _) = picture_view();
2461 assert_eq!(
2462 view.compose(String::new(), None).unwrap(),
2463 Response::default()
2464 );
2465 assert!(view.compose("k".into(), Some((1, 1))).unwrap().changed);
2466 assert!(view.compose(String::new(), None).unwrap().changed);
2467 assert!(view.editor.marked_range().is_none());
2468}
2469
2470/// The lab's page: a title, then outlines placed in the order Alpha, Beta, Gamma, Delta, with
2471/// Delta highest on the page, drawn at 1 point per pixel.
2472fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) {
2473 let mut engine = TextEngine::default();
2474 let outline = |engine: &mut TextEngine, text: &str, origin| {
2475 let paragraphs = text
2476 .split('\n')
2477 .map(|line| Paragraph::new(line.into(), Default::default()))
2478 .collect();
2479 TextOutline::new(
2480 engine,
2481 TextDocument::new(paragraphs).unwrap(),
2482 240.0,
2483 origin,
2484 )
2485 .unwrap()
2486 .snapshot()
2487 };
2488 let mut title = outline(&mut engine, "Multi", [0.0; 2]);
2489 title.title = true;
2490 let body = [
2491 ("Alpha one\nAlpha two", [36.0, 90.0]),
2492 ("Beta", [300.0, 180.0]),
2493 ("Gamma long line of text", [90.0, 300.0]),
2494 ("Delta", [380.0, 72.0]),
2495 ]
2496 .map(|(text, origin)| outline(&mut engine, text, origin));
2497 let ids = body.each_ref().map(|outline| outline.id);
2498 let page = Page {
2499 title: "Multi".into(),
2500 identity: None,
2501 created: None,
2502 margin_origin: [36.0, 14.4],
2503 rtl: false,
2504 color: None,
2505 rule_lines: None,
2506 definitions: Default::default(),
2507 objects: std::iter::once(onestore::page::PageObject::Title(onestore::page::Title {
2508 id: onestore::page::text::new_id().unwrap(),
2509 date: None,
2510 layout: Default::default(),
2511 outlines: vec![title],
2512 }))
2513 .chain(body.map(onestore::page::PageObject::Outline))
2514 .collect(),
2515 };
2516 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
2517 let mut view = PageView::new(
2518 editor,
2519 engine,
2520 Some((scene, [0.0; 2])),
2521 [800, 600],
2522 1.0,
2523 Duration::from_millis(500),
2524 );
2525 view.viewport.scale = 1.0;
2526 view.viewport.origin = [0.0; 2];
2527 (view, ids)
2528}
2529
2530const START: TextPosition = TextPosition {
2531 paragraph: 0,
2532 offset: 0,
2533};
2534
2535fn select_page(view: &mut PageView, from: onestore::ExGuid) {
2536 view.editor.focus_outline(from).unwrap();
2537 view.editor.select([START; 2].into()).unwrap();
2538 while view.editor.whole() != Some(Whole::Page) {
2539 let _ = view.widen_selection().unwrap();
2540 }
2541}
2542
2543fn origins(view: &PageView) -> Vec<[f32; 2]> {
2544 let body = view
2545 .editor
2546 .outlines()
2547 .iter()
2548 .filter(|outline| !outline.title);
2549 body.map(TextOutline::origin).collect()
2550}
2551
2552/// As OneNote 2010 widens Ctrl+A past an outline (lab, 2026-09-28): a body outline's
2553/// paragraph, the outline as an object, then every body outline, focusing the last.
2554#[test]
2555fn select_all_widens_from_an_outline_to_the_page() {
2556 let (mut view, [alpha, beta, _, delta]) = page_selection_view();
2557 let levels = |view: &mut PageView, from| {
2558 view.editor.focus_outline(from).unwrap();
2559 view.editor.select([START; 2].into()).unwrap();
2560 (0..4)
2561 .map(|_| {
2562 let _ = view.widen_selection().unwrap();
2563 (view.editor.whole(), view.editor.active_outline().id)
2564 })
2565 .collect::<Vec<_>>()
2566 };
2567 let page = (Some(Whole::Page), delta);
2568 for from in [alpha, beta] {
2569 assert_eq!(
2570 levels(&mut view, from),
2571 [(None, from), (Some(Whole::Outline), from), page, page]
2572 );
2573 }
2574 // The title has no object level: its text, then the page.
2575 let title = view.editor.outlines()[0].id;
2576 assert_eq!(levels(&mut view, title), [(None, title), page, page, page]);
2577 // The selection it chose ends with any other; an arrow leaves the caret at its start.
2578 let _ = view.key(&Key::Named(NamedKey::ArrowLeft), None).unwrap();
2579 assert_eq!(view.editor.whole(), None);
2580 assert_eq!(view.editor.active_outline().id, delta);
2581 assert_eq!(view.editor.selection().positions, [START; 2]);
2582}
2583
2584/// Each outline Select All holds shows its frame and a grey body, its text highlighted.
2585#[test]
2586fn a_page_selection_frames_and_greys_every_body_outline() {
2587 let (mut view, [alpha, ..]) = page_selection_view();
2588 let grey = crate::gpu::colorref(0x00f0f0f0);
2589 let paint = |view: &PageView| {
2590 let primitives = view.primitives(COLORS).unwrap();
2591 let count =
2592 |wanted: &dyn Fn(&Primitive) -> bool| primitives.iter().filter(|p| wanted(p)).count();
2593 (
2594 count(
2595 &|p| matches!(p, Primitive::RoundedRect { color, stroke: None, .. } if *color == grey),
2596 ),
2597 count(&|p| matches!(p, Primitive::Rect { color, .. } if *color == COLORS.selection)),
2598 )
2599 };
2600 view.editor.focus_outline(alpha).unwrap();
2601 assert_eq!(paint(&view).0, 0);
2602 let _ = view.widen_selection().unwrap();
2603 let _ = view.widen_selection().unwrap();
2604 let (greyed, alpha) = paint(&view);
2605 assert_eq!(greyed, 1);
2606 let _ = view.widen_selection().unwrap();
2607 // One line each of the others besides Alpha's.
2608 assert_eq!(paint(&view), (4, alpha + 3));
2609}
2610
2611/// Keys on the page selection (lab): arrows up and down and Tab leave it, Enter and Right
2612/// put the caret at the last outline's end, Delete and Backspace remove every outline and
2613/// leave the caret where the first began, and typing replaces them all with the last outline
2614/// holding the text. Each edit stores as one edit and undoes in one step to the selection.
2615#[test]
2616fn keys_act_on_the_page_selection_as_onenote_does() {
2617 let (mut view, [alpha, beta, gamma, delta]) = page_selection_view();
2618 let end = TextPosition {
2619 paragraph: 0,
2620 offset: 5,
2621 };
2622 for key in [
2623 NamedKey::ArrowUp,
2624 NamedKey::ArrowDown,
2625 NamedKey::Tab,
2626 NamedKey::Enter,
2627 ] {
2628 select_page(&mut view, beta);
2629 let _ = view.key(&Key::Named(key), None).unwrap();
2630 let kept = key != NamedKey::Enter;
2631 assert_eq!(view.editor.whole() == Some(Whole::Page), kept, "{key:?}");
2632 if !kept {
2633 assert_eq!(view.editor.selection().positions, [end; 2]);
2634 }
2635 }
2636 assert_eq!(view.editor.take_ops().unwrap(), []);
2637
2638 let stored = origins(&view);
2639 for key in [NamedKey::Delete, NamedKey::Backspace] {
2640 select_page(&mut view, gamma);
2641 let _ = view.key(&Key::Named(key), None).unwrap();
2642 assert!(view.editor.outlines().iter().all(|outline| outline.title));
2643 assert_eq!(
2644 view.editor.caret_outline().map(TextOutline::origin),
2645 Some(stored[0])
2646 );
2647 let ops = view.editor.take_ops().unwrap();
2648 let deleted = ops.iter().filter_map(|op| match op {
2649 onestore::op::PageOp::Delete { object } => Some(*object),
2650 _ => None,
2651 });
2652 assert_eq!(
2653 deleted.collect::<BTreeSet<_>>(),
2654 BTreeSet::from([alpha, beta, gamma, delta])
2655 );
2656 assert_eq!(ops.len(), 4);
2657 let _ = view.undo(false).unwrap();
2658 assert_eq!(origins(&view), stored);
2659 assert_eq!(view.editor.whole(), Some(Whole::Page));
2660 view.editor.take_ops().unwrap();
2661 }
2662
2663 select_page(&mut view, alpha);
2664 let _ = view.key(&Key::Character("Z".into()), Some("Z")).unwrap();
2665 let left = view
2666 .editor
2667 .outlines()
2668 .iter()
2669 .filter(|outline| !outline.title);
2670 assert_eq!(
2671 left.map(|outline| (outline.id, outline.shown_text()))
2672 .collect::<Vec<_>>(),
2673 [(delta, "Z".to_owned())]
2674 );
2675 let _ = view.undo(false).unwrap();
2676 assert_eq!(origins(&view), stored);
2677 assert_eq!(view.editor.whole(), Some(Whole::Page));
2678 assert_eq!(view.editor.active_outline().shown_text(), "Delta");
2679}
2680
2681/// Copy takes the outlines top to bottom with a blank line between, as OneNote's plain text
2682/// does; Cut removes them as Delete does.
2683#[test]
2684fn copy_and_cut_take_the_page_selection() {
2685 let (mut view, [alpha, ..]) = page_selection_view();
2686 select_page(&mut view, alpha);
2687 let text = "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text";
2688 assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some(text));
2689 assert_eq!(view.editor.whole(), Some(Whole::Page));
2690 assert_eq!(copied(view.copy(true).unwrap()).as_deref(), Some(text));
2691 assert!(view.editor.outlines().iter().all(|outline| outline.title));
2692 assert!(view.editor.caret_outline().is_some());
2693}
2694
2695/// Dragging any selected outline's handle moves them all by one snapped offset, stored as one
2696/// edit and undone in one step; a click on a handle selects that outline alone.
2697#[test]
2698fn a_dragged_page_selection_moves_every_outline() {
2699 let (mut view, [alpha, beta, ..]) = page_selection_view();
2700 let stored = origins(&view);
2701 select_page(&mut view, alpha);
2702 let now = Instant::now();
2703 let bounds = view
2704 .editor
2705 .outlines()
2706 .iter()
2707 .find(|outline| outline.id == beta)
2708 .unwrap()
2709 .bounds();
2710 let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0];
2711 let _ = view.pointer_moved(header).unwrap();
2712 let _ = view.pointer_pressed(now).unwrap();
2713 assert_eq!(view.editor.whole(), Some(Whole::Page));
2714 let _ = view
2715 .pointer_moved([header[0] + 30.0, header[1] + 40.0])
2716 .unwrap();
2717 // Every selected outline follows the drag while it lasts.
2718 let dragged = view.primitives(COLORS).unwrap().len();
2719 assert!(dragged > 0);
2720 let _ = view.pointer_released().unwrap();
2721 // Beta lands on the grid, 18 pt cells from the margin origin, and the rest follow.
2722 let moved = origins(&view);
2723 assert_eq!(moved[1], [324.0, 212.4]);
2724 for (before, after) in stored.iter().zip(&moved) {
2725 for axis in 0..2 {
2726 let delta = moved[1][axis] - stored[1][axis];
2727 assert!((after[axis] - before[axis] - delta).abs() < 1e-3);
2728 }
2729 }
2730 assert_eq!(view.editor.whole(), Some(Whole::Page));
2731 assert_eq!(view.editor.take_ops().unwrap().len(), 4);
2732 let _ = view.undo(false).unwrap();
2733 assert_eq!(origins(&view), stored);
2734
2735 // Option-Command-arrows nudge the whole selection.
2736 let _ = view
2737 .modifiers_changed(Modifiers {
2738 command: true,
2739 option: true,
2740 ..Modifiers::default()
2741 })
2742 .unwrap();
2743 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
2744 let _ = view.modifiers_changed(Modifiers::default()).unwrap();
2745 assert!(
2746 origins(&view)
2747 .iter()
2748 .zip(&stored)
2749 .all(|(after, before)| *after == [before[0] + 1.0, before[1]])
2750 );
2751
2752 let _ = view.pointer_moved(header).unwrap();
2753 let _ = view.pointer_pressed(now + Duration::from_secs(1)).unwrap();
2754 let _ = view.pointer_released().unwrap();
2755 assert_eq!(view.editor.whole(), Some(Whole::Outline));
2756 assert_eq!(view.editor.active_outline().id, beta);
2757}
2758
2759/// A toggle on the page selection turns on everywhere unless every outline has it, as one
2760/// undo step that keeps the selection.
2761#[test]
2762fn formatting_the_page_selection_settles_on_one_state() {
2763 let (mut view, [alpha, beta, ..]) = page_selection_view();
2764 let bold = |view: &mut PageView| {
2765 let ids = view
2766 .editor
2767 .outlines()
2768 .iter()
2769 .filter(|outline| !outline.title)
2770 .map(|outline| outline.id)
2771 .collect::<Vec<_>>();
2772 let focus = view.editor.active_outline().id;
2773 let selection = view.editor.selection();
2774 let whole = view.editor.whole();
2775 let states = ids
2776 .into_iter()
2777 .map(|id| {
2778 view.editor.focus_outline(id).unwrap();
2779 view.editor.select_all().unwrap();
2780 view.editor
2781 .format_state()
2782 .unwrap()
2783 .toggles
2784 .contains(&Toggle::Bold)
2785 })
2786 .collect::<Vec<_>>();
2787 if whole == Some(Whole::Page) {
2788 select_page(view, focus);
2789 } else {
2790 view.editor.focus_outline(focus).unwrap();
2791 view.editor.select(selection).unwrap();
2792 }
2793 states
2794 };
2795 view.editor.focus_outline(beta).unwrap();
2796 view.editor.select_all().unwrap();
2797 let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap();
2798 assert_eq!(bold(&mut view), [false, true, false, false]);
2799 select_page(&mut view, alpha);
2800 let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap();
2801 assert_eq!(view.editor.whole(), Some(Whole::Page));
2802 assert_eq!(bold(&mut view), [true; 4]);
2803 let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap();
2804 assert_eq!(bold(&mut view), [false; 4]);
2805 let _ = view.undo(false).unwrap();
2806 assert_eq!(bold(&mut view), [true; 4]);
2807 assert_eq!(view.editor.whole(), Some(Whole::Page));
2808}
2809
2810/// Selecting the last outline's text again by other means selects only that text.
2811#[test]
2812fn the_page_selection_does_not_return_with_its_text_selection() {
2813 let (mut view, [alpha, ..]) = page_selection_view();
2814 select_page(&mut view, alpha);
2815 let whole = view.editor.selection();
2816 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
2817 view.editor.select(whole).unwrap();
2818 assert_eq!(view.editor.whole(), None);
2819}
2820
2821/// The point under the pointer stays put through a pinch, as in Safari, even where the
2822/// page's content is too small to scroll that far; scrolling afterwards still reaches it.
2823#[test]
2824fn pinch_zooms_about_the_pointer_within_range() {
2825 let mut engine = TextEngine::default();
2826 let editor = CanvasEditor::new(
2827 &mut engine,
2828 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
2829 240.0,
2830 )
2831 .unwrap();
2832 let mut view = PageView::new(
2833 editor,
2834 engine,
2835 None,
2836 [800, 600],
2837 1.0,
2838 Duration::from_millis(500),
2839 );
2840 let pointer = [700.0, 500.0];
2841 let under = view.viewport.document_point(pointer);
2842 let shown = view.viewport.document_point([800.0, 600.0]);
2843 let stays = |view: &PageView| {
2844 let after = view.viewport.document_point(pointer);
2845 assert!(
2846 (after[0] - under[0]).abs() < 1e-3 && (after[1] - under[1]).abs() < 1e-3,
2847 "{under:?} moved to {after:?}"
2848 );
2849 };
2850 let zoom = view.zoom();
2851 for factor in [1.05, 1.0 / 1.05, 1.05] {
2852 for _ in 0..10 {
2853 assert!(view.pinch(factor, pointer).unwrap().moved);
2854 stays(&view);
2855 }
2856 }
2857 assert!((view.zoom() / zoom - 1.05_f32.powi(10)).abs() < 1e-4);
2858 let _ = view.wheel([-5000.0; 2]).unwrap();
2859 let corner = view.viewport.document_point([800.0, 600.0]);
2860 assert!((corner[0] - shown[0]).abs() < 1e-3 && (corner[1] - shown[1]).abs() < 1e-3);
2861 let _ = view.pinch(100.0, pointer).unwrap();
2862 assert!((view.zoom() - 4.0).abs() < 1e-5);
2863}
2864
2865fn texts(view: &PageView) -> Vec<(String, [f32; 2])> {
2866 view.editor
2867 .outlines()
2868 .iter()
2869 .filter(|outline| !outline.title)
2870 .map(|outline| (outline.shown_text(), outline.origin()))
2871 .collect()
2872}
2873
2874/// Drags Insert Space from `from` to `to`, view points at 1:1.
2875fn insert_space(view: &mut PageView, from: [f32; 2], to: [f32; 2]) {
2876 let _ = view.insert_space();
2877 let _ = view.pointer_moved(from).unwrap();
2878 let _ = view.pointer_pressed(Instant::now()).unwrap();
2879 let _ = view.pointer_moved(to).unwrap();
2880 let _ = view.pointer_released().unwrap();
2881}
2882
2883/// As OneNote 2010's Insert Space (lab, 2026-09-29): what starts at or below the line moves
2884/// by the drag, an outline it crosses parts between paragraphs into a new outline below, the
2885/// title stays, and one undo step puts everything back.
2886#[test]
2887fn insert_space_moves_what_lies_below_and_parts_a_crossed_outline() {
2888 let (mut view, [alpha, ..]) = page_selection_view();
2889 let stored = texts(&view);
2890 let title = view.editor.outlines()[0].origin();
2891 let alpha_outline = view
2892 .editor
2893 .outlines()
2894 .iter()
2895 .find(|o| o.id == alpha)
2896 .unwrap();
2897 let second = alpha_outline.origin()[1] + alpha_outline.shaped().paragraphs[1].origin[1];
2898 let _ = view.insert_space();
2899 let _ = view.pointer_moved([400.0, second]).unwrap();
2900 assert!(view.inserting_space());
2901 assert_eq!(view.cursor(), Cursor::RowResize);
2902 let _ = view.pointer_pressed(Instant::now()).unwrap();
2903 let _ = view.pointer_moved([400.0, second + 50.0]).unwrap();
2904 // Nothing moves until the drag ends; its lines, arrow and landing tints show.
2905 assert_eq!(texts(&view), stored);
2906 assert!(view.primitives(COLORS).is_ok());
2907 let _ = view.pointer_released().unwrap();
2908 assert!(!view.inserting_space());
2909 let moved = texts(&view);
2910 let expected: Vec<(String, [f32; 2])> = vec![
2911 ("Alpha one".into(), stored[0].1),
2912 ("Alpha two".into(), [stored[0].1[0], second + 50.0]),
2913 ("Beta".into(), [300.0, 230.0]),
2914 ("Gamma long line of text".into(), [90.0, 350.0]),
2915 ("Delta".into(), [380.0, 72.0]),
2916 ];
2917 assert_eq!(moved, expected);
2918 assert_eq!(view.editor.outlines()[0].origin(), title);
2919 assert!(!view.editor.take_ops().unwrap().is_empty());
2920 let _ = view.undo(false).unwrap();
2921 assert_eq!(texts(&view), stored);
2922 assert_eq!(view.editor.whole(), None);
2923 let _ = view.undo(true).unwrap();
2924 assert_eq!(texts(&view), expected);
2925}
2926
2927/// Within half an inch of the view's side the line runs down the page and moves what starts
2928/// right of it; taking space back stops at the end of what stays before the line.
2929#[test]
2930fn insert_space_runs_down_the_page_near_its_side_and_takes_back_only_space() {
2931 let (mut view, _) = page_selection_view();
2932 let stored = texts(&view);
2933 let _ = view.insert_space();
2934 let _ = view.pointer_moved([20.0, 200.0]).unwrap();
2935 assert_eq!(view.cursor(), Cursor::ColResize);
2936 let _ = view.pointer_moved([790.0, 200.0]).unwrap();
2937 assert_eq!(view.cursor(), Cursor::ColResize);
2938 insert_space(&mut view, [20.0, 200.0], [60.0, 200.0]);
2939 let right: Vec<_> = stored
2940 .iter()
2941 .map(|(text, [x, y])| (text.clone(), [x + 40.0, *y]))
2942 .collect();
2943 assert_eq!(texts(&view), right);
2944 let _ = view.undo(false).unwrap();
2945 // Undo scrolls to what it restores; the drags below are in page points.
2946 view.viewport.origin = [0.0; 2];
2947
2948 // Beta ends at its line's bottom; Gamma, below the line at 250, comes up to it and no
2949 // further.
2950 let beta = view.editor.outlines()[2].bounds().y1 as f32;
2951 insert_space(&mut view, [400.0, 250.0], [400.0, 20.0]);
2952 let gamma = texts(&view)[2].1[1];
2953 assert!((gamma - (300.0 - (250.0 - beta))).abs() < 1e-3, "{gamma}");
2954 // A line with something stationary across it cannot take space back.
2955 let _ = view.undo(false).unwrap();
2956 view.viewport.origin = [0.0; 2];
2957 insert_space(&mut view, [400.0, 185.0], [400.0, 20.0]);
2958 assert_eq!(texts(&view), stored);
2959}
2960
2961#[test]
2962fn escape_cancels_insert_space() {
2963 let (mut view, _) = page_selection_view();
2964 let stored = texts(&view);
2965 let _ = view.editor.take_ops().unwrap();
2966 let _ = view.insert_space();
2967 let _ = view.pointer_moved([400.0, 150.0]).unwrap();
2968 let _ = view.key(&Key::Named(NamedKey::Escape), None).unwrap();
2969 assert!(!view.inserting_space());
2970 let _ = view.insert_space();
2971 let _ = view.pointer_pressed(Instant::now()).unwrap();
2972 let _ = view.pointer_moved([400.0, 250.0]).unwrap();
2973 let _ = view.key(&Key::Named(NamedKey::Escape), None).unwrap();
2974 let _ = view.pointer_released().unwrap();
2975 assert!(!view.inserting_space());
2976 assert_eq!(texts(&view), stored);
2977 assert!(view.editor.take_ops().unwrap().is_empty());
2978}
2979
2980/// Rule lines draw as OneNote 2010 draws them: horizontal lines from the margin origin
2981/// down, a margin line 1/96 inch left of it, and grid lines through it either way.
2982#[test]
2983fn rule_lines_start_at_the_margin_origin() {
2984 // Each line's axis and the centre across it, to a hundredth of a point.
2985 let lines = |lines| {
2986 super::rule_primitives(
2987 Some(lines),
2988 [36.0, 14.4],
2989 [0.0, 0.0, 100.0, 100.0],
2990 0.5,
2991 Paper::WHITE,
2992 )
2993 .into_iter()
2994 .map(|primitive| match primitive {
2995 Primitive::Rect {
2996 rect: [x0, y0, x1, y1],
2997 ..
2998 } => {
2999 let vertical = y1 - y0 > x1 - x0;
3000 let center = if vertical { x0 + x1 } else { y0 + y1 } / 2.0;
3001 (vertical, (center * 100.0).round() / 100.0)
3002 }
3003 _ => panic!("rule lines are rectangles"),
3004 })
3005 .collect::<Vec<_>>()
3006 };
3007 let [(_, standard), (_, small)] = [
3008 crate::template::RULE_LINES[2],
3009 crate::template::RULE_LINES[4],
3010 ];
3011 assert_eq!(
3012 lines(standard),
3013 [(false, 14.4), (false, 47.52), (false, 80.64), (true, 35.25)]
3014 );
3015 let vertical: Vec<f32> = lines(small)
3016 .into_iter()
3017 .filter_map(|(vertical, at)| vertical.then_some(at))
3018 .collect();
3019 assert_eq!(
3020 vertical,
3021 [0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 96.0]
3022 );
3023}
3024
3025/// Template art the Page Color menu gives draws in the frame after, over the rule lines as
3026/// OneNote's opaque pictures hide them, and follows undo and redo without a reload.
3027#[test]
3028fn art_draws_at_once_over_the_rule_lines_and_follows_undo() {
3029 let mut engine = TextEngine::default();
3030 let outline = TextOutline::new(
3031 &mut engine,
3032 TextDocument::new(vec![Paragraph::new("Notes".into(), Format::default())]).unwrap(),
3033 300.0,
3034 [300.0, 100.0],
3035 )
3036 .unwrap()
3037 .snapshot();
3038 let page = Page {
3039 identity: None,
3040 title: String::new(),
3041 created: None,
3042 margin_origin: [36.0, 14.4],
3043 rtl: false,
3044 color: None,
3045 rule_lines: Some(crate::template::RULE_LINES[2].1),
3046 definitions: Default::default(),
3047 objects: vec![onestore::page::PageObject::Outline(outline)],
3048 };
3049 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
3050 let mut view = PageView::new(
3051 editor,
3052 engine,
3053 Some((scene, [0.0; 2])),
3054 [800, 600],
3055 1.0,
3056 Duration::from_millis(500),
3057 );
3058 let ivy = crate::template::find("Ivy").unwrap();
3059 let art = ivy.pictures().unwrap();
3060 let [x, y] = ivy.art[0].position;
3061 let [width, height] = ivy.art[0].size.unwrap();
3062 let drawn = |view: &mut PageView, paper: Paper| {
3063 view.update_pictures(paper, std::task::Waker::noop());
3064 let primitives = view.primitives(TextColors { paper, ..COLORS }).unwrap();
3065 let backing = primitives.iter().position(|primitive| {
3066 matches!(primitive, Primitive::Rect { rect, color }
3067 if *rect == [x, y, x + width, y + height] && *color == paper.color)
3068 });
3069 let image = primitives
3070 .iter()
3071 .position(|primitive| matches!(primitive, Primitive::Image { .. }));
3072 let lines = primitives.iter().rposition(
3073 |primitive| matches!(primitive, Primitive::Rect { rect, .. } if rect[0] < x),
3074 );
3075 (backing, image, lines)
3076 };
3077 let _ = view
3078 .set_paper(None, view.editor.rule_lines(), Some(art))
3079 .unwrap();
3080 let (backing, image, lines) = drawn(&mut view, Paper::WHITE);
3081 assert!(
3082 lines < backing && backing < image,
3083 "{lines:?} {backing:?} {image:?}"
3084 );
3085 let dark = Paper {
3086 color: [0.0137, 0.0144, 0.0159, 1.0],
3087 ink: [0.791, 0.791, 0.791, 1.0],
3088 };
3089 // Dark paper hides the lines in its own colour while its recolouring is made.
3090 assert!(matches!(drawn(&mut view, dark), (Some(_), None, _)));
3091 let _ = view.undo(false).unwrap();
3092 assert!(matches!(drawn(&mut view, Paper::WHITE), (None, None, _)));
3093 let _ = view.undo(true).unwrap();
3094 assert!(matches!(
3095 drawn(&mut view, Paper::WHITE),
3096 (Some(_), Some(_), _)
3097 ));
3098}
3099
3100#[test]
3101fn an_attached_file_selects_opens_on_a_double_click_and_deletes() {
3102 let (mut view, _) = picture_view();
3103 let now = Instant::now();
3104 let file = onestore::page::Attachment {
3105 id: onestore::page::text::new_id().unwrap(),
3106 filename: "notes.txt".into(),
3107 source_path: None,
3108 size: Some([24.0, 24.0]),
3109 layout: Default::default(),
3110 bytes: Some(Arc::from(b"notes".as_slice())),
3111 preview: None,
3112 recording: None,
3113 tags: Vec::new(),
3114 };
3115 view.editor
3116 .move_selection(&mut view.engine, Movement::DocumentEnd, false)
3117 .unwrap();
3118 assert!(view.insert_attachment(file.clone()).unwrap().changed);
3119 let stored = view.editor.attachment(file.id).unwrap().clone();
3120 // OneNote draws a broken picture for a file stored without its icon.
3121 assert!(stored.preview.as_ref().unwrap().starts_with(b"\x89PNG"));
3122 view.primitives(COLORS).unwrap();
3123
3124 let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap();
3125 let center = [(x0 + x1) / 2.0, (y0 + y1) / 2.0];
3126 click(&mut view, center, now);
3127 assert_eq!(view.object_focus(), Some(ObjectFocus::File(file.id)));
3128 assert!(!view.accepts_text());
3129 view.primitives(COLORS).unwrap();
3130 let _ = view.pointer_moved(center).unwrap();
3131 assert_eq!(
3132 view.pointer_pressed(now + Duration::from_millis(100))
3133 .unwrap()
3134 .request,
3135 Some(Request::OpenAttachment(stored.clone()))
3136 );
3137 let _ = view.pointer_released().unwrap();
3138 let (_, context) = view.context().unwrap().unwrap();
3139 assert_eq!(context.attachment, Some(stored));
3140
3141 assert!(
3142 view.key(&Key::Named(NamedKey::Delete), None)
3143 .unwrap()
3144 .changed
3145 );
3146 assert!(view.editor.attachment(file.id).is_none());
3147 assert!(view.accepts_text());
3148}
3149
3150/// OneNote 2010 puts a file attached or dropped on blank page on the page there, where it
3151/// selects, drags on the grid, opens and deletes as one (`corpus/attachment-floating`).
3152#[test]
3153fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() {
3154 use onestore::op::PageOp;
3155 let mut engine = TextEngine::default();
3156 let document =
3157 TextDocument::new(vec![Paragraph::new("Text".into(), Default::default())]).unwrap();
3158 let outline = TextOutline::new(&mut engine, document, 240.0, [36.0, 36.0]).unwrap();
3159 let page = Page {
3160 title: String::new(),
3161 identity: None,
3162 created: None,
3163 margin_origin: [36.0, 14.4],
3164 rtl: false,
3165 color: None,
3166 rule_lines: None,
3167 definitions: Default::default(),
3168 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
3169 };
3170 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
3171 let mut view = PageView::new(
3172 editor,
3173 engine,
3174 Some((scene, [0.0; 2])),
3175 [800, 600],
3176 1.0,
3177 Duration::from_millis(500),
3178 );
3179 view.viewport.scale = 1.0;
3180 view.viewport.origin = [0.0; 2];
3181 let _ = view.editor.take_ops().unwrap();
3182 let file = onestore::page::Attachment {
3183 id: onestore::page::text::new_id().unwrap(),
3184 filename: "float.txt".into(),
3185 source_path: None,
3186 size: Some([24.0, 24.0]),
3187 layout: Default::default(),
3188 bytes: Some(Arc::from(b"float".as_slice())),
3189 preview: None,
3190 recording: None,
3191 tags: Vec::new(),
3192 };
3193 let _ = view.drop_attachment([400.0, 400.0], file.clone()).unwrap();
3194 assert_eq!(view.editor.outlines().len(), 1);
3195 let placed = |view: &PageView| {
3196 view.editor
3197 .page()
3198 .unwrap()
3199 .objects
3200 .into_iter()
3201 .find_map(|object| match object {
3202 onestore::page::PageObject::Attachment(file) => Some(file),
3203 _ => None,
3204 })
3205 };
3206 // At the caret a click there places, on the 18 pt grid from the margin origin.
3207 let stored = placed(&view).unwrap();
3208 assert_eq!(
3209 [stored.layout.x, stored.layout.y],
3210 [Some(396.0), Some(392.4)]
3211 );
3212 assert_eq!(stored.layout.max_width, Some(54.0));
3213 assert!(matches!(
3214 &view.editor.take_ops().unwrap()[..],
3215 [PageOp::Add { object: onestore::page::PageObject::Attachment(added), before: None }]
3216 if added.id == file.id
3217 ));
3218 assert!(
3219 view.primitives(COLORS)
3220 .unwrap()
3221 .iter()
3222 .any(|primitive| matches!(primitive, Primitive::Text { .. }))
3223 );
3224
3225 let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap();
3226 let center = [(x0 + x1) / 2.0, (y0 + y1) / 2.0];
3227 let now = Instant::now();
3228 let _ = view.pointer_moved(center).unwrap();
3229 let _ = view.pointer_pressed(now).unwrap();
3230 assert_eq!(view.object_focus(), Some(ObjectFocus::File(file.id)));
3231 let _ = view
3232 .pointer_moved([center[0] + 100.0, center[1] + 50.0])
3233 .unwrap();
3234 view.primitives(COLORS).unwrap();
3235 let _ = view.pointer_released().unwrap();
3236 let moved = placed(&view).unwrap();
3237 assert_eq!([moved.layout.x, moved.layout.y], [Some(504.0), Some(446.4)]);
3238 assert!(matches!(
3239 &view.editor.take_ops().unwrap()[..],
3240 [PageOp::Outline { object, .. }] if *object == file.id
3241 ));
3242 let (_, context) = view.context().unwrap().unwrap();
3243 assert_eq!(context.attachment.map(|found| found.id), Some(file.id));
3244
3245 let _ = view.undo(false).unwrap();
3246 assert_eq!(placed(&view).unwrap().layout.x, Some(396.0));
3247 let _ = view.undo(true).unwrap();
3248 assert_eq!(placed(&view).unwrap().layout.x, Some(504.0));
3249 let _ = view.editor.take_ops().unwrap();
3250
3251 let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap();
3252 click(
3253 &mut view,
3254 [(x0 + x1) / 2.0, (y0 + y1) / 2.0],
3255 now + Duration::from_secs(1),
3256 );
3257 let _ = view.key(&Key::Named(NamedKey::Delete), None).unwrap();
3258 assert!(placed(&view).is_none());
3259 assert!(matches!(
3260 &view.editor.take_ops().unwrap()[..],
3261 [PageOp::Delete { object }] if *object == file.id
3262 ));
3263 let _ = view.undo(false).unwrap();
3264 assert_eq!(placed(&view).unwrap().layout.x, Some(504.0));
3265 view.primitives(COLORS).unwrap();
3266}
3267
3268/// Words the fake dictionary misses are marked red under their text once checked, except
3269/// the word still being typed; the context menu offers the dictionary's corrections, and
3270/// taking one is one edit and one undo step.
3271#[test]
3272fn misspelled_words_are_marked_and_corrected_in_one_edit() {
3273 struct Wake(std::sync::mpsc::Sender<()>);
3274 impl std::task::Wake for Wake {
3275 fn wake(self: Arc<Self>) {
3276 let _ = self.0.send(());
3277 }
3278 }
3279 let mut engine = TextEngine::default();
3280 let document = TextDocument::new(vec![Paragraph::new(
3281 "Ths sentence has the typos".into(),
3282 Default::default(),
3283 )])
3284 .unwrap();
3285 let outline = TextOutline::new(&mut engine, document, 400.0, [36.0, 36.0]).unwrap();
3286 let editor = CanvasEditor::from_page(
3287 Page {
3288 title: String::new(),
3289 identity: None,
3290 created: None,
3291 margin_origin: [36.0, 14.4],
3292 rtl: false,
3293 color: None,
3294 rule_lines: None,
3295 definitions: Default::default(),
3296 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
3297 },
3298 &mut engine,
3299 )
3300 .unwrap();
3301 let mut view = PageView::new(editor, engine, None, [800, 600], 1.0, Duration::ZERO);
3302 view.viewport.scale = 1.0;
3303 view.viewport.origin = [0.0; 2];
3304 let (woken, wakes) = std::sync::mpsc::channel();
3305 view.spelling = Some(crate::spelling::Spelling::new(
3306 Box::new(crate::spelling::tests::Fake),
3307 Arc::new(Wake(woken)).into(),
3308 ));
3309 let red = draw::srgb(0xff, 0x00, 0x00);
3310 let marks = |view: &PageView| -> Vec<[f32; 2]> {
3311 view.primitives(COLORS)
3312 .unwrap()
3313 .into_iter()
3314 .filter_map(|primitive| match primitive {
3315 Primitive::Rect { rect, color } if color == red => Some([rect[0], rect[1]]),
3316 _ => None,
3317 })
3318 .collect()
3319 };
3320 assert!(marks(&view).is_empty());
3321 wakes
3322 .recv_timeout(Duration::from_secs(10))
3323 .expect("the spelling thread wakes the host");
3324 let outline = view.editor.active_outline();
3325 let [origin_x, origin_y] = outline.origin();
3326 let [left, right, baseline] = outline.underlines(0, 0..3).unwrap()[0];
3327 let under = marks(&view);
3328 // OneNote's pixels along "Ths": a column each, stepping mid, low, mid, high on the
3329 // three pixel rows from one to three below the baseline.
3330 let [start, end, line] =
3331 [origin_x + left, origin_x + right, origin_y + baseline].map(f32::round);
3332 assert_eq!(under.len(), (end - start) as usize);
3333 for (column, [x, y]) in under.into_iter().enumerate() {
3334 assert_eq!(x, start + column as f32);
3335 assert_eq!(y, line + [2.0, 3.0, 2.0, 1.0][column % 4]);
3336 }
3337
3338 let _ = view
3339 .pointer_moved([origin_x + (left + right) / 2.0, origin_y + baseline - 3.0])
3340 .unwrap();
3341 let (_, context) = view.context().unwrap().unwrap();
3342 let correction = context.spelling.unwrap();
3343 assert_eq!(correction.word, "Ths");
3344 assert_eq!(correction.suggestions, ["This", "Thus"]);
3345 view.editor.take_ops().unwrap();
3346 assert!(view.correct(&correction, "This").unwrap().changed);
3347 assert_eq!(
3348 view.editor.active_outline().shown_text(),
3349 "This sentence has the typos"
3350 );
3351 assert!(!view.editor.take_ops().unwrap().is_empty());
3352 marks(&view);
3353 wakes
3354 .recv_timeout(Duration::from_secs(10))
3355 .expect("the spelling thread wakes the host");
3356 assert!(marks(&view).is_empty());
3357 let _ = view.undo(false).unwrap();
3358 assert_eq!(
3359 view.editor.active_outline().shown_text(),
3360 "Ths sentence has the typos"
3361 );
3362
3363 view.editor
3364 .move_selection(&mut view.engine, Movement::DocumentEnd, false)
3365 .unwrap();
3366 let _ = view.key(&Key::Character(" ".into()), Some(" ")).unwrap();
3367 for letter in ["q", "w", "r", "t"] {
3368 let _ = view
3369 .key(&Key::Character(letter.into()), Some(letter))
3370 .unwrap();
3371 }
3372 marks(&view);
3373 wakes
3374 .recv_timeout(Duration::from_secs(10))
3375 .expect("the spelling thread wakes the host");
3376 let typing = marks(&view).len();
3377 let _ = view.key(&Key::Character(" ".into()), Some(" ")).unwrap();
3378 marks(&view);
3379 wakes
3380 .recv_timeout(Duration::from_secs(10))
3381 .expect("the spelling thread wakes the host");
3382 assert!(marks(&view).len() > typing);
3383}
3384
3385/// See Playback scrolls the note playing into view, as OneNote 2010 does
3386/// (`corpus/recording/native/read/onenote-see-playback-scrolls.png`); the caret stays.
3387#[test]
3388fn the_note_playing_scrolls_into_view() {
3389 let mut engine = TextEngine::default();
3390 let lines = (0..100)
3391 .map(|line| Paragraph::new(format!("Line {line}"), Default::default()))
3392 .collect();
3393 let outline = TextOutline::new(
3394 &mut engine,
3395 TextDocument::new(lines).unwrap(),
3396 240.0,
3397 [36.0, 36.0],
3398 )
3399 .unwrap();
3400 let editor = CanvasEditor::from_page(
3401 Page {
3402 title: String::new(),
3403 identity: None,
3404 created: None,
3405 margin_origin: [36.0, 14.4],
3406 rtl: false,
3407 color: None,
3408 rule_lines: None,
3409 definitions: Default::default(),
3410 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
3411 },
3412 &mut engine,
3413 )
3414 .unwrap();
3415 let mut view = PageView::new(
3416 editor,
3417 engine,
3418 None,
3419 [800, 200],
3420 1.0,
3421 Duration::from_millis(500),
3422 );
3423 view.viewport.scale = 1.0;
3424 view.viewport.origin = [0.0; 2];
3425 let caret = view.editor.selection();
3426 let outline = view.editor.outlines()[0].id;
3427 let at = |offset| TextPosition {
3428 paragraph: 60,
3429 offset,
3430 };
3431 let note: Selection = [at(0), at(7)].into();
3432 let played = Some((outline, note));
3433 assert!(view.set_played(played).unwrap().moved);
3434 let rects = view.editor.outlines()[0].range_rects(note).unwrap();
3435 let top = view.editor.outlines()[0].origin()[1] + rects[0].y0 as f32;
3436 let bottom = view.editor.outlines()[0].origin()[1] + rects[0].y1 as f32;
3437 let shown = [top, bottom].map(|y| y + view.viewport.origin[1]);
3438 assert!(shown[0] >= 0.0 && shown[1] <= 200.0, "{shown:?}");
3439 assert_eq!(view.editor.selection(), caret);
3440 // Playing on within the same note leaves the view where the reader put it.
3441 view.viewport.origin[1] = 0.0;
3442 assert!(!view.set_played(played).unwrap().moved);
3443 assert_eq!(view.viewport.origin[1], 0.0);
3444}
3445
3446/// The Spelling pane walks marked words from the caret in page order, wraps to the page's
3447/// top, and ends once none is left.
3448#[test]
3449fn the_spelling_pane_walks_marked_words_from_the_caret() {
3450 let mut engine = TextEngine::default();
3451 let document = TextDocument::new(
3452 ["the wrng word", "this is qwrt here"]
3453 .map(|text| Paragraph::new(text.into(), Default::default()))
3454 .into(),
3455 )
3456 .unwrap();
3457 let outline = TextOutline::new(&mut engine, document, 400.0, [36.0, 36.0]).unwrap();
3458 let editor = CanvasEditor::from_page(
3459 Page {
3460 title: String::new(),
3461 identity: None,
3462 created: None,
3463 margin_origin: [36.0, 14.4],
3464 rtl: false,
3465 color: None,
3466 rule_lines: None,
3467 definitions: Default::default(),
3468 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
3469 },
3470 &mut engine,
3471 )
3472 .unwrap();
3473 let mut view = PageView::new(editor, engine, None, [800, 600], 1.0, Duration::ZERO);
3474 let spelling = crate::spelling::Spelling::new(
3475 Box::new(crate::spelling::tests::Fake),
3476 std::task::Waker::noop().clone(),
3477 );
3478 view.spelling = Some(spelling.clone());
3479 let caret = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into();
3480 view.editor.select(caret(1, 17)).unwrap();
3481 let next = |view: &mut PageView| {
3482 view.next_correction()
3483 .unwrap()
3484 .map(|(_, correction)| correction.word)
3485 };
3486 assert_eq!(next(&mut view).as_deref(), Some("wrng"));
3487 assert_eq!(
3488 view.editor.selection().positions,
3489 [
3490 TextPosition {
3491 paragraph: 0,
3492 offset: 4
3493 },
3494 TextPosition {
3495 paragraph: 0,
3496 offset: 8
3497 }
3498 ]
3499 );
3500 spelling.ignore("wrng");
3501 assert_eq!(next(&mut view).as_deref(), Some("qwrt"));
3502 spelling.learn("qwrt");
3503 assert_eq!(next(&mut view), None);
3504}
3505
3506/// Page `title` of `section`, with a view of it whose scene holds its pictures.
3507fn scene_view(section: &[u8], title: &str) -> PageView {
3508 let arena = onestore::Arena::default();
3509 let mut section = onestore::Section::open(&arena, section.to_vec()).unwrap();
3510 let (space, ..) = section
3511 .pages()
3512 .unwrap()
3513 .into_iter()
3514 .find(|page| page.1 == title)
3515 .unwrap();
3516 let mut engine = TextEngine::default();
3517 let (scene, editor) = PageScene::from_page(section.page(space).unwrap(), &mut engine).unwrap();
3518 PageView::new(
3519 editor,
3520 engine,
3521 Some((scene, [0.0; 2])),
3522 [800, 600],
3523 1.0,
3524 Duration::from_millis(500),
3525 )
3526}
3527
3528fn device(view: &PageView, point: [f32; 2]) -> [f32; 2] {
3529 [0, 1].map(|axis| point[axis] * view.viewport.scale + view.viewport.origin[axis])
3530}
3531
3532/// A click selects a picture with a link and Ctrl+click (Command on macOS) follows it, as in
3533/// OneNote 2010, whose tooltip says so (`corpus/picture-link`).
3534#[test]
3535fn ctrl_click_follows_a_pictures_link() {
3536 let mut view = scene_view(
3537 include_bytes!("../../../../corpus/m6/native-features-01/notebook/Features.one"),
3538 "Image png",
3539 );
3540 let (id, [x, y], [width, height]) = view
3541 .editor
3542 .objects
3543 .iter()
3544 .find_map(|object| match object {
3545 crate::editor::page::Content::Image(image) => Some(image.id),
3546 _ => None,
3547 })
3548 .and_then(|id| {
3549 let (origin, size) = view.editor.image_placement(id)?;
3550 Some((id, origin, size))
3551 })
3552 .unwrap();
3553 assert_eq!(
3554 view.editor.picture_link(id),
3555 Some("https://example.invalid/image/png")
3556 );
3557 let _ = view
3558 .pointer_moved(device(&view, [x + width / 2.0, y + height / 2.0]))
3559 .unwrap();
3560 let response = view.pointer_pressed(Instant::now()).unwrap();
3561 let _ = view.pointer_released().unwrap();
3562 assert_eq!(response.request, None);
3563 assert_eq!(view.object_focus, Some(ObjectFocus::Image(id)));
3564 let _ = view
3565 .modifiers_changed(Modifiers {
3566 control: true,
3567 command: true,
3568 ..Modifiers::default()
3569 })
3570 .unwrap();
3571 assert_eq!(view.cursor(), Cursor::Pointer);
3572 let response = view.pointer_pressed(Instant::now()).unwrap();
3573 let _ = view.pointer_released().unwrap();
3574 assert_eq!(
3575 response.request,
3576 Some(Request::OpenLink(
3577 "https://example.invalid/image/png".into()
3578 ))
3579 );
3580}
3581
3582/// A click on the check box OneNote 2010 draws beside a tagged picture on the page checks
3583/// it, and undo clears it (`corpus/object-tags`).
3584#[test]
3585fn a_check_box_beside_a_page_picture_checks_it() {
3586 let mut view = scene_view(
3587 include_bytes!("../../../../corpus/object-tags/native/notebook/files.one"),
3588 "Pictures and files",
3589 );
3590 let (id, [x, y], [_, height]) = view
3591 .editor
3592 .objects
3593 .iter()
3594 .find_map(|object| match object {
3595 crate::editor::page::Content::Image(image) if !image.tags.is_empty() => Some(image.id),
3596 _ => None,
3597 })
3598 .and_then(|id| {
3599 let (origin, size) = view.editor.image_placement(id)?;
3600 Some((id, origin, size))
3601 })
3602 .unwrap();
3603 let status = |view: &PageView| {
3604 view.editor
3605 .objects
3606 .iter()
3607 .find_map(|object| match object {
3608 crate::editor::page::Content::Image(image) if image.id == id => {
3609 Some(image.tags[0].status)
3610 }
3611 _ => None,
3612 })
3613 .unwrap()
3614 };
3615 let _ = view
3616 .pointer_moved(device(&view, [x - 18.0, y + height / 2.0]))
3617 .unwrap();
3618 assert_eq!(view.cursor(), Cursor::Default);
3619 assert!(view.pointer_pressed(Instant::now()).unwrap().changed);
3620 let _ = view.pointer_released().unwrap();
3621 assert_eq!(status(&view) & 1, 1);
3622 assert!(view.editor.undo(&mut view.engine).unwrap());
3623 assert_eq!(status(&view) & 1, 0);
3624}
3625
3626/// OneNote 2010 resizes a column from its right border: a column-resize pointer over it, the
3627/// table redrawn as it moves, and one edit on release.
3628#[test]
3629fn a_column_border_drags_with_a_resize_pointer_and_stores_on_release() {
3630 let mut engine = TextEngine::default();
3631 let mut editor = CanvasEditor::new(
3632 &mut engine,
3633 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(),
3634 468.0,
3635 )
3636 .unwrap();
3637 editor.insert_table(&mut engine, 1, 2).unwrap();
3638 let mut view = PageView::new(
3639 editor,
3640 engine,
3641 None,
3642 [800, 600],
3643 1.0,
3644 Duration::from_millis(500),
3645 );
3646 let _ = view.editor.take_ops().unwrap();
3647 let outline = view.editor.active_outline();
3648 let cell = outline.shaped().tables[0].cells[0].clone();
3649 let origin = outline.origin();
3650 let (scale, offset) = (view.viewport.scale, view.viewport.origin);
3651 let device = |point: [f32; 2]| [0, 1].map(|axis| point[axis] * scale + offset[axis]);
3652 let border = [
3653 origin[0] + cell.rect[2],
3654 origin[1] + (cell.rect[1] + cell.rect[3]) / 2.0,
3655 ];
3656 let _ = view.pointer_moved(device(border)).unwrap();
3657 assert_eq!(view.cursor(), Cursor::ColResize);
3658 let _ = view.pointer_pressed(Instant::now()).unwrap();
3659 let to = device([border[0] + 50.0, border[1]]);
3660 assert!(view.pointer_moved(to).unwrap().changed);
3661 assert_eq!(view.cursor(), Cursor::ColResize);
3662 view.primitives(COLORS).unwrap();
3663 let onestore::page::ParagraphContent::Table(table) =
3664 &view.editor.active_outline().document().nodes()[0].content
3665 else {
3666 panic!()
3667 };
3668 assert!(!table.columns[0].locked);
3669 let _ = view.pointer_released().unwrap();
3670 let onestore::page::ParagraphContent::Table(table) =
3671 &view.editor.active_outline().document().nodes()[0].content
3672 else {
3673 panic!()
3674 };
3675 assert!(table.columns[0].locked);
3676 assert!((table.columns[0].width - (37.11 + 50.0)).abs() < 0.01);
3677 assert_eq!(view.editor.take_ops().unwrap().len(), 1);
3678}
3679
3680/// The text of what a response asks the host to copy.
3681fn copied(response: Response) -> Option<String> {
3682 match response.request {
3683 Some(Request::Copy(clip)) => Some(clip.text()),
3684 _ => None,
3685 }
3686}
3687
3688fn reader_page() -> Page {
3689 let mut engine = TextEngine::default();
3690 let mut outlines = [
3691 vec!["A title that wraps across several displayed lines on a narrow phone"],
3692 vec!["Monday", "6:14 AM"],
3693 vec!["Retained header"],
3694 vec!["Body 😀 with enough words to wrap across many displayed lines. Body words continue here.", "", "Last paragraph"],
3695 ].map(|text| TextOutline::new(
3696 &mut engine,
3697 TextDocument::new(text.into_iter().map(|text| Paragraph::new(text.into(), Default::default())).collect()).unwrap(),
3698 468.0,
3699 [36.0, 240.0],
3700 ).unwrap().snapshot());
3701 outlines[0].title = true;
3702 outlines[0].layout.x = Some(0.0);
3703 outlines[0].layout.y = Some(0.0);
3704 outlines[1].layout.x = Some(0.0);
3705 outlines[1].layout.y = Some(8.0);
3706 outlines[1].layout.width_set_by_user = Some(false);
3707 outlines[2].layout.x = Some(0.0);
3708 outlines[2].layout.y = Some(30.0);
3709 outlines[2].layout.max_width = Some(120.0);
3710 outlines[2].layout.width_set_by_user = Some(false);
3711 let [title, date, header, body] = outlines;
3712 Page {
3713 identity: None,
3714 title: "Reader".into(),
3715 created: Some(1),
3716 margin_origin: [36.0, 14.4],
3717 rtl: false,
3718 color: None,
3719 rule_lines: None,
3720 definitions: Default::default(),
3721 objects: vec![
3722 onestore::page::PageObject::Title(onestore::page::Title {
3723 id: onestore::ExGuid::default(),
3724 date: Some(date.id),
3725 layout: Default::default(),
3726 outlines: vec![title, date, header],
3727 }),
3728 onestore::page::PageObject::Outline(body),
3729 ],
3730 }
3731}
3732
3733fn reader_view(page: Page) -> PageView {
3734 let mut engine = TextEngine::default();
3735 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
3736 let mut view = PageView::new(
3737 editor,
3738 engine,
3739 Some((scene, [20.0, 40.0])),
3740 [360, 320],
3741 1.0,
3742 Duration::ZERO,
3743 );
3744 view.host_viewport = true;
3745 view.viewport.scale = 1.0;
3746 view.viewport.origin = [0.0; 2];
3747 view
3748}
3749
3750/// `reader_page` with its body as two columns side by side, the right one a little higher,
3751/// and a closing outline below both, stored in an order other than reading order.
3752fn reader_columns_page() -> (Page, [onestore::ExGuid; 3]) {
3753 let mut page = reader_page();
3754 let mut engine = TextEngine::default();
3755 let mut outline = |text: &[&str], width, origin| {
3756 TextOutline::new(
3757 &mut engine,
3758 TextDocument::new(
3759 text.iter()
3760 .map(|text| Paragraph::new((*text).into(), Default::default()))
3761 .collect(),
3762 )
3763 .unwrap(),
3764 width,
3765 origin,
3766 )
3767 .unwrap()
3768 .snapshot()
3769 };
3770 let left = outline(
3771 &[
3772 "Left column first, with words enough to wrap",
3773 "Its second paragraph",
3774 ],
3775 200.0,
3776 [36.0, 240.0],
3777 );
3778 let right = outline(&["Right column note"], 180.0, [300.0, 230.0]);
3779 let below = outline(&["Closing outline below both"], 468.0, [36.0, 520.0]);
3780 let ids = [left.id, right.id, below.id];
3781 page.objects.truncate(1);
3782 page.objects.extend(
3783 [below, right, left]
3784 .into_iter()
3785 .map(onestore::page::PageObject::Outline),
3786 );
3787 (page, ids)
3788}
3789
3790#[test]
3791fn reader_stacks_several_outlines_in_reading_order_each_editable_where_it_is() {
3792 let (page, [left, right, below]) = reader_columns_page();
3793 let mut view = reader_view(page);
3794 let original = view.editor.page().unwrap();
3795 let title = view
3796 .editor
3797 .outlines()
3798 .iter()
3799 .find(|outline| outline.title)
3800 .unwrap()
3801 .id;
3802 assert!(view.reading_status(200.0).0);
3803 assert!(view.set_reading(Some(200.0)));
3804 let column = |view: &PageView| {
3805 view.column
3806 .as_ref()
3807 .unwrap()
3808 .outlines
3809 .iter()
3810 .map(|(id, origin, shaped)| (*id, *origin, shaped.size[1]))
3811 .collect::<Vec<_>>()
3812 };
3813 let stacked = column(&view);
3814 assert_eq!(
3815 stacked.iter().map(|(id, ..)| *id).collect::<Vec<_>>(),
3816 [title, left, right, below]
3817 );
3818 for pair in stacked.windows(2) {
3819 assert_eq!(pair[1].1[0], 36.0);
3820 assert!(pair[1].1[1] >= pair[0].1[1] + pair[0].2 + 18.0);
3821 }
3822 let offset = view.scene.as_ref().unwrap().1;
3823 let tap = |view: &mut PageView, id| {
3824 let (_, origin, _) = column(view).into_iter().find(|(o, ..)| *o == id).unwrap();
3825 let point = [origin[0] + offset[0] + 4.0, origin[1] + offset[1] + 6.0];
3826 assert!(matches!(view.hit(point), Some(Hit::Text { id: hit, .. }) if hit == id));
3827 click(view, point, Instant::now());
3828 assert_eq!(view.editor.active_outline().id, id);
3829 };
3830 tap(&mut view, right);
3831 view.move_selection(Movement::DocumentEnd, false).unwrap();
3832 assert!(view.commit_text(" typed 😀".into()).unwrap().changed);
3833 assert!(view.compose("かな".into(), None).unwrap().changed);
3834 assert!(view.editor.marked_range().is_some());
3835 assert!(view.commit_text("仮名".into()).unwrap().changed);
3836 assert!(view.editor.marked_range().is_none());
3837 assert!(!view.editor.take_ops().unwrap().is_empty());
3838 let edited = view.editor.page().unwrap();
3839 let text = |page: &Page, id| {
3840 page.objects
3841 .iter()
3842 .find_map(|object| match object {
3843 onestore::page::PageObject::Outline(outline) if outline.id == id => Some(
3844 outline
3845 .paragraphs
3846 .iter()
3847 .filter_map(|paragraph| paragraph.text())
3848 .map(|text| text.text.text().to_owned())
3849 .collect::<Vec<_>>()
3850 .join("\n"),
3851 ),
3852 _ => None,
3853 })
3854 .unwrap()
3855 };
3856 assert_eq!(text(&edited, right), "Right column note typed 😀仮名");
3857 for id in [left, below] {
3858 assert_eq!(text(&edited, id), text(&original, id));
3859 }
3860 let layout = |page: &Page, id| {
3861 page.objects
3862 .iter()
3863 .find(|object| object.id() == id)
3864 .unwrap()
3865 .layout()
3866 .clone()
3867 };
3868 for id in [left, right, below] {
3869 assert_eq!(
3870 [layout(&edited, id).x, layout(&edited, id).y],
3871 [layout(&original, id).x, layout(&original, id).y]
3872 );
3873 }
3874 let restacked = column(&view);
3875 assert_eq!(
3876 restacked.iter().map(|(id, ..)| *id).collect::<Vec<_>>(),
3877 [title, left, right, below]
3878 );
3879 tap(&mut view, below);
3880 tap(&mut view, left);
3881 let history = view.editor.history_depth();
3882 while view.editor.history_depth()[0] > 0 {
3883 assert!(view.undo(false).unwrap().changed);
3884 }
3885 assert_eq!(view.editor.page().unwrap(), original);
3886 assert_eq!(view.editor.active_outline().id, right);
3887 while view.editor.history_depth()[1] > 0 {
3888 assert!(view.undo(true).unwrap().changed);
3889 }
3890 assert_eq!(view.editor.page().unwrap(), edited);
3891 assert_eq!(view.editor.history_depth(), history);
3892 assert!(view.column.is_some());
3893 view.primitives(COLORS).unwrap();
3894 // A tap between boxes goes to the nearer one.
3895 let place = |view: &PageView, id| {
3896 let (_, origin, height) = column(view).into_iter().find(|(o, ..)| *o == id).unwrap();
3897 [origin[1] + offset[1], origin[1] + offset[1] + height]
3898 };
3899 for (id, y) in [
3900 (left, place(&view, left)[1] + 3.0),
3901 (below, place(&view, below)[0] - 3.0),
3902 ] {
3903 click(&mut view, [offset[0] + 60.0, y], Instant::now());
3904 assert_eq!(view.editor.active_outline().id, id);
3905 }
3906 // An emptied box keeps its place.
3907 tap(&mut view, right);
3908 view.move_selection(Movement::DocumentStart, false).unwrap();
3909 view.move_selection(Movement::DocumentEnd, true).unwrap();
3910 let _ = view.key(&Key::Named(NamedKey::Backspace), None).unwrap();
3911 assert!(view.editor.active_outline().shown_text().is_empty());
3912 assert_eq!(
3913 column(&view).iter().map(|(id, ..)| *id).collect::<Vec<_>>(),
3914 [title, left, right, below]
3915 );
3916}
3917
3918/// A picture of the corpus photo `size` points across, at `origin` on the page.
3919fn reader_picture(origin: [f32; 2], size: [f32; 2]) -> onestore::page::Image {
3920 let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png");
3921 onestore::page::Image {
3922 size: Some(size),
3923 id: onestore::page::text::new_id().unwrap(),
3924 layout: onestore::document::Layout {
3925 x: Some(origin[0]),
3926 y: Some(origin[1]),
3927 ..Default::default()
3928 },
3929 bytes: Some(Arc::from(&bytes[..])),
3930 display: None,
3931 alt: None,
3932 background: false,
3933 printout: None,
3934 tags: Vec::new(),
3935 link: None,
3936 text: None,
3937 }
3938}
3939
3940#[test]
3941fn reader_shrinks_pictures_to_the_column_in_reading_order_and_leaves_them_alone() {
3942 use onestore::page::{PageObject, ParagraphContent};
3943 let (mut page, [left, right, below]) = reader_columns_page();
3944 let wide = reader_picture([60.0, 330.0], [400.0, 160.0]);
3945 let small = reader_picture([520.0, 524.0], [20.0, 15.0]);
3946 let [wide_id, small_id] = [wide.id, small.id];
3947 page.objects
3948 .extend([PageObject::Image(small), PageObject::Image(wide)]);
3949 let PageObject::Outline(outline) = page
3950 .objects
3951 .iter_mut()
3952 .find(|object| object.id() == left)
3953 .unwrap()
3954 else {
3955 unreachable!()
3956 };
3957 let mut inline = outline.paragraphs[0].clone();
3958 inline.id = onestore::page::text::new_id().unwrap();
3959 let inline_picture = reader_picture([0.0; 2], [240.0, 48.0]);
3960 let inline_id = inline_picture.id;
3961 inline.content = ParagraphContent::Image(onestore::page::Image {
3962 layout: Default::default(),
3963 ..inline_picture
3964 });
3965 outline.paragraphs.insert(1, inline);
3966 let mut view = reader_view(page);
3967 let (scene, _) = view.scene.as_mut().unwrap();
3968 scene.settle(Some(&view.editor), 1.0, COLORS.paper);
3969 let original = view.editor.page().unwrap();
3970 let offset = view.scene.as_ref().unwrap().1;
3971 // A picture picked in the page view lets go in the column.
3972 click(
3973 &mut view,
3974 [60.0 + offset[0] + 10.0, 330.0 + offset[1] + 10.0],
3975 Instant::now(),
3976 );
3977 assert_eq!(view.object_focus, Some(ObjectFocus::Image(wide_id)));
3978 assert!(view.set_reading(Some(200.0)));
3979 assert!(view.object_focus.is_none());
3980 let column = view.column.as_ref().unwrap();
3981 let place = |id| {
3982 column
3983 .outlines
3984 .iter()
3985 .find(|(outline, ..)| *outline == id)
3986 .map(|(_, origin, shaped)| [origin[1], origin[1] + shaped.size[1]])
3987 .or_else(|| column.picture(id).map(|rect| [rect[1], rect[3]]))
3988 .unwrap()
3989 };
3990 let stacked = [left, right, wide_id, below, small_id].map(place);
3991 for pair in stacked.windows(2) {
3992 assert!(pair[1][0] >= pair[0][1] + 18.0);
3993 }
3994 let wide_rect = column.picture(wide_id).unwrap();
3995 assert_eq!(wide_rect, [36.0, wide_rect[1], 236.0, wide_rect[1] + 80.0]);
3996 let small_rect = column.picture(small_id).unwrap();
3997 assert_eq!(
3998 small_rect,
3999 [36.0, small_rect[1], 56.0, small_rect[1] + 15.0]
4000 );
4001 let (_, _, shaped) = column.outlines.iter().find(|(id, ..)| *id == left).unwrap();
4002 let object = shaped.objects.iter().find(|o| o.id == inline_id).unwrap();
4003 let [x0, y0, x1, y1] = object.rect;
4004 assert!(x1 <= 200.0 + 0.01);
4005 assert!(((x1 - x0) / (y1 - y0) - 5.0).abs() < 0.01);
4006 let primitives = view.primitives(COLORS).unwrap();
4007 for rect in [
4008 wide_rect,
4009 small_rect,
4010 crate::translated(object.rect, [36.0, place(left)[0]]),
4011 ] {
4012 let rect = crate::translated(rect, offset);
4013 assert!(
4014 primitives
4015 .iter()
4016 .any(|primitive| matches!(primitive, Primitive::Image { rect: drawn, .. } if *drawn == rect)),
4017 "{rect:?}"
4018 );
4019 }
4020 drop(primitives);
4021 let centre = [
4022 (wide_rect[0] + wide_rect[2]) / 2.0 + offset[0],
4023 (wide_rect[1] + wide_rect[3]) / 2.0 + offset[1],
4024 ];
4025 assert!(!matches!(view.hit(centre), Some(Hit::Image { .. })));
4026 click(&mut view, centre, Instant::now());
4027 assert!(view.object_focus.is_none());
4028 // Nor does a drag across one, or across its corner, move or resize it.
4029 for from in [
4030 centre,
4031 [
4032 wide_rect[2] + offset[0] - 1.0,
4033 wide_rect[3] + offset[1] - 1.0,
4034 ],
4035 ] {
4036 let _ = view.pointer_moved(from).unwrap();
4037 let _ = view.pointer_pressed(Instant::now()).unwrap();
4038 let _ = view
4039 .pointer_moved([from[0] + 40.0, from[1] + 30.0])
4040 .unwrap();
4041 let _ = view.pointer_released().unwrap();
4042 assert!(view.object_focus.is_none());
4043 }
4044 let bounds: Vec<_> = view
4045 .scene
4046 .as_ref()
4047 .unwrap()
4048 .0
4049 .content_bounds(&view.editor, view.column.as_ref())
4050 .map(|(rect, _)| rect)
4051 .collect();
4052 assert!(bounds.contains(&wide_rect) && bounds.contains(&small_rect));
4053 assert_eq!(view.editor.page().unwrap(), original);
4054 assert!(view.editor.take_ops().unwrap().is_empty());
4055 assert!(!view.set_reading(None) && view.column.is_none());
4056}
4057
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
4182#[test]
4183fn reader_consumers_share_title_header_date_and_body_geometry() {
4184 let mut view = reader_view(reader_page());
4185 let original = view.editor.page().unwrap();
4186 let mut access = accessibility::Accessibility::default();
4187 let mut tree = None;
4188 let mut dates = Vec::new();
4189 let mut body_tops = Vec::new();
4190 let mut hanging_whitespace = false;
4191 for width in [240.0, 160.0] {
4192 assert!(view.set_reading(Some(width)));
4193 let body = view
4194 .editor
4195 .visible_outlines()
4196 .find(|outline| !outline.title)
4197 .unwrap()
4198 .id;
4199 let column = view.column.as_ref().unwrap();
4200 for (_, _, shaped) in &column.outlines {
4201 for paragraph in &shaped.paragraphs {
4202 for (line, _) in paragraph.text.lines() {
4203 let metrics = line.metrics();
4204 assert!(
4205 paragraph.origin[0] + metrics.advance - metrics.trailing_whitespace
4206 <= width + 1.0
4207 );
4208 hanging_whitespace |= paragraph.origin[0] + metrics.advance > width + 1.0;
4209 }
4210 }
4211 }
4212 let (_, origin, shaped) = column
4213 .outlines
4214 .iter()
4215 .find(|(id, _, _)| *id == body)
4216 .unwrap();
4217 body_tops.push(origin[1]);
4218 let (scene, offset) = view.scene.as_ref().unwrap();
4219 assert!(
4220 scene
4221 .content_bounds(&view.editor, Some(column))
4222 .all(|(rect, _)| rect[3] < origin[1])
4223 );
4224 let primitives = view.primitives(COLORS).unwrap();
4225 assert!(primitives.iter().any(|primitive| matches!(primitive,
4226 Primitive::Text { text, origin: painted, .. }
4227 if painted_layout(*text).id() == shaped.paragraphs[0].text.id()
4228 && *painted == [origin[0] + offset[0] + shaped.paragraphs[0].origin[0], origin[1] + offset[1] + shaped.paragraphs[0].origin[1]])));
4229 let header = view
4230 .editor
4231 .objects
4232 .iter()
4233 .find_map(|object| match object {
4234 crate::editor::page::Content::Outline { layout, .. } => {
4235 Some(layout.paragraphs[0].text.id())
4236 }
4237 _ => None,
4238 })
4239 .unwrap();
4240 assert!(primitives.iter().any(|primitive| matches!(primitive,
4241 Primitive::Text { text, .. } if painted_layout(*text).id() == header)));
4242 drop(primitives);
4243 let nodes = accessibility::tests::nodes(accessibility::tests::apply(
4244 &mut tree,
4245 access.update(&view, view.viewport, "Reader").unwrap(),
4246 ));
4247 let field = nodes
4248 .iter()
4249 .find(|node| access.outline_for_node(node.locate().0) == Some(body))
4250 .unwrap();
4251 assert!(
4252 field
4253 .data()
4254 .supports_action(accesskit::Action::ReplaceSelectedText)
4255 );
4256 assert_eq!(
4257 field.bounding_box().unwrap().y0,
4258 f64::from(origin[1] + offset[1])
4259 );
4260 let header = nodes
4261 .iter()
4262 .find(|node| {
4263 node.role() == accesskit::Role::Label
4264 && node.data().value() == Some("Retained header")
4265 })
4266 .unwrap();
4267 assert!(header.bounding_box().unwrap().y1 < field.bounding_box().unwrap().y0);
4268 let buttons: Vec<_> = nodes
4269 .iter()
4270 .filter(|node| node.role() == accesskit::Role::Button)
4271 .collect();
4272 assert_eq!(buttons.len(), 2);
4273 for (index, node) in buttons.iter().enumerate() {
4274 let rect = node.bounding_box().unwrap();
4275 assert_eq!(
4276 view.hit([
4277 ((rect.x0 + rect.x1) / 2.0) as f32,
4278 ((rect.y0 + rect.y1) / 2.0) as f32
4279 ]),
4280 Some(Hit::Date([DateField::Date, DateField::Time][index]))
4281 );
4282 if dates.len() < 2 {
4283 dates.push(node.locate().0);
4284 } else {
4285 assert_eq!(node.locate().0, dates[index]);
4286 }
4287 }
4288 assert_eq!(view.editor.page().unwrap(), original);
4289 assert_eq!(view.editor.history_depth(), [0, 0]);
4290 assert!(view.editor.take_ops().unwrap().is_empty());
4291 }
4292 assert!(body_tops[1] > body_tops[0]);
4293 assert!(hanging_whitespace);
4294 let mut page = reader_page();
4295 let onestore::page::PageObject::Title(title) = &mut page.objects[0] else {
4296 unreachable!()
4297 };
4298 title.outlines[1].layout.width_set_by_user = Some(true);
4299 let mut wide_date = reader_view(page);
4300 assert_eq!(wide_date.editor.date().unwrap().layout().size[0], 468.0);
4301 assert!(!wide_date.reading_status(240.0).0);
4302 assert!(!wide_date.set_reading(Some(240.0)));
4303 assert!(wide_date.editor.take_ops().unwrap().is_empty());
4304}
4305
4306#[test]
4307fn reader_width_changes_preserve_original_ops_undo_and_source_coordinates() {
4308 let page = reader_page();
4309 let mut results = Vec::new();
4310 for width in [160.0, 240.0] {
4311 let mut view = reader_view(page.clone());
4312 let original = view.editor.page().unwrap();
4313 let body = view
4314 .editor
4315 .outlines()
4316 .iter()
4317 .find(|outline| !outline.title)
4318 .unwrap()
4319 .id;
4320 view.editor.focus_outline(body).unwrap();
4321 view.editor
4322 .move_selection(&mut view.engine, Movement::DocumentEnd, false)
4323 .unwrap();
4324 assert!(view.set_reading(Some(width)));
4325 assert!(view.commit_text(" accepted 😀".into()).unwrap().changed);
4326 let committed = view.editor.page().unwrap();
4327 let ops = view.editor.take_ops().unwrap();
4328 assert!(!ops.is_empty());
4329 let history = view.editor.history_depth();
4330 let selection = view.editor.selection();
4331 assert!(view.set_reading(Some(200.0)));
4332 assert_eq!(view.editor.selection(), selection);
4333 assert_eq!(view.editor.history_depth(), history);
4334 assert!(view.editor.take_ops().unwrap().is_empty());
4335 assert!(view.undo(false).unwrap().changed);
4336 assert_eq!(view.editor.page().unwrap(), original);
4337 assert!(view.undo(true).unwrap().changed);
4338 assert_eq!(view.editor.page().unwrap(), committed);
4339 assert_eq!(view.editor.selection(), selection);
4340 results.push((committed, ops, history));
4341 }
4342 assert_eq!(results[0], results[1]);
4343}
4344
4345#[test]
4346fn reader_page_navigation_prepares_a_marked_draft_before_its_first_geometry_query() {
4347 for up in [false, true] {
4348 let mut view = reader_view(reader_page());
4349 let body = view
4350 .editor
4351 .outlines()
4352 .iter()
4353 .find(|outline| !outline.title)
4354 .unwrap()
4355 .id;
4356 view.editor.focus_outline(body).unwrap();
4357 let bottom = view.editor.active_outline().shaped().size[1];
4358 assert!(
4359 view.editor
4360 .select_below(&mut view.engine, body, [10.0, bottom + 20.0])
4361 .unwrap()
4362 );
4363 let first_blank = view
4364 .editor
4365 .outlines()
4366 .iter()
4367 .find(|outline| outline.id == body)
4368 .unwrap()
4369 .document()
4370 .nodes()
4371 .len();
4372 view.editor
4373 .select(
4374 [TextPosition {
4375 paragraph: first_blank,
4376 offset: 0,
4377 }; 2]
4378 .into(),
4379 )
4380 .unwrap();
4381 assert!(view.set_reading(Some(160.0)));
4382 assert!(view.compose("IME 😀".into(), None).unwrap().changed);
4383 let before = view.editor.active_outline().document().nodes().len();
4384 let marked = view.editor.marked_range().unwrap();
4385 let preview = view.editor.page().unwrap();
4386 assert!(view.set_reading(Some(180.0)));
4387 assert_eq!(view.editor.marked_range(), Some(marked.clone()));
4388 assert_eq!(view.editor.page().unwrap(), preview);
4389 assert!(view.relayout().unwrap().moved);
4390 assert_eq!(view.editor.marked_range(), Some(marked.clone()));
4391 assert!(view.editor.take_ops().unwrap().is_empty());
4392 view.move_page(up, true).unwrap();
4393 assert!(view.editor.active_outline().document().nodes().len() < before);
4394 assert!(view.editor.marked_range().is_none());
4395 assert_eq!(view.editor.history_depth(), [1, 0]);
4396 assert!(!view.editor.take_ops().unwrap().is_empty());
4397 let source = view.editor.active_outline();
4398 let geometry = crate::reading::geometry(source, view.column.as_ref()).0;
4399 assert_eq!(
4400 geometry
4401 .layouts()
4402 .map(|(index, _)| source.document().leaf(index).unwrap().2.id)
4403 .collect::<Vec<_>>(),
4404 geometry
4405 .shaped
4406 .paragraphs
4407 .iter()
4408 .map(|paragraph| paragraph.id)
4409 .collect::<Vec<_>>()
4410 );
4411 view.caret_area().unwrap();
4412 view.primitives(COLORS).unwrap();
4413 let committed = view.editor.page().unwrap();
4414 view.move_page(up, false).unwrap();
4415 assert_eq!(view.editor.page().unwrap(), committed);
4416 assert_eq!(view.editor.history_depth(), [1, 0]);
4417 assert!(view.editor.take_ops().unwrap().is_empty());
4418 }
4419}
4420
4421#[test]
4422fn reader_projection_failure_after_an_accepted_edit_keeps_ops_and_full_canvas() {
4423 let mut view = reader_view(reader_page());
4424 let body = view
4425 .editor
4426 .outlines()
4427 .iter()
4428 .find(|outline| !outline.title)
4429 .unwrap()
4430 .id;
4431 view.editor.focus_outline(body).unwrap();
4432 assert!(view.set_reading(Some(200.0)));
4433 view.column.as_mut().unwrap().width = f32::NAN;
4434 assert!(
4435 view.commit_text(" accepted before failed shaping".into())
4436 .unwrap()
4437 .changed
4438 );
4439 assert!(view.column.is_none());
4440 assert!(
4441 view.editor
4442 .active_outline()
4443 .shown_text()
4444 .contains("accepted before failed shaping")
4445 );
4446 assert_eq!(view.editor.history_depth(), [1, 0]);
4447 assert!(!view.editor.take_ops().unwrap().is_empty());
4448 view.primitives(COLORS).unwrap();
4449 assert!(view.set_reading(Some(200.0)));
4450 assert!(view.insert_table(2, 2).unwrap().changed);
4451 assert!(view.column.is_none());
4452 assert!(!view.editor.take_ops().unwrap().is_empty());
4453 view.primitives(COLORS).unwrap();
4454}
4455
4456#[test]
4457fn reader_native_queries_and_accessibility_selection_use_source_utf16() {
4458 let mut view = reader_view(reader_page());
4459 let body = view
4460 .editor
4461 .outlines()
4462 .iter()
4463 .find(|outline| !outline.title)
4464 .unwrap()
4465 .id;
4466 view.editor.focus_outline(body).unwrap();
4467 assert!(view.set_reading(Some(160.0)));
4468 let mut access = accessibility::Accessibility::default();
4469 let mut tree = None;
4470 for offset in [0, 8, 25, 50] {
4471 let at = view.editor.active_outline().utf16_position(offset).unwrap();
4472 view.editor.select([at; 2].into()).unwrap();
4473 assert!(view.focus_text().unwrap().changed);
4474 let caret = view
4475 .caret_at(body, at, parley::Affinity::Downstream, 0.0)
4476 .unwrap();
4477 let hit = view
4478 .selection_at(
4479 body,
4480 [caret.x0 as f32, (caret.y0 + caret.y1) as f32 / 2.0],
4481 SelectionUnit::Grapheme,
4482 )
4483 .unwrap();
4484 assert_eq!(hit.positions, [at; 2]);
4485 let nodes = accessibility::tests::nodes(accessibility::tests::apply(
4486 &mut tree,
4487 access.update(&view, view.viewport, "Reader").unwrap(),
4488 ));
4489 let field = nodes
4490 .iter()
4491 .find(|node| access.outline_for_node(node.locate().0) == Some(body))
4492 .unwrap();
4493 assert_eq!(
4494 access
4495 .selection(body, *field.data().text_selection().unwrap())
4496 .unwrap(),
4497 view.editor.selection()
4498 );
4499 let rect = view.caret_area().unwrap();
4500 assert_eq!(rect[0], caret.x0 as f32);
4501 assert_eq!(rect[1], caret.y0 as f32);
4502 }
4503 for movement in [
4504 Movement::DocumentStart,
4505 Movement::Left,
4506 Movement::Up,
4507 Movement::DocumentEnd,
4508 Movement::Right,
4509 Movement::Down,
4510 ] {
4511 view.move_selection(movement, false).unwrap();
4512 }
4513 assert_eq!(view.editor.outlines().len(), 2);
4514 assert!(view.editor.caret_outline().is_none());
4515 assert!(view.editor.take_ops().unwrap().is_empty());
4516}
4517
4518#[test]
4519fn reader_preserves_nested_visibility_rich_text_and_link_glyph_hits() {
4520 let mut page = reader_page();
4521 let onestore::page::PageObject::Outline(body) = &mut page.objects[1] else {
4522 unreachable!()
4523 };
4524 body.paragraphs[0].collapsed = true;
4525 body.paragraphs[1].parent = Some(body.paragraphs[0].id);
4526 body.paragraphs[1].level = 2;
4527 body.paragraphs[1].text_mut().unwrap().text = Paragraph::new(
4528 "Hidden 😀 child".into(),
4529 Format {
4530 bold: Some(true),
4531 font_size: Some(18.0),
4532 ..Default::default()
4533 },
4534 );
4535 body.paragraphs[2].text_mut().unwrap().text = Paragraph::new(
4536 "Last rich paragraph".into(),
4537 Format {
4538 bold: Some(true),
4539 font_size: Some(18.0),
4540 ..Default::default()
4541 },
4542 );
4543 let mut view = reader_view(page);
4544 let body = view
4545 .editor
4546 .outlines()
4547 .iter()
4548 .find(|outline| !outline.title)
4549 .unwrap()
4550 .id;
4551 view.editor.focus_outline(body).unwrap();
4552 assert!(view.set_reading(Some(160.0)));
4553 let source = view.editor.active_outline();
4554 let geometry = crate::reading::geometry(source, view.column.as_ref()).0;
4555 assert_eq!(
4556 geometry
4557 .layouts()
4558 .map(|(index, _)| index)
4559 .collect::<Vec<_>>(),
4560 [0, 2]
4561 );
4562 let mut access = accessibility::Accessibility::default();
4563 let mut tree = None;
4564 let update = access.update(&view, view.viewport, "Reader").unwrap();
4565 let nodes = accessibility::tests::nodes(accessibility::tests::apply(&mut tree, update));
4566 let field = nodes
4567 .iter()
4568 .find(|node| access.outline_for_node(node.locate().0) == Some(body))
4569 .unwrap();
4570 assert!(!field.data().value().unwrap().contains("Hidden"));
4571 let text = view
4572 .editor
4573 .active_outline()
4574 .document()
4575 .paragraph(0)
4576 .unwrap();
4577 let end = text.utf16_offset(text.text().len()).unwrap();
4578 view.editor
4579 .select(
4580 [
4581 TextPosition {
4582 paragraph: 0,
4583 offset: 0,
4584 },
4585 TextPosition {
4586 paragraph: 0,
4587 offset: end,
4588 },
4589 ]
4590 .into(),
4591 )
4592 .unwrap();
4593 assert!(view.link_prefill().is_some());
4594 assert!(
4595 view.set_link(
4596 "A hyperlink label with several wrapped words",
4597 "https://example.test/reader",
4598 )
4599 .unwrap()
4600 .changed
4601 );
4602 assert_eq!(
4603 view.editor
4604 .link_at(view.editor.selection().positions[1])
4605 .unwrap()
4606 .target,
4607 "https://example.test/reader"
4608 );
4609 assert!(!view.editor.take_ops().unwrap().is_empty());
4610 let source = view.editor.active_outline();
4611 let (geometry, _) = crate::reading::geometry(source, view.column.as_ref());
4612 let paragraph = &geometry.shaped.paragraphs[0];
4613 assert!(paragraph.text.lines().count() > 1);
4614 for (line, bounds) in paragraph.text.lines() {
4615 assert_eq!(
4616 geometry
4617 .link_under([
4618 paragraph.origin[0] + line.metrics().advance / 2.0,
4619 paragraph.origin[1] + bounds.top + bounds.height / 2.0
4620 ])
4621 .as_deref(),
4622 Some("https://example.test/reader")
4623 );
4624 assert!(
4625 geometry
4626 .link_under([
4627 paragraph.origin[0] + line.metrics().advance + 8.0,
4628 paragraph.origin[1] + bounds.top + bounds.height / 2.0
4629 ])
4630 .is_none()
4631 );
4632 }
4633}
4634
4635#[test]
4636fn reader_empty_title_only_and_remote_unsupported_content_follow_current_source() {
4637 let mut page = reader_page();
4638 page.objects.truncate(1);
4639 let mut title_only = reader_view(page.clone());
4640 assert!(title_only.reading_status(160.0).0);
4641 assert!(title_only.set_reading(Some(160.0)));
4642 assert!(title_only.editor.take_ops().unwrap().is_empty());
4643 page.objects.clear();
4644 page.title.clear();
4645 page.created = None;
4646 let mut empty = reader_view(page);
4647 assert!(empty.reading_status(160.0).0);
4648 assert!(empty.set_reading(Some(160.0)));
4649 let outline = empty.editor.active_outline().id;
4650 let caret = empty
4651 .caret_at(
4652 outline,
4653 empty.editor.selection().positions[1],
4654 parley::Affinity::Downstream,
4655 1.0,
4656 )
4657 .unwrap();
4658 assert!(
4659 matches!(empty.hit([caret.x0 as f32 + 0.5, (caret.y0 + caret.y1) as f32 / 2.0]), Some(Hit::Text { id, .. }) if id == outline)
4660 );
4661 assert!(empty.editor.take_ops().unwrap().is_empty());
4662 let mut view = reader_view(reader_page());
4663 assert!(view.set_reading(Some(160.0)));
4664 assert!(
4665 view.commit_text(" preserved history".into())
4666 .unwrap()
4667 .changed
4668 );
4669 view.editor.take_ops().unwrap();
4670 let title = view.editor.active_outline().id;
4671 let history = view.editor.history_depth();
4672 let mut remote = view.editor.page().unwrap();
4673 let mut engine = TextEngine::default();
4674 remote.objects.push(onestore::page::PageObject::Outline(
4675 TextOutline::new(
4676 &mut engine,
4677 TextDocument::new(vec![Paragraph::new(
4678 "Remote annotation".into(),
4679 Format {
4680 math: Some(true),
4681 ..Default::default()
4682 },
4683 )])
4684 .unwrap(),
4685 120.0,
4686 [600.0, 20.0],
4687 )
4688 .unwrap()
4689 .snapshot(),
4690 ));
4691 assert!(view.refresh(remote).unwrap().moved);
4692 assert!(view.column.is_none());
4693 assert_eq!(view.editor.history_depth(), history);
4694 assert_eq!(view.editor.active_outline().id, title);
4695 assert!(
4696 view.editor
4697 .active_outline()
4698 .shown_text()
4699 .contains("preserved history")
4700 );
4701 view.primitives(COLORS).unwrap();
4702 assert!(!view.reading_status(160.0).0);
4703 assert!(view.editor.take_ops().unwrap().is_empty());
4704 let mut rtl = reader_page();
4705 rtl.rtl = true;
4706 assert!(!reader_view(rtl).reading_status(160.0).0);
4707}