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(&editor, Some(&scene), viewport, "Header", 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 = page_primitives(&editor, None, None, None, paint(false, 1.0, 1.0)).unwrap();
181 let borders = primitives
182 .iter()
183 .filter_map(|p| match p {
184 Primitive::RoundedRect { stroke, .. } => Some(*stroke),
185 _ => None,
186 })
187 .collect::<Vec<_>>();
188 assert_eq!(borders, [Some(Stroke::Dashed(1.0))]);
189 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])));
190 let mut access = accessibility::Accessibility::default();
191 let update = access
192 .update(
193 &editor,
194 None,
195 Viewport {
196 size: [800, 600],
197 scale: 1.0,
198 origin: [0.0; 2],
199 },
200 "Header",
201 None,
202 None,
203 )
204 .unwrap();
205 assert!(update.nodes.iter().any(|(_, node)| node.role()
206 == accesskit::Role::MultilineTextInput
207 && node.label() == Some("Page title")));
208}
209
210#[test]
211fn title_frame_shows_unfocused_and_its_pen_scales_only_when_zoomed_in() {
212 let mut engine = TextEngine::default();
213 let mut outline = |text: &str, origin, title| {
214 let mut source = TextOutline::new(
215 &mut engine,
216 TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]).unwrap(),
217 468.0,
218 origin,
219 )
220 .unwrap()
221 .snapshot();
222 source.title = title;
223 TextOutline::from_outline(&mut engine, &source, &Default::default()).unwrap()
224 };
225 let title = outline("Title", [36.0, 14.4], true);
226 let body = outline("Body", [36.0, 120.0], false);
227 let body_id = body.id;
228 let mut editor =
229 CanvasEditor::from_text_outlines(vec![title, body], Default::default(), None).unwrap();
230 editor.focus_outline(body_id).unwrap();
231 // OneNote 2010: a 4 px dash period at 100% and below, 4 px times the zoom above it.
232 for (zoom, pen) in [(0.5, 1.5), (1.0, 0.75), (2.0, 0.75), (3.0, 0.75)] {
233 let scale = zoom * 96.0 / 72.0;
234 let primitives =
235 page_primitives(&editor, None, None, None, paint(false, scale, 1.0)).unwrap();
236 let dashed = primitives
237 .iter()
238 .filter_map(|primitive| match primitive {
239 Primitive::RoundedRect {
240 stroke: Some(Stroke::Dashed(width)),
241 ..
242 } => Some(*width),
243 _ => None,
244 })
245 .collect::<Vec<_>>();
246 assert!(
247 matches!(dashed[..], [width] if (width - pen).abs() < 1e-5),
248 "zoom {zoom}: {dashed:?}"
249 );
250 }
251}
252
253#[test]
254fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() {
255 let mut engine = TextEngine::default();
256 let mut editor = CanvasEditor::new(
257 &mut engine,
258 TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(),
259 240.0,
260 )
261 .unwrap();
262 let id = editor.active_outline().id;
263 for _ in 0..2 {
264 editor
265 .move_selection(&mut engine, Movement::Down, false)
266 .unwrap();
267 }
268 let primitives = page_primitives(&editor, None, None, None, paint(true, 1.0, 1.0)).unwrap();
269 assert_eq!(
270 primitives
271 .iter()
272 .filter(|p| matches!(p, Primitive::Text { .. }))
273 .count(),
274 3
275 );
276 let caret = editor.caret(1.0).unwrap();
277 assert!(
278 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)
279 );
280 let mut access = accessibility::Accessibility::default();
281 let update = access
282 .update(
283 &editor,
284 None,
285 Viewport {
286 size: [800, 600],
287 scale: 1.0,
288 origin: [0.0; 2],
289 },
290 "Test",
291 None,
292 None,
293 )
294 .unwrap();
295 let fields = update
296 .nodes
297 .iter()
298 .filter(|(_, node)| node.role() == accesskit::Role::MultilineTextInput)
299 .collect::<Vec<_>>();
300 assert_eq!(fields.len(), 1);
301 assert_eq!(fields[0].1.value(), Some("body\n\n"));
302 assert_eq!(editor.outlines()[0].document().nodes().len(), 1);
303 editor
304 .place_caret(&mut engine, [300.0, 200.0], 240.0)
305 .unwrap();
306 assert_eq!(
307 editor
308 .visible_outlines()
309 .next()
310 .unwrap()
311 .document()
312 .nodes()
313 .len(),
314 1
315 );
316}
317
318#[test]
319fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() {
320 let mut engine = TextEngine::default();
321 let mut editor = CanvasEditor::new(
322 &mut engine,
323 TextDocument::new(vec![Paragraph::new(String::new(), Default::default())]).unwrap(),
324 240.0,
325 )
326 .unwrap();
327 editor
328 .place_caret(&mut engine, [120.0, 100.0], 240.0)
329 .unwrap();
330 let mut access = accessibility::Accessibility::default();
331 let viewport = Viewport {
332 size: [1000, 720],
333 scale: 96.0 / 72.0,
334 origin: [48.0; 2],
335 };
336 let initial = access
337 .update(&editor, None, viewport, "Page", None, None)
338 .unwrap();
339 let id = editor.active_outline().id;
340 assert_eq!(access.outline_for_node(initial.focus), Some(id));
341 let rectangles = |editor: &CanvasEditor| {
342 page_primitives(editor, None, None, None, paint(true, viewport.scale, 1.0))
343 .unwrap()
344 .into_iter()
345 .filter_map(|p| match p {
346 Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => {
347 Some((rect, color))
348 }
349 _ => None,
350 })
351 .collect::<Vec<_>>()
352 };
353 assert_eq!(rectangles(&editor).len(), 1);
354 assert_eq!(
355 page_hit_test(&editor, None, [120.0, 100.0], 1.0 / viewport.scale),
356 None
357 );
358 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
359 assert!(editor.outlines().is_empty());
360 assert!(
361 rectangles(&editor)
362 .iter()
363 .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0] || *color == COLORS.caret)
364 );
365 let preedit = access
366 .update(&editor, None, viewport, "Page", None, None)
367 .unwrap();
368 assert_eq!(preedit.focus, initial.focus);
369 editor.commit_text(&mut engine, "日本".into()).unwrap();
370 let committed = access
371 .update(&editor, None, viewport, "Page", None, None)
372 .unwrap();
373 assert_eq!(committed.focus, initial.focus);
374 assert!(rectangles(&editor).len() > 1);
375 editor.undo(&mut engine).unwrap();
376 assert!(editor.outlines().is_empty());
377 assert_eq!(rectangles(&editor).len(), 1);
378 let empty = access
379 .update(&editor, None, viewport, "Page", None, None)
380 .unwrap();
381 assert_eq!(empty.focus, committed.focus);
382 assert_eq!(access.outline_for_node(committed.focus), Some(id));
383 editor.redo(&mut engine).unwrap();
384 editor.select_all().unwrap();
385 editor.delete(&mut engine, true).unwrap();
386 assert!(editor.outlines().is_empty());
387 assert_eq!(rectangles(&editor).len(), 1);
388 let retired = access
389 .update(&editor, None, viewport, "Page", None, None)
390 .unwrap();
391 assert_eq!(retired.focus, committed.focus);
392 assert_eq!(access.outline_for_node(retired.focus), Some(id));
393 editor.undo(&mut engine).unwrap();
394 assert!(rectangles(&editor).len() > 1);
395 let restored = access
396 .update(&editor, None, viewport, "Page", None, None)
397 .unwrap();
398 assert_eq!(restored.focus, committed.focus);
399 editor.redo(&mut engine).unwrap();
400 assert_eq!(rectangles(&editor).len(), 1);
401 assert!(editor.caret_outline().unwrap().is_empty());
402 editor.insert(&mut engine, " ").unwrap();
403 assert!(editor.caret_outline().unwrap().is_empty());
404 editor.compose(&mut engine, "に".into(), 1..1).unwrap();
405 assert!(!editor.caret_outline().unwrap().is_empty());
406}
407
408#[test]
409#[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"]
410fn imported_page_widgets_and_accessibility_follow_edits() {
411 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
412 let page = {
413 let store = onestore::Store::parse(&bytes).unwrap();
414 let index = onestore::RevisionIndex::parse(&store).unwrap();
415 Page::from_document(
416 &onestore::document::Document::parse(&index).unwrap(),
417 &std::env::var("CANVAS_TEST_PAGE").unwrap(),
418 )
419 .unwrap()
420 };
421 drop(bytes);
422 let mut engine = TextEngine::default();
423 if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") {
424 engine
425 .register_substitute(parley::fontique::Blob::new(Arc::new(
426 std::fs::read(font).unwrap(),
427 )))
428 .unwrap();
429 }
430 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
431 let scene = (scene, [0.0; 2]);
432 let viewport = Viewport {
433 size: [1000, 720],
434 scale: 96.0 / 72.0,
435 origin: [48.0; 2],
436 };
437 let mut tag_hits = 0;
438 for outline in editor.outlines() {
439 for (_, paragraph) in outline.layouts() {
440 for tag in &paragraph.tags {
441 let point = [
442 outline.origin()[0] + tag.origin[0] + crate::outline::ParagraphTag::SIZE / 2.0,
443 outline.origin()[1]
444 + paragraph.origin[1]
445 + tag.origin[1]
446 + crate::outline::ParagraphTag::SIZE / 2.0,
447 ];
448 assert!(
449 matches!(page_hit_test(&editor, Some(&scene), point, 1.0), Some(Hit::Text { id, .. }) if id == outline.id),
450 "Tag center {point:?} did not target outline {:?}",
451 outline.id
452 );
453 tag_hits += 1;
454 }
455 }
456 }
457 eprintln!("Verified {tag_hits} imported tag-gutter targets");
458 let mut access = accessibility::Accessibility::default();
459 let mut tree = None;
460 let tags = |editor: &CanvasEditor| {
461 page_primitives(
462 editor,
463 Some(&scene),
464 None,
465 None,
466 paint(true, viewport.scale, 1.0),
467 )
468 .unwrap()
469 .into_iter()
470 .filter_map(|primitive| match primitive {
471 Primitive::Icon {
472 sources,
473 origin,
474 tint,
475 ..
476 } => Some(((sources, tint[3]), origin)),
477 _ => None,
478 })
479 .collect::<Vec<_>>()
480 };
481 let original_tags = tags(&editor);
482 let ids: Vec<_> = editor.outlines().iter().map(|outline| outline.id).collect();
483 for id in ids {
484 editor.focus_outline(id).unwrap();
485 let original = editor.active_outline().document().clone();
486 editor
487 .select(
488 [crate::document::TextPosition {
489 paragraph: 0,
490 offset: 0,
491 }; 2]
492 .into(),
493 )
494 .unwrap();
495 let before = access
496 .update(&editor, None, viewport, "Test", None, None)
497 .unwrap();
498 let before = accessibility::tests::apply(&mut tree, before)
499 .focus()
500 .unwrap()
501 .document_range()
502 .text();
503 editor.insert(&mut engine, "CANVAS CHECK ").unwrap();
504 {
505 let update = access
506 .update(&editor, None, viewport, "Test", None, None)
507 .unwrap();
508 assert_eq!(
509 accessibility::tests::apply(&mut tree, update)
510 .focus()
511 .unwrap()
512 .document_range()
513 .text(),
514 format!("CANVAS CHECK {before}")
515 );
516 let primitives = page_primitives(
517 &editor,
518 Some(&scene),
519 None,
520 None,
521 paint(true, viewport.scale, 1.0),
522 )
523 .unwrap();
524 assert!(!primitives.is_empty());
525 let edited_tags = tags(&editor);
526 assert_eq!(edited_tags.len(), original_tags.len());
527 for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) {
528 assert_eq!(edited, original);
529 }
530 }
531 editor.undo(&mut engine).unwrap();
532 assert_eq!(editor.active_outline().document(), &original);
533 assert_eq!(tags(&editor), original_tags);
534 let update = access
535 .update(&editor, None, viewport, "Test", None, None)
536 .unwrap();
537 assert_eq!(
538 accessibility::tests::apply(&mut tree, update)
539 .focus()
540 .unwrap()
541 .document_range()
542 .text(),
543 before
544 );
545 }
546 eprintln!(
547 "Rendered and restored {} imported outline widgets and accessibility fields",
548 editor.outlines().len()
549 );
550}
551
552#[test]
553fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {
554 let mut engine = TextEngine::default();
555 let upper = TextOutline::new(
556 &mut engine,
557 TextDocument::new(vec![Paragraph::new("Upper".into(), Default::default())]).unwrap(),
558 240.0,
559 [36.0, 90.0],
560 )
561 .unwrap();
562 let bottom = upper.bounds().y1 as f32;
563 let id = upper.id;
564 let lower = TextOutline::new(
565 &mut engine,
566 TextDocument::new(vec![Paragraph::new("Lower".into(), Default::default())]).unwrap(),
567 240.0,
568 [36.0, bottom + 20.0],
569 )
570 .unwrap();
571 let lower_id = lower.id;
572 let source = upper.snapshot();
573 let mut editor =
574 CanvasEditor::from_text_outlines(vec![lower, upper], Default::default(), None).unwrap();
575 assert!(
576 matches!(page_hit_test(&editor, None, [60.0, bottom + 22.0], 0.75), Some(Hit::Text { id, .. }) if id == lower_id)
577 );
578 editor.move_outline(lower_id, [600.0, 500.0]).unwrap();
579 for pixel in [0.375, 0.75, 1.5] {
580 let hit = page_hit_test(&editor, None, [60.0, bottom + 26.0], pixel).unwrap();
581 assert!(
582 matches!(hit, Hit::Text { id: hit_id, point } if hit_id == id && (point[1] - (bottom + 26.0 - 90.0)).abs() < 0.001)
583 );
584 assert_eq!(
585 page_hit_test(&editor, None, [60.0, bottom + 30.0], pixel),
586 None
587 );
588 }
589 let page = onestore::page::Page {
590 identity: None,
591 title: String::new(),
592 created: None,
593 margin_origin: [0.0; 2],
594 rtl: false,
595 color: None,
596 rule_lines: None,
597 definitions: Default::default(),
598 objects: vec![onestore::page::PageObject::Outline(source)],
599 };
600 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
601 let offset = [50.0, 300.0];
602 assert!(
603 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)
604 );
605}
606
607#[test]
608fn chrome_follows_focus_hover_and_drag_without_changing_hit_geometry() {
609 let mut engine = TextEngine::default();
610 let outlines = [[0.0, 0.0], [220.0, 0.0]]
611 .into_iter()
612 .map(|origin| {
613 TextOutline::new(
614 &mut engine,
615 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
616 100.0,
617 origin,
618 )
619 .unwrap()
620 })
621 .collect();
622 let editor = CanvasEditor::from_text_outlines(outlines, Default::default(), None).unwrap();
623 let second = editor.outlines()[1].id;
624 let borders = |feedback| {
625 page_primitives(&editor, None, feedback, None, paint(false, 1.0, 1.0))
626 .unwrap()
627 .into_iter()
628 .filter_map(|p| match p {
629 Primitive::RoundedRect {
630 rect,
631 stroke: Some(Stroke::Solid(1.0)),
632 ..
633 } => Some(rect),
634 _ => None,
635 })
636 .collect::<Vec<_>>()
637 };
638 assert_eq!(borders(None).len(), 1);
639 let hovered = borders(Some(PointerFeedback::Hover(second)));
640 assert_eq!(hovered.len(), 2);
641 let moved = borders(Some(PointerFeedback::Move(second, [20.0, 50.0])));
642 assert_eq!(moved[1][0] - hovered[1][0], 20.0);
643 assert_eq!(moved[1][1] - hovered[1][1], 50.0);
644 assert!(
645 matches!(page_hit_test(&editor, None, [225.0, 5.0], 1.0), Some(Hit::Text { id, .. }) if id == second)
646 );
647}
648
649#[test]
650fn a_covered_width_handle_stays_reachable_beside_the_outline_above_it() {
651 let mut engine = TextEngine::default();
652 let pixel = 0.75;
653 let mut first = TextOutline::new(
654 &mut engine,
655 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
656 100.0,
657 [36.0, 68.4],
658 )
659 .unwrap();
660 let editor =
661 CanvasEditor::from_text_outlines(vec![first.clone()], Default::default(), None).unwrap();
662 first = editor.preview_resize(&mut engine, 226.5).unwrap();
663 let (frame, body_top) = outline_chrome(&first, pixel);
664 // The casual night page: the next outline starts 7.5 pt right of the first one.
665 let second = TextOutline::new(
666 &mut engine,
667 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
668 100.0,
669 [first.bounds().x1 as f32 + 7.5, 68.4],
670 )
671 .unwrap();
672 let (first_id, second_id) = (first.id, second.id);
673 let editor =
674 CanvasEditor::from_text_outlines(vec![first, second], Default::default(), None).unwrap();
675 let header = (frame[1] + body_top) / 2.0;
676 let hit = |x| page_hit_test(&editor, None, [x, header], pixel);
677 assert!(matches!(hit(frame[2] - 12.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id));
678 assert!(matches!(hit(frame[2] - 2.0 * pixel), Some(Hit::Handle { id, .. }) if id == second_id));
679 let body = |x| page_hit_test(&editor, None, [x, body_top + 20.0], pixel);
680 assert!(matches!(body(frame[2] - 4.0 * pixel), Some(Hit::Resize { id, .. }) if id == first_id));
681 assert!(matches!(body(frame[2] - 8.0 * pixel), Some(Hit::Text { id, .. }) if id == second_id));
682 assert!(matches!(body(frame[2] - 12.0 * pixel), Some(Hit::Text { id, .. }) if id == first_id));
683}
684
685#[test]
686fn a_picture_in_an_outline_takes_the_click_over_its_text() {
687 use onestore::page::{Image, Page, PageObject, ParagraphContent};
688 let mut engine = TextEngine::default();
689 let mut source = TextOutline::new(
690 &mut engine,
691 TextDocument::new(vec![
692 Paragraph::new("Before".into(), Default::default()),
693 Paragraph::new("After".into(), Default::default()),
694 ])
695 .unwrap(),
696 240.0,
697 [36.0, 36.0],
698 )
699 .unwrap()
700 .snapshot();
701 let mut picture = source.paragraphs[0].clone();
702 picture.id = onestore::page::text::new_id().unwrap();
703 let id = onestore::page::text::new_id().unwrap();
704 picture.content = ParagraphContent::Image(Image {
705 size: Some([40.0, 30.0]),
706 id,
707 layout: Default::default(),
708 bytes: None,
709 display: None,
710 alt: None,
711 background: false,
712 printout: None,
713 tags: Vec::new(),
714 link: None,
715 text: None,
716 });
717 source.paragraphs.insert(1, picture);
718 let editor = CanvasEditor::from_page(
719 Page {
720 title: String::new(),
721 identity: None,
722 created: None,
723 margin_origin: [36.0, 14.4],
724 rtl: false,
725 color: None,
726 rule_lines: None,
727 definitions: Default::default(),
728 objects: vec![PageObject::Outline(source)],
729 },
730 &mut engine,
731 )
732 .unwrap();
733 let (origin, size) = editor.image_placement(id).unwrap();
734 let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0];
735 assert_eq!(
736 page_hit_test(&editor, None, center, 1.0),
737 Some(Hit::Image { id, handle: [0, 0] })
738 );
739 assert!(matches!(
740 page_hit_test(&editor, None, [origin[0] + 2.0, origin[1] - 4.0], 1.0),
741 Some(Hit::Text { .. })
742 ));
743}
744
745#[test]
746fn picture_handles_resize_as_onenote_does() {
747 // Native drags of the 333 x 200.1 pt mockup on "av: casual night in the trees".
748 let (origin, size) = ([468.0, 86.4], [333.0, 200.1]);
749 let (corner_origin, corner) = resize_image(origin, size, [1, -1], [-75.0, 30.75]);
750 assert!((corner[0] - 281.827).abs() < 0.01 && (corner[1] - 169.351).abs() < 0.01);
751 assert!((corner_origin[1] - 117.15).abs() < 0.01 && corner_origin[0] == 468.0);
752 assert_eq!(
753 resize_image(origin, size, [1, 0], [46.5, 10.0]),
754 (origin, [379.5, 200.1])
755 );
756 let (left_origin, left) = resize_image(origin, size, [-1, 0], [400.0, 0.0]);
757 assert_eq!(left, [1.0, 200.1]);
758 assert_eq!(left_origin[0], 468.0 + 333.0 - 1.0);
759 let pixel = 0.75;
760 let rect = image_rect(origin, size);
761 assert_eq!(
762 image_handle_at(rect, pixel, [468.0 - 3.75, 86.4 - 3.75], 0.0),
763 Some([-1, -1])
764 );
765 assert_eq!(
766 image_handle_at(rect, pixel, [468.0 + 166.5, 286.5 + 3.75], 0.0),
767 Some([0, 1])
768 );
769 assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0], 0.0), None);
770}
771
772const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4];
773
774#[test]
775fn native_grid_follows_the_page_margin_origin() {
776 // "av: casual night in the trees": margin 36.75; a 22.5 x 15 pt drag lands one cell on.
777 let margin = [36.75, 14.4];
778 let result = snap_to_grid([468.75 + 22.5, 86.4 + 15.0], margin);
779 assert!((result[0] - 486.75).abs() < 0.0001);
780 assert!((result[1] - 104.4).abs() < 0.0001);
781}
782
783#[test]
784fn native_grid_matches_drag_offsets_midpoints_and_zoom() {
785 for (pixels, points) in [
786 (5.0, 0.0),
787 (6.0, 0.0),
788 (7.0, 0.0),
789 (8.0, 0.0),
790 (11.0, 0.0),
791 (12.0, 9.0),
792 (13.0, 18.0),
793 (17.0, 18.0),
794 (18.0, 18.0),
795 (19.0, 18.0),
796 (25.0, 18.0),
797 (37.0, 36.0),
798 (-6.0, 0.0),
799 (-7.0, 0.0),
800 (-12.0, -9.0),
801 (-13.0, -18.0),
802 (-19.0, -18.0),
803 ] {
804 let origin = [486.0, 230.40001];
805 let proposed = origin.map(|v| v + pixels * 72.0 / 96.0);
806 let result = snap_to_grid(proposed, DEFAULT_MARGIN);
807 for axis in 0..2 {
808 assert!((result[axis] - origin[axis] - points).abs() < 0.00004);
809 }
810 }
811 for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] {
812 let result = snap_to_grid(
813 [491.25 + pixels * 0.75, 235.65 + pixels * 0.75],
814 DEFAULT_MARGIN,
815 );
816 assert!((result[0] - 504.0).abs() < 0.00004);
817 assert!((result[1] - 248.4).abs() < 0.00004);
818 }
819 for (scale, delta, expected) in [
820 (96.0 / 72.0, [27.0, 31.0], [54.0, 104.4]),
821 (96.0 / 72.0, [13.0, 17.0], [54.0, 104.4]),
822 (192.0 / 72.0, [13.0, 17.0], [36.0, 104.4]),
823 ] {
824 for dpr in [1.0, 2.0] {
825 let origin = [36.0, 90.0];
826 let proposed =
827 std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr));
828 let result = snap_to_grid(proposed, DEFAULT_MARGIN);
829 for axis in 0..2 {
830 assert!((result[axis] - expected[axis]).abs() < 0.00004);
831 }
832 }
833 }
834 for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] {
835 assert_eq!(snap_to_grid(point, DEFAULT_MARGIN), point);
836 }
837}
838
839#[test]
840fn native_fresh_click_grid_uses_screen_offset_at_both_zoom_levels() {
841 for (x, y, scale, expected) in [
842 (900.0, 500.0, 96.0 / 72.0, [639.0, 302.4]),
843 (901.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
844 (906.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
845 (913.0, 500.0, 96.0 / 72.0, [648.0, 302.4]),
846 (900.0, 501.0, 96.0 / 72.0, [639.0, 302.4]),
847 (900.0, 506.0, 96.0 / 72.0, [639.0, 320.4]),
848 (900.0, 520.0, 96.0 / 72.0, [639.0, 320.4]),
849 (900.0, 500.0, 192.0 / 72.0, [324.0, 158.4]),
850 ] {
851 for dpr in [1.0, 2.0] {
852 let viewport = Viewport {
853 size: [(1600.0 * dpr) as u32, (900.0 * dpr) as u32],
854 origin: [48.0 * dpr, 83.0 * dpr],
855 scale: scale * dpr,
856 };
857 let point = viewport.document_point([x * dpr, y * dpr]);
858 let result = snap_to_grid(
859 [point[0], point[1] - 7.0 * dpr / viewport.scale],
860 DEFAULT_MARGIN,
861 );
862 for axis in 0..2 {
863 assert!((result[axis] - expected[axis]).abs() < 0.00004);
864 }
865 }
866 }
867}
868
869#[test]
870fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
871 let mut engine = TextEngine::default();
872 let mut editor = CanvasEditor::new(
873 &mut engine,
874 TextDocument::new(vec![Paragraph::new(
875 "M".into(),
876 Format {
877 font_size: Some(130.0),
878 highlight: Some(0xffff),
879 ..Default::default()
880 },
881 )])
882 .unwrap(),
883 180.0,
884 )
885 .unwrap();
886 editor
887 .move_selection(&mut engine, Movement::LineEnd, false)
888 .unwrap();
889 editor.tab(&mut engine, false).unwrap();
890 editor.insert(&mut engine, "R").unwrap();
891 // Columns dragged narrower than the glyphs they hold.
892 let table = editor.active_outline().shaped().tables[0].id;
893 for column in 0..2 {
894 editor
895 .resize_column(&mut engine, table, column, 37.11)
896 .unwrap();
897 }
898 editor.tab(&mut engine, true).unwrap();
899 let mut primitives = Vec::new();
900 append_outline(
901 Some(&editor),
902 false,
903 editor.active_outline(),
904 [24.0, 48.0],
905 Paint {
906 caret: 0.0,
907 scale: 1.0,
908 device_origin: [0.0; 2],
909 pixel: 1.0,
910 colors: COLORS,
911 visible: [f32::NEG_INFINITY, f32::INFINITY],
912 chrome: true,
913 found: &[],
914 played: None,
915 spelling: None,
916 tag_art: &NO_ART,
917 },
918 &mut primitives,
919 )
920 .unwrap();
921 let bounds = editor.active_outline().shaped().tables[0]
922 .cells
923 .iter()
924 .map(|cell| {
925 let [left, top, right, bottom] = cell.text_bounds();
926 [left + 24.0, top + 48.0, right + 24.0, bottom + 48.0]
927 })
928 .collect::<Vec<_>>();
929 let text = primitives
930 .iter()
931 .filter_map(|primitive| match primitive {
932 Primitive::Text { clip, .. } => Some(clip.unwrap()),
933 _ => None,
934 })
935 .collect::<Vec<_>>();
936 assert_eq!(text, bounds);
937 let highlights = primitives
938 .iter()
939 .filter_map(|primitive| match primitive {
940 Primitive::Rect { rect, color } if *color == crate::gpu::colorref(0xffff) => {
941 Some(*rect)
942 }
943 _ => None,
944 })
945 .collect::<Vec<_>>();
946 assert_eq!(highlights.len(), 2);
947 for (rect, bounds) in highlights.iter().zip(&bounds) {
948 assert_eq!(rect[2], bounds[2]);
949 assert!(rect[0] >= bounds[0] && rect[1] >= bounds[1] && rect[3] <= bounds[3]);
950 }
951 let selection = primitives
952 .iter()
953 .find_map(|primitive| match primitive {
954 Primitive::Rect { rect, color } if *color == COLORS.selection => Some(*rect),
955 _ => None,
956 })
957 .unwrap();
958 assert_eq!(selection[2], bounds[0][2]);
959 assert!(editor.caret(1.0).unwrap().x0 + 24.0 > f64::from(bounds[0][2]));
960}
961
962#[test]
963fn editable_tables_paint_borders_before_selection_and_cell_text() {
964 let mut engine = TextEngine::default();
965 let mut editor = CanvasEditor::new(
966 &mut engine,
967 TextDocument::new(vec![Paragraph::new("Left".into(), Format::default())]).unwrap(),
968 180.0,
969 )
970 .unwrap();
971 editor
972 .move_selection(&mut engine, Movement::LineEnd, false)
973 .unwrap();
974 editor.tab(&mut engine, false).unwrap();
975 editor.insert(&mut engine, "Right").unwrap();
976 editor.tab(&mut engine, true).unwrap();
977 let mut primitives = Vec::new();
978 append_outline(
979 Some(&editor),
980 false,
981 editor.active_outline(),
982 [24.0, 48.0],
983 Paint {
984 caret: 0.0,
985 scale: 1.0,
986 device_origin: [0.0; 2],
987 pixel: 1.0,
988 colors: COLORS,
989 visible: [f32::NEG_INFINITY, f32::INFINITY],
990 chrome: true,
991 found: &[],
992 played: None,
993 spelling: None,
994 tag_art: &NO_ART,
995 },
996 &mut primitives,
997 )
998 .unwrap();
999 let color = crate::gpu::colorref(0x00a3a3a3);
1000 assert!(matches!(primitives[0], Primitive::RoundedRect {
1001 stroke: Some(draw::Stroke::Solid(0.75)), color: actual, ..
1002 } if actual == color));
1003 assert!(matches!(primitives[1], Primitive::Rect { color: actual, .. } if actual == color));
1004 assert!(matches!(primitives[2], Primitive::Rect { color, .. } if color == COLORS.selection));
1005 let painted = primitives
1006 .iter()
1007 .filter_map(|primitive| match primitive {
1008 Primitive::Text { text, origin, .. } => Some((painted_layout(*text).id(), *origin)),
1009 _ => None,
1010 })
1011 .collect::<Vec<_>>();
1012 let expected = editor
1013 .active_outline()
1014 .layouts()
1015 .map(|(_, paragraph)| {
1016 (
1017 paragraph.text.id(),
1018 [paragraph.origin[0] + 24.0, paragraph.origin[1] + 48.0],
1019 )
1020 })
1021 .collect::<Vec<_>>();
1022 assert_eq!(painted, expected);
1023 assert_eq!(painted.len(), 2);
1024}
1025
1026#[test]
1027fn resize_preview_paints_reflowed_text_without_changing_the_editor() {
1028 let mut engine = TextEngine::default();
1029 let editor = CanvasEditor::new(
1030 &mut engine,
1031 TextDocument::new(vec![Paragraph::new(
1032 "A paragraph with enough words to wrap when the resize handle moves inward.".into(),
1033 Format::default(),
1034 )])
1035 .unwrap(),
1036 240.0,
1037 )
1038 .unwrap();
1039 let original = editor.active_outline().snapshot();
1040 let original_layout = editor
1041 .active_outline()
1042 .paragraph_layout(0)
1043 .unwrap()
1044 .text
1045 .id();
1046 let resized = editor.preview_resize(&mut engine, 72.0).unwrap();
1047 let primitives = page_primitives(
1048 &editor,
1049 None,
1050 Some(PointerFeedback::Resize(&resized)),
1051 None,
1052 paint(false, 96.0 / 72.0, 1.0),
1053 )
1054 .unwrap();
1055 let painted = primitives
1056 .iter()
1057 .find_map(|primitive| match primitive {
1058 Primitive::Text { text, .. } => Some(painted_layout(*text)),
1059 _ => None,
1060 })
1061 .unwrap();
1062 assert_eq!(painted.id(), resized.paragraph_layout(0).unwrap().text.id());
1063 assert!(
1064 painted.lines().count()
1065 > editor
1066 .active_outline()
1067 .paragraph_layout(0)
1068 .unwrap()
1069 .text
1070 .lines()
1071 .count()
1072 );
1073 assert_eq!(editor.active_outline().layout(), &original.layout);
1074 assert_eq!(
1075 editor
1076 .active_outline()
1077 .paragraph_layout(0)
1078 .unwrap()
1079 .text
1080 .id(),
1081 original_layout
1082 );
1083 assert!(primitives.iter().any(
1084 |primitive| matches!(primitive, Primitive::RoundedRect { stroke, .. } if stroke.is_some())
1085 ));
1086}
1087
1088#[test]
1089fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and_dpi() {
1090 let mut engine = TextEngine::default();
1091 let format = Format {
1092 font: Some("Arial".into()),
1093 font_size: Some(11.0),
1094 ..Default::default()
1095 };
1096 let mut editor = CanvasEditor::new(&mut engine, TextDocument::new(vec![
1097 Paragraph::new("HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), format.clone()),
1098 Paragraph::new("Second paragraph abcdefghijklmnopqrstuvwxyz.".into(), format),
1099 ]).unwrap(), 220.0).unwrap();
1100 let id = editor.active_outline().id;
1101 editor.move_outline(id, [36.0, 90.0]).unwrap();
1102 let document = editor.active_outline().document().clone();
1103 let layout_ids: Vec<_> = editor
1104 .active_outline()
1105 .layouts()
1106 .map(|(_, p)| p.text.id())
1107 .collect();
1108 for (zoom, expected) in [
1109 (0.5, [62.0, 134.0, 223.0, 219.0]),
1110 (1.0, [81.0, 189.0, 394.0, 348.0]),
1111 (1.5, [101.0, 245.0, 565.0, 479.0]),
1112 (2.0, [120.0, 300.0, 736.0, 608.0]),
1113 ] {
1114 for dpr in [1.0, 2.0] {
1115 let scale = zoom * (96.0 / 72.0) * dpr;
1116 let pixel = dpr / scale;
1117 let (frame, body_top) = outline_chrome(editor.active_outline(), pixel);
1118 for (index, coordinate) in frame.iter().enumerate() {
1119 let origin = if index % 2 == 0 { 48.0 } else { 83.0 };
1120 let screen = coordinate * scale / dpr + origin;
1121 assert!(
1122 (screen - expected[index]).abs() <= 1.5,
1123 "zoom={zoom} dpr={dpr} edge={index}: {screen} vs {}",
1124 expected[index]
1125 );
1126 }
1127 let header = [36.0, (frame[1] + body_top) / 2.0];
1128 assert!(
1129 matches!(page_hit_test(&editor, None, header, pixel), Some(Hit::Handle {id: hit, ..}) if hit == id)
1130 );
1131 assert!(matches!(
1132 page_hit_test(&editor, None, [frame[2] - 4.0 * pixel, header[1]], pixel),
1133 Some(Hit::Resize { id: hit, .. }) if hit == id
1134 ));
1135 let padding = [frame[0] + pixel, 90.0];
1136 assert!(
1137 matches!(page_hit_test(&editor, None, padding, pixel), Some(Hit::Text {id: hit, ..}) if hit == id)
1138 );
1139 assert_eq!(
1140 page_hit_test(&editor, None, [frame[0] - pixel, 90.0], pixel),
1141 None
1142 );
1143 let primitives =
1144 page_primitives(&editor, None, None, None, paint(false, scale, dpr)).unwrap();
1145 assert!(
1146 matches!(&primitives[0], Primitive::RoundedRect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top])
1147 );
1148 assert!(
1149 primitives
1150 .iter()
1151 .any(|p| matches!(p, Primitive::Text {origin, ..} if *origin == [36.0, 90.0]))
1152 );
1153 }
1154 }
1155 assert_eq!(editor.active_outline().document(), &document);
1156 assert_eq!(
1157 editor
1158 .active_outline()
1159 .layouts()
1160 .map(|(_, p)| p.text.id())
1161 .collect::<Vec<_>>(),
1162 layout_ids
1163 );
1164}
1165
1166#[test]
1167fn a_later_text_body_takes_priority_over_an_earlier_drag_handle() {
1168 let mut engine = TextEngine::default();
1169 let mut editor = CanvasEditor::new(
1170 &mut engine,
1171 TextDocument::new(vec![Paragraph::new("first".into(), Default::default())]).unwrap(),
1172 240.0,
1173 )
1174 .unwrap();
1175 let first = editor.active_outline().id;
1176 editor.move_outline(first, [0.0, 20.0]).unwrap();
1177 assert_eq!(
1178 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1179 Some(Hit::Handle {
1180 id: first,
1181 grab: [5.0, -8.0]
1182 })
1183 );
1184 let second = editor
1185 .create_outline(&mut engine, [0.0, 10.0], 240.0)
1186 .unwrap();
1187 assert_eq!(
1188 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1189 Some(Hit::Text {
1190 id: second,
1191 point: [5.0, 2.0]
1192 })
1193 );
1194 editor.undo(&mut engine).unwrap();
1195 assert_eq!(
1196 page_hit_test(&editor, None, [5.0, 12.0], 1.0),
1197 Some(Hit::Handle {
1198 id: first,
1199 grab: [5.0, -8.0]
1200 })
1201 );
1202}
1203
1204#[test]
1205fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
1206 use onestore::page::{Outline, PageObject, Unsupported};
1207 for readonly_on_top in [false, true] {
1208 let mut engine = TextEngine::default();
1209 let document = TextDocument::new(vec![Paragraph::new(
1210 "Imported text".into(),
1211 Default::default(),
1212 )])
1213 .unwrap();
1214 let id = onestore::ExGuid {
1215 guid: [5; 16],
1216 n: 1,
1217 };
1218 let mut objects = vec![
1219 PageObject::Outline(Outline {
1220 id,
1221 title: false,
1222 min_width: None,
1223 layout: onestore::document::Layout {
1224 x: Some(30.0),
1225 y: Some(40.0),
1226 max_width: Some(180.0),
1227 ..Default::default()
1228 },
1229 indents: vec![18.0, 0.0],
1230 paragraphs: document.nodes().to_vec(),
1231 unsupported: Vec::new(),
1232 }),
1233 PageObject::Unsupported(Unsupported {
1234 id: Default::default(),
1235 jcid: 0xdead,
1236 layout: onestore::document::Layout {
1237 x: Some(20.0),
1238 y: Some(20.0),
1239 max_width: Some(200.0),
1240 max_height: Some(100.0),
1241 ..Default::default()
1242 },
1243 }),
1244 ];
1245 if !readonly_on_top {
1246 objects.reverse();
1247 }
1248 let page = Page {
1249 identity: None,
1250 created: None,
1251 title: String::new(),
1252 margin_origin: [0.0; 2],
1253 rtl: false,
1254 color: None,
1255 rule_lines: None,
1256 definitions: Default::default(),
1257 objects,
1258 };
1259 let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
1260 let scene = (scene, [0.0; 2]);
1261 let imported_layout = editor
1262 .active_outline()
1263 .layouts()
1264 .next()
1265 .unwrap()
1266 .1
1267 .text
1268 .id();
1269 let texts: Vec<_> =
1270 page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0))
1271 .unwrap()
1272 .into_iter()
1273 .filter_map(|p| match p {
1274 Primitive::Text { text, .. } => Some(painted_layout(text).id()),
1275 _ => None,
1276 })
1277 .collect();
1278 assert_eq!(texts.len(), 2);
1279 assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout);
1280 let expected = if readonly_on_top {
1281 Hit::ReadOnly(0)
1282 } else {
1283 Hit::Text {
1284 id,
1285 point: [10.0, 5.0],
1286 }
1287 };
1288 assert_eq!(
1289 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1290 Some(expected)
1291 );
1292 let header = page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0);
1293 if readonly_on_top {
1294 assert_eq!(header, Some(Hit::ReadOnly(0)));
1295 } else {
1296 assert_eq!(
1297 header,
1298 Some(Hit::Handle {
1299 id,
1300 grab: [10.0, -8.0]
1301 })
1302 );
1303 }
1304 let annotation = editor
1305 .create_outline(&mut engine, [30.0, 40.0], 180.0)
1306 .unwrap();
1307 assert_eq!(
1308 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1309 Some(Hit::Text {
1310 id: annotation,
1311 point: [10.0, 5.0]
1312 })
1313 );
1314 assert_eq!(
1315 page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0),
1316 Some(Hit::Handle {
1317 id: annotation,
1318 grab: [10.0, -8.0]
1319 })
1320 );
1321 let texts: Vec<_> =
1322 page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0))
1323 .unwrap()
1324 .into_iter()
1325 .filter_map(|p| match p {
1326 Primitive::Text { text, .. } => Some(painted_layout(text).id()),
1327 _ => None,
1328 })
1329 .collect();
1330 assert_eq!(
1331 *texts.last().unwrap(),
1332 editor
1333 .active_outline()
1334 .layouts()
1335 .next()
1336 .unwrap()
1337 .1
1338 .text
1339 .id()
1340 );
1341 editor.move_outline(annotation, [500.0, 500.0]).unwrap();
1342 assert!(
1343 !matches!(page_hit_test(&editor, Some(&scene), [40.0,45.0], 1.0), Some(Hit::Text { id, .. }) if id == annotation)
1344 );
1345 editor.undo(&mut engine).unwrap();
1346 assert_eq!(
1347 page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0),
1348 Some(Hit::Text {
1349 id: annotation,
1350 point: [10.0, 5.0]
1351 })
1352 );
1353 editor.undo(&mut engine).unwrap();
1354 assert_eq!(editor.outlines().len(), 1);
1355 assert_eq!(editor.active_outline().document(), &document);
1356 assert_eq!(
1357 page_hit_test(&editor, Some(&scene), [10.0, 10.0], 1.0),
1358 None
1359 );
1360 }
1361}
1362
1363#[test]
1364fn read_only_focus_accepts_navigation_without_edit_shortcuts() {
1365 for modifiers in [
1366 Modifiers::default(),
1367 Modifiers {
1368 shift: true,
1369 ..Modifiers::default()
1370 },
1371 Modifiers {
1372 option: true,
1373 ..Modifiers::default()
1374 },
1375 Modifiers {
1376 command: true,
1377 ..Modifiers::default()
1378 },
1379 Modifiers {
1380 command: true,
1381 shift: true,
1382 ..Modifiers::default()
1383 },
1384 Modifiers {
1385 command: true,
1386 option: true,
1387 ..Modifiers::default()
1388 },
1389 ] {
1390 for key in [
1391 Key::Character("a".into()),
1392 Key::Character("v".into()),
1393 Key::Character("c".into()),
1394 Key::Character("x".into()),
1395 Key::Character("z".into()),
1396 Key::Character("🌳".into()),
1397 Key::Named(NamedKey::Backspace),
1398 Key::Named(NamedKey::Delete),
1399 Key::Named(NamedKey::Enter),
1400 Key::Named(NamedKey::ArrowLeft),
1401 Key::Named(NamedKey::ArrowRight),
1402 Key::Named(NamedKey::ArrowUp),
1403 Key::Named(NamedKey::ArrowDown),
1404 ] {
1405 assert!(
1406 !read_only_shortcut(&key, modifiers),
1407 "{key:?} {modifiers:?}"
1408 );
1409 }
1410 }
1411 for (key, modifiers) in [
1412 (Key::Named(NamedKey::Escape), Modifiers::default()),
1413 (
1414 Key::Named(NamedKey::Tab),
1415 Modifiers {
1416 control: true,
1417 ..Modifiers::default()
1418 },
1419 ),
1420 (
1421 Key::Named(NamedKey::Tab),
1422 Modifiers {
1423 control: true,
1424 shift: true,
1425 ..Modifiers::default()
1426 },
1427 ),
1428 ] {
1429 assert!(read_only_shortcut(&key, modifiers));
1430 }
1431 assert!(!read_only_shortcut(
1432 &Key::Named(NamedKey::Tab),
1433 Modifiers::default()
1434 ));
1435}
1436
1437#[test]
1438fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
1439 use onestore::page::{PageObject, Unsupported};
1440 let mut engine = TextEngine::default();
1441 let mut editor = CanvasEditor::new(
1442 &mut engine,
1443 TextDocument::new(vec![Paragraph::new("Draft".into(), Default::default())]).unwrap(),
1444 240.0,
1445 )
1446 .unwrap();
1447 let page = Page {
1448 identity: None,
1449 created: None,
1450 title: String::new(),
1451 margin_origin: [0.0; 2],
1452 rtl: false,
1453 color: None,
1454 rule_lines: None,
1455 definitions: Default::default(),
1456 objects: vec![PageObject::Unsupported(Unsupported {
1457 id: Default::default(),
1458 jcid: 0xdead,
1459 layout: onestore::document::Layout {
1460 x: Some(100.0),
1461 y: Some(80.0),
1462 max_width: Some(180.0),
1463 max_height: Some(80.0),
1464 ..Default::default()
1465 },
1466 })],
1467 };
1468 let scene = (PageScene::new(page, &mut engine).unwrap(), [20.0, 30.0]);
1469 let original = editor.active_outline().document().clone();
1470 let rectangles = |primitives: Vec<Primitive<'_>>| {
1471 primitives
1472 .into_iter()
1473 .filter_map(|p| match p {
1474 Primitive::Rect { rect, color } | Primitive::RoundedRect { rect, color, .. } => {
1475 Some((rect, color))
1476 }
1477 _ => None,
1478 })
1479 .collect::<Vec<_>>()
1480 };
1481 for scale in [1.0, 2.0] {
1482 let text = rectangles(
1483 page_primitives(&editor, Some(&scene), None, None, paint(true, scale, 1.0)).unwrap(),
1484 );
1485 assert!(text.iter().any(|(_, color)| *color == COLORS.caret));
1486 let selected = rectangles(
1487 page_primitives(
1488 &editor,
1489 Some(&scene),
1490 None,
1491 Some(ObjectFocus::ReadOnly(0)),
1492 paint(true, scale, 1.0),
1493 )
1494 .unwrap(),
1495 );
1496 assert!(!selected.iter().any(|(_, color)| *color == COLORS.caret));
1497 assert_eq!(
1498 selected,
1499 rectangles(
1500 page_primitives(
1501 &editor,
1502 Some(&scene),
1503 None,
1504 Some(ObjectFocus::ReadOnly(0)),
1505 paint(false, scale, 1.0)
1506 )
1507 .unwrap()
1508 )
1509 );
1510 assert_eq!(
1511 selected.last().unwrap().0,
1512 [300.0 - 2.0 / scale, 110.0, 300.0, 190.0]
1513 );
1514 }
1515 editor.select_all().unwrap();
1516 let text = rectangles(
1517 page_primitives(&editor, Some(&scene), None, None, paint(false, 1.0, 1.0)).unwrap(),
1518 );
1519 assert!(text.iter().any(|(_, color)| *color == COLORS.selection));
1520 let selected = rectangles(
1521 page_primitives(
1522 &editor,
1523 Some(&scene),
1524 None,
1525 Some(ObjectFocus::ReadOnly(0)),
1526 paint(true, 1.0, 1.0),
1527 )
1528 .unwrap(),
1529 );
1530 assert!(!selected.iter().any(|(_, color)| *color == COLORS.selection));
1531 assert_eq!(editor.active_outline().document(), &original);
1532}
1533
1534/// A view at 100% on a 1x display over one outline holding "Before", a 40 x 30 pt picture and
1535/// "After" at (36, 36), with the picture's identity.
1536fn picture_view() -> (PageView, onestore::ExGuid) {
1537 use onestore::page::{Image, PageObject, ParagraphContent};
1538 let mut engine = TextEngine::default();
1539 let mut source = TextOutline::new(
1540 &mut engine,
1541 TextDocument::new(vec![
1542 Paragraph::new("Before".into(), Default::default()),
1543 Paragraph::new("After".into(), Default::default()),
1544 ])
1545 .unwrap(),
1546 240.0,
1547 [36.0, 36.0],
1548 )
1549 .unwrap()
1550 .snapshot();
1551 let mut picture = source.paragraphs[0].clone();
1552 picture.id = onestore::page::text::new_id().unwrap();
1553 let id = onestore::page::text::new_id().unwrap();
1554 picture.content = ParagraphContent::Image(Image {
1555 size: Some([40.0, 30.0]),
1556 id,
1557 layout: Default::default(),
1558 bytes: None,
1559 display: None,
1560 alt: None,
1561 background: false,
1562 printout: None,
1563 tags: Vec::new(),
1564 link: None,
1565 text: None,
1566 });
1567 source.paragraphs.insert(1, picture);
1568 let editor = CanvasEditor::from_page(
1569 Page {
1570 title: String::new(),
1571 identity: None,
1572 created: None,
1573 margin_origin: [36.0, 14.4],
1574 rtl: false,
1575 color: None,
1576 rule_lines: None,
1577 definitions: Default::default(),
1578 objects: vec![PageObject::Outline(source)],
1579 },
1580 &mut engine,
1581 )
1582 .unwrap();
1583 let mut view = PageView::new(
1584 editor,
1585 engine,
1586 None,
1587 [800, 600],
1588 1.0,
1589 Duration::from_millis(500),
1590 );
1591 view.viewport.scale = 1.0;
1592 view.viewport.origin = [0.0; 2];
1593 (view, id)
1594}
1595
1596fn pasted_picture_view() -> PageView {
1597 let (mut view, _) = picture_view();
1598 let (scene, editor) =
1599 PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap();
1600 view.editor = editor;
1601 view.scene = Some((scene, [0.0; 2]));
1602 let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png");
1603 let _ = view.insert_picture(bytes.to_vec(), [40.0, 30.0]).unwrap();
1604 let (scene, _) = view.scene.as_mut().unwrap();
1605 scene.settle(Some(&view.editor), 1.0, COLORS.paper);
1606 view
1607}
1608
1609#[test]
1610fn a_picture_pasted_into_an_open_outline_reaches_the_scene() {
1611 let view = pasted_picture_view();
1612 assert!(view.primitives(COLORS).unwrap().iter().any(|primitive| {
1613 matches!(primitive, Primitive::Image { rect, .. }
1614 if (rect[2] - rect[0] - 40.0).abs() < 0.01
1615 && (rect[3] - rect[1] - 30.0).abs() < 0.01)
1616 }));
1617}
1618
1619#[test]
1620#[ignore = "requires a native GPU adapter"]
1621fn a_pasted_inline_picture_is_drawn_on_the_gpu() {
1622 let view = pasted_picture_view();
1623 let primitives = view.primitives(COLORS).unwrap();
1624 let rect = primitives
1625 .iter()
1626 .find_map(|primitive| match primitive {
1627 Primitive::Image { rect, .. } => Some(*rect),
1628 _ => None,
1629 })
1630 .unwrap();
1631 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
1632 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
1633 let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
1634 let size = [512, 256];
1635 let texture = device.create_texture(&wgpu::TextureDescriptor {
1636 label: Some("Pasted picture"),
1637 size: wgpu::Extent3d {
1638 width: size[0],
1639 height: size[1],
1640 depth_or_array_layers: 1,
1641 },
1642 mip_level_count: 1,
1643 sample_count: 1,
1644 dimension: wgpu::TextureDimension::D2,
1645 format: wgpu::TextureFormat::Rgba8UnormSrgb,
1646 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1647 view_formats: &[],
1648 });
1649 let mut renderer = draw::Renderer::new(
1650 device.clone(),
1651 queue.clone(),
1652 wgpu::TextureFormat::Rgba8UnormSrgb,
1653 );
1654 renderer
1655 .draw(
1656 &texture.create_view(&Default::default()).into(),
1657 size,
1658 [1.0; 4],
1659 &[draw::Layer {
1660 scale: 1.0,
1661 origin: [0.0; 2],
1662 clip: None,
1663 backdrop: None,
1664 round: None,
1665 motion: None,
1666 primitives: &primitives,
1667 }],
1668 )
1669 .unwrap();
1670 let readback = device.create_buffer(&wgpu::BufferDescriptor {
1671 label: Some("Pasted picture pixels"),
1672 size: u64::from(size[0] * size[1] * 4),
1673 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1674 mapped_at_creation: false,
1675 });
1676 let mut encoder = device.create_command_encoder(&Default::default());
1677 encoder.copy_texture_to_buffer(
1678 texture.as_image_copy(),
1679 wgpu::TexelCopyBufferInfo {
1680 buffer: &readback,
1681 layout: wgpu::TexelCopyBufferLayout {
1682 offset: 0,
1683 bytes_per_row: Some(size[0] * 4),
1684 rows_per_image: Some(size[1]),
1685 },
1686 },
1687 texture.size(),
1688 );
1689 queue.submit([encoder.finish()]);
1690 let (sender, receiver) = std::sync::mpsc::channel();
1691 readback.map_async(wgpu::MapMode::Read, .., move |result| {
1692 sender.send(result).unwrap();
1693 });
1694 device
1695 .poll(wgpu::PollType::Wait {
1696 submission_index: None,
1697 timeout: Some(Duration::from_secs(5)),
1698 })
1699 .unwrap();
1700 receiver
1701 .recv_timeout(Duration::from_secs(5))
1702 .unwrap()
1703 .unwrap();
1704 let pixels = readback.get_mapped_range(..).unwrap();
1705 let colored = (rect[1].ceil() as usize..rect[3].floor() as usize)
1706 .flat_map(|y| {
1707 (rect[0].ceil() as usize..rect[2].floor() as usize)
1708 .map(move |x| (y * size[0] as usize + x) * 4)
1709 })
1710 .filter(|&at| {
1711 let rgb = &pixels[at..at + 3];
1712 rgb.iter().max().unwrap() - rgb.iter().min().unwrap() > 30
1713 })
1714 .count();
1715 assert!(
1716 colored > 100,
1717 "picture rectangle contained only {colored} colored pixels"
1718 );
1719 if let Some(path) = std::env::var_os("SNOWBOUND_PICTURE_CAPTURE") {
1720 let mut encoder = png::Encoder::new(std::fs::File::create(path).unwrap(), size[0], size[1]);
1721 encoder.set_color(png::ColorType::Rgba);
1722 encoder.set_depth(png::BitDepth::Eight);
1723 encoder
1724 .write_header()
1725 .unwrap()
1726 .write_image_data(&pixels)
1727 .unwrap();
1728 }
1729}
1730
1731#[test]
1732fn a_picture_context_copies_and_cuts_the_picture_instead_of_hidden_text() {
1733 use onestore::page::{PageObject, ParagraphContent};
1734 for floating in [false, true] {
1735 let (mut view, _) = picture_view();
1736 let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png");
1737 let id = if floating {
1738 let _ = view
1739 .drop_picture([400.0, 200.0], bytes.to_vec(), [40.0, 30.0])
1740 .unwrap();
1741 view.editor
1742 .page()
1743 .unwrap()
1744 .objects
1745 .iter()
1746 .find_map(|object| match object {
1747 PageObject::Image(image) => Some(image.id),
1748 _ => None,
1749 })
1750 .unwrap()
1751 } else {
1752 let image = crate::editor::picture(bytes.to_vec(), [40.0, 30.0]).unwrap();
1753 let id = image.id;
1754 view.editor.insert_picture(&mut view.engine, image).unwrap();
1755 id
1756 };
1757 let (scene, _) =
1758 PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap();
1759 view.scene = Some((scene, [0.0; 2]));
1760 let (origin, size) = view.editor.image_placement(id).unwrap();
1761 let _ = view
1762 .pointer_moved([origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0])
1763 .unwrap();
1764 let (_, context) = view.context().unwrap().unwrap();
1765 assert_eq!(context.image.as_ref().unwrap().id, id);
1766 assert!(context.selected);
1767 let copied = view.copy(false).unwrap();
1768 let Some(Request::Copy(clip)) = copied.request else {
1769 panic!("a picture copy");
1770 };
1771 assert_eq!(clip.paragraphs.len(), 1);
1772 let ParagraphContent::Image(picture) = &clip.paragraphs[0].content else {
1773 panic!("a picture");
1774 };
1775 assert_eq!(picture.id, id);
1776 assert_eq!([picture.layout.x, picture.layout.y], [None; 2]);
1777 assert_eq!(picture.bytes.as_deref(), Some(bytes.as_slice()));
1778 assert_eq!(clip.text(), "");
1779 assert!(view.editor.image_placement(id).is_some());
1780 let cut = view.copy(true).unwrap();
1781 assert!(cut.changed);
1782 assert!(matches!(cut.request, Some(Request::Copy(_))));
1783 assert!(view.editor.image_placement(id).is_none());
1784 assert!(view.undo(false).unwrap().changed);
1785 assert!(view.editor.image_placement(id).is_some());
1786 assert!(view.undo(true).unwrap().changed);
1787 assert!(view.editor.image_placement(id).is_none());
1788 view.editor
1789 .place_caret(&mut view.engine, [600.0, 400.0], 240.0)
1790 .unwrap();
1791 assert!(view.paste_clip(clip).unwrap().changed);
1792 let page = view.editor.page().unwrap();
1793 assert!(
1794 page.objects
1795 .iter()
1796 .filter_map(|object| match object {
1797 PageObject::Outline(outline) => Some(outline),
1798 _ => None,
1799 })
1800 .flat_map(|outline| &outline.paragraphs)
1801 .filter_map(|node| node.text())
1802 .any(|text| text.text.text() == "Before")
1803 );
1804 let pasted = view
1805 .editor
1806 .outlines()
1807 .iter()
1808 .flat_map(|outline| crate::document::descendants(outline.document().nodes(), None))
1809 .find_map(|(_, _, node)| match &node.content {
1810 ParagraphContent::Image(image) if image.bytes.is_some() => Some(image.id),
1811 _ => None,
1812 })
1813 .unwrap();
1814 let (scene, _) = view.scene.as_mut().unwrap();
1815 scene.settle(Some(&view.editor), 1.0, crate::gpu::Paper::WHITE);
1816 assert!(scene.image(pasted).is_some());
1817 }
1818}
1819
1820#[test]
1821fn restoring_a_pictures_original_size_is_undoable_in_an_outline_and_on_the_page() {
1822 for floating in [false, true] {
1823 let (mut view, _) = picture_view();
1824 let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png");
1825 let id = if floating {
1826 let _ = view
1827 .drop_picture([400.0, 200.0], bytes.to_vec(), [40.0, 30.0])
1828 .unwrap();
1829 view.editor
1830 .page()
1831 .unwrap()
1832 .objects
1833 .iter()
1834 .find_map(|object| match object {
1835 onestore::page::PageObject::Image(image) => Some(image.id),
1836 _ => None,
1837 })
1838 .unwrap()
1839 } else {
1840 let image = crate::editor::picture(bytes.to_vec(), [40.0, 30.0]).unwrap();
1841 let id = image.id;
1842 view.editor.insert_picture(&mut view.engine, image).unwrap();
1843 id
1844 };
1845 let (scene, _) =
1846 PageScene::from_page(view.editor.page().unwrap(), &mut view.engine).unwrap();
1847 view.scene = Some((scene, [0.0; 2]));
1848 let (origin, _) = view.editor.image_placement(id).unwrap();
1849 view.editor
1850 .place_image(&mut view.engine, id, origin, [80.0, 60.0])
1851 .unwrap();
1852 let _ = view
1853 .pointer_moved([origin[0] + 10.0, origin[1] + 10.0])
1854 .unwrap();
1855 let (_, context) = view.context().unwrap().unwrap();
1856 assert_eq!(context.image.unwrap().id, id);
1857 assert!(view.restore_picture_size(id).unwrap().changed);
1858 assert_eq!(
1859 view.editor.image_placement(id),
1860 Some((origin, [40.0, 30.0]))
1861 );
1862 assert!(view.undo(false).unwrap().changed);
1863 assert_eq!(
1864 view.editor.image_placement(id),
1865 Some((origin, [80.0, 60.0]))
1866 );
1867 assert!(view.undo(true).unwrap().changed);
1868 assert_eq!(
1869 view.editor.image_placement(id),
1870 Some((origin, [40.0, 30.0]))
1871 );
1872 }
1873}
1874
1875#[test]
1876fn a_page_outline_emptied_by_backspace_still_draws() {
1877 let mut engine = TextEngine::default();
1878 let objects = [[36.0, 36.0], [36.0, 200.0]]
1879 .map(|origin| {
1880 let document =
1881 TextDocument::new(vec![Paragraph::new("Delete me".into(), Default::default())])
1882 .unwrap();
1883 let outline = TextOutline::new(&mut engine, document, 240.0, origin).unwrap();
1884 onestore::page::PageObject::Outline(outline.snapshot())
1885 })
1886 .into();
1887 let page = Page {
1888 title: String::new(),
1889 identity: None,
1890 created: None,
1891 margin_origin: [36.0, 14.4],
1892 rtl: false,
1893 color: None,
1894 rule_lines: None,
1895 definitions: Default::default(),
1896 objects,
1897 };
1898 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
1899 let other = editor.outlines()[1].id;
1900 let mut view = PageView::new(
1901 editor,
1902 engine,
1903 Some((scene, [0.0; 2])),
1904 [800, 600],
1905 1.0,
1906 Duration::from_millis(500),
1907 );
1908 view.editor.select_all().unwrap();
1909 assert!(
1910 view.key(&Key::Named(NamedKey::Backspace), None)
1911 .unwrap()
1912 .changed
1913 );
1914 assert!(view.editor.caret_outline().is_some());
1915 view.primitives(COLORS).unwrap();
1916 view.editor.focus_outline(other).unwrap();
1917 assert_eq!(view.editor.outlines().len(), 1);
1918 view.primitives(COLORS).unwrap();
1919}
1920
1921fn click(view: &mut PageView, point: [f32; 2], now: Instant) {
1922 let _ = view.pointer_moved(point).unwrap();
1923 assert!(view.pointer_pressed(now).unwrap().changed);
1924 assert!(view.pointer_released().unwrap().changed);
1925}
1926
1927#[test]
1928fn events_route_through_the_view_as_the_host_delivers_them() {
1929 let (mut view, picture) = picture_view();
1930 let now = Instant::now();
1931 let outline = view.editor.active_outline().id;
1932 let bounds = view.editor.active_outline().bounds();
1933
1934 // Typing at a clicked caret edits the outline and asks for nothing else.
1935 click(
1936 &mut view,
1937 [bounds.x0 as f32 + 2.0, bounds.y0 as f32 + 6.0],
1938 now,
1939 );
1940 assert_eq!(view.cursor(), Cursor::Text);
1941 let typed = view.key(&Key::Character("X".into()), Some("X")).unwrap();
1942 assert!(typed.changed && typed.request.is_none());
1943 assert!(
1944 view.editor
1945 .active_outline()
1946 .document()
1947 .paragraphs()
1948 .next()
1949 .unwrap()
1950 .text()
1951 .starts_with('X')
1952 );
1953
1954 // Copy asks the host to copy; the host's paste commits its text. The shortcut
1955 // modifier's chords are the host's, so the page takes none of them as typing.
1956 let _ = view
1957 .modifiers_changed(Modifiers {
1958 shift: true,
1959 ..Modifiers::default()
1960 })
1961 .unwrap();
1962 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
1963 assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some("B"));
1964 let _ = view
1965 .modifiers_changed(Modifiers {
1966 command: true,
1967 ..Modifiers::default()
1968 })
1969 .unwrap();
1970 assert_eq!(
1971 view.key(&Key::Character("v".into()), Some("v")).unwrap(),
1972 Response::default()
1973 );
1974 assert!(view.commit_text("line\nbreak".into()).unwrap().changed);
1975 assert_eq!(
1976 view.editor.active_outline().document().paragraphs().count(),
1977 3
1978 );
1979
1980 // Select All selects the caret's paragraph, then the whole outline.
1981 let caret = view.editor.selection().positions[1].paragraph;
1982 let select_all = |view: &mut PageView| {
1983 let _ = view.widen_selection().unwrap();
1984 view.editor
1985 .selection()
1986 .positions
1987 .map(|position| (position.paragraph, position.offset))
1988 };
1989 let last = view
1990 .editor
1991 .active_outline()
1992 .document()
1993 .paragraphs()
1994 .last()
1995 .unwrap();
1996 let whole = [(0, 0), (2, last.utf16_offset(last.text().len()).unwrap())];
1997 let paragraph = select_all(&mut view);
1998 assert!(
1999 paragraph[0] == (caret, 0) && paragraph != whole,
2000 "{paragraph:?}"
2001 );
2002 assert_eq!(select_all(&mut view), whole);
2003 let _ = view.modifiers_changed(Modifiers::default()).unwrap();
2004
2005 // A click on the picture selects it, stops text input, and Delete removes it.
2006 let (origin, size) = view.editor.image_placement(picture).unwrap();
2007 let center = [origin[0] + size[0] / 2.0, origin[1] + size[1] / 2.0];
2008 click(&mut view, center, now + Duration::from_secs(1));
2009 assert_eq!(view.object_focus(), Some(ObjectFocus::Image(picture)));
2010 assert!(!view.accepts_text());
2011 assert_eq!(view.cursor(), Cursor::Move);
2012 assert_eq!(
2013 view.commit_text("ignored".into()).unwrap(),
2014 Response::default()
2015 );
2016 assert!(
2017 view.key(&Key::Named(NamedKey::Delete), None)
2018 .unwrap()
2019 .changed
2020 );
2021 assert!(view.editor.image_placement(picture).is_none());
2022 assert!(view.accepts_text());
2023
2024 // The header drags the outline onto the grid anchored at the page margin.
2025 let bounds = view.editor.active_outline().bounds();
2026 let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0];
2027 let _ = view.pointer_moved(header).unwrap();
2028 let _ = view.pointer_pressed(now + Duration::from_secs(2)).unwrap();
2029 assert_eq!(view.cursor(), Cursor::Move);
2030 let _ = view
2031 .pointer_moved([header[0] + 30.0, header[1] + 20.0])
2032 .unwrap();
2033 assert_eq!(view.outline_preview().map(|(id, _)| id), Some(outline));
2034 let _ = view.pointer_released().unwrap();
2035 let origin = view.editor.active_outline().origin();
2036 assert_eq!(origin, [72.0, 50.4]);
2037
2038 // Dropped outside the view, as past the window's corner, it stays where it was.
2039 let bounds = view.editor.active_outline().bounds();
2040 let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0];
2041 let _ = view.pointer_moved(header).unwrap();
2042 let _ = view.pointer_pressed(now + Duration::from_secs(4)).unwrap();
2043 let _ = view.pointer_moved([-200.0, -150.0]).unwrap();
2044 assert!(view.outline_preview().is_some());
2045 let _ = view.pointer_released().unwrap();
2046 assert_eq!(view.editor.active_outline().origin(), origin);
2047
2048 // An empty spot places a new caret there, 7 px above the pointer on the grid.
2049 click(&mut view, [400.0, 300.0], now + Duration::from_secs(3));
2050 let caret = view.editor.caret_outline().unwrap().origin();
2051 assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4]));
2052
2053 // With Snap to Grid off it lands where the pointer is, as OneNote 2010 places it.
2054 view.snap_to_grid = false;
2055 click(&mut view, [410.0, 330.0], now + Duration::from_secs(5));
2056 let caret = view.editor.caret_outline().unwrap().origin();
2057 assert_eq!(caret, [410.0, 323.0]);
2058}
2059
2060#[test]
2061fn toolbar_commands_wait_for_composition_and_object_focus() {
2062 use crate::editor::{Formatting, Toggle};
2063 let mut engine = TextEngine::default();
2064 let editor = CanvasEditor::new(
2065 &mut engine,
2066 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
2067 240.0,
2068 )
2069 .unwrap();
2070 let mut view = PageView::new(
2071 editor,
2072 engine,
2073 None,
2074 [800, 600],
2075 1.0,
2076 Duration::from_millis(500),
2077 );
2078 let bold = Formatting::Toggle(Toggle::Bold);
2079 view.editor.select_all().unwrap();
2080 let _ = view.compose("に".into(), None).unwrap();
2081 assert_eq!(view.format(bold.clone()).unwrap(), Response::default());
2082 let _ = view.cancel_composition().unwrap();
2083 view.editor.select_all().unwrap();
2084 view.set_object_focus(Some(ObjectFocus::ReadOnly(0)));
2085 assert_eq!(view.format(bold.clone()).unwrap(), Response::default());
2086 view.set_object_focus(None);
2087 assert!(view.format(bold).unwrap().changed);
2088 assert_eq!(view.editor.format_state().unwrap().toggles, [Toggle::Bold]);
2089}
2090
2091#[test]
2092fn outline_chrome_grows_left_to_contain_its_tag_column() {
2093 use crate::editor::{Formatting, NoteTag};
2094 let mut engine = TextEngine::default();
2095 let outline = TextOutline::new(
2096 &mut engine,
2097 TextDocument::new(vec![
2098 Paragraph::new("Plain one".into(), Format::default()),
2099 Paragraph::new("Plain two".into(), Format::default()),
2100 ])
2101 .unwrap(),
2102 468.0,
2103 [288.0, 36.0],
2104 )
2105 .unwrap();
2106 let mut editor =
2107 CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
2108 // OneNote 2010 at 100%: the border sits 15 px left of the text, plus 16 px per tag slot,
2109 // and the text and right border stay put.
2110 let pixel = 0.75;
2111 let (plain, _) = outline_chrome(editor.active_outline(), pixel);
2112 let check = |editor: &CanvasEditor, slots: f32| {
2113 let outline = editor.active_outline();
2114 let (frame, _) = outline_chrome(outline, pixel);
2115 assert_eq!(outline.origin()[0], 288.0);
2116 assert!(((288.0 - frame[0]) / pixel - (15.0 + 16.0 * slots)).abs() < 0.01);
2117 assert_eq!(frame[2], plain[2]);
2118 let shaped = outline.shaped();
2119 for (_, paragraph) in outline.layouts() {
2120 for tag in &paragraph.tags {
2121 let x = outline.origin()[0] + shaped.tag_column_offset() + tag.origin[0];
2122 let y = outline.origin()[1] + paragraph.origin[1] + tag.origin[1];
2123 let size = crate::outline::ParagraphTag::SIZE;
2124 assert!(frame[0] < x && x + size < frame[2], "{frame:?} {x}");
2125 assert!(frame[1] < y && y + size < frame[3], "{frame:?} {y}");
2126 // OneNote 2010 shows an arrow over a check box, which a click toggles.
2127 let hit = page_hit_test(editor, None, [x + 1.0, y + 1.0], pixel);
2128 if tag.icon.checkable() {
2129 assert_eq!(
2130 hit,
2131 Some(Hit::Check {
2132 outline: outline.id,
2133 paragraph: paragraph.id,
2134 })
2135 );
2136 } else {
2137 assert!(matches!(hit, Some(Hit::Text { .. })));
2138 }
2139 }
2140 }
2141 };
2142 check(&editor, 0.0);
2143 editor
2144 .format(
2145 &mut engine,
2146 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
2147 )
2148 .unwrap();
2149 check(&editor, 1.0);
2150 editor
2151 .format(
2152 &mut engine,
2153 Formatting::Tag(NoteTag::defaults()[2].clone(), 2),
2154 )
2155 .unwrap();
2156 check(&editor, 2.0);
2157 editor
2158 .format(
2159 &mut engine,
2160 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
2161 )
2162 .unwrap();
2163 editor
2164 .format(
2165 &mut engine,
2166 Formatting::Tag(NoteTag::defaults()[2].clone(), 2),
2167 )
2168 .unwrap();
2169 check(&editor, 0.0);
2170}
2171
2172#[test]
2173fn a_click_on_a_check_box_toggles_it_under_an_arrow() {
2174 use crate::editor::{Formatting, NoteTag};
2175 let mut engine = TextEngine::default();
2176 let outline = TextOutline::new(
2177 &mut engine,
2178 TextDocument::new(vec![Paragraph::new("Task".into(), Format::default())]).unwrap(),
2179 468.0,
2180 [288.0, 36.0],
2181 )
2182 .unwrap();
2183 let mut editor =
2184 CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
2185 editor
2186 .format(
2187 &mut engine,
2188 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
2189 )
2190 .unwrap();
2191 let outline = editor.active_outline();
2192 let (_, paragraph) = outline.layouts().next().unwrap();
2193 let tag = &paragraph.tags[0];
2194 let centre = [
2195 outline.origin()[0] + outline.shaped().tag_column_offset() + tag.origin[0] + tag.size / 2.0,
2196 outline.origin()[1] + paragraph.origin[1] + tag.origin[1] + tag.size / 2.0,
2197 ];
2198 let mut view = PageView::new(
2199 editor,
2200 engine,
2201 None,
2202 [800, 600],
2203 1.0,
2204 Duration::from_millis(500),
2205 );
2206 let device = [0, 1].map(|axis| centre[axis] * view.viewport.scale + view.viewport.origin[axis]);
2207 let _ = view.pointer_moved(device).unwrap();
2208 assert_eq!(view.cursor(), Cursor::Default);
2209 let status = |view: &PageView| {
2210 view.editor.active_outline().document().nodes()[0]
2211 .text()
2212 .unwrap()
2213 .tags[0]
2214 .status
2215 };
2216 assert!(view.pointer_pressed(Instant::now()).unwrap().changed);
2217 let _ = view.pointer_released().unwrap();
2218 assert_eq!(status(&view), 1);
2219 let _ = view.pointer_moved([device[0] + 40.0, device[1]]).unwrap();
2220 assert_eq!(view.cursor(), Cursor::Text);
2221}
2222
2223/// OneNote 2010 moves an outline by the drag from its stored position and snaps it to the
2224/// nearest 18 pt step from the page's margin origin, (36, 14.4) until one is stored.
2225#[test]
2226fn a_dragged_outline_snaps_to_the_margin_grid_as_onenote_stores_it() {
2227 let margin = [36.0, 14.4];
2228 for (stored, drag, expected) in [
2229 ([72.0, 72.0], [15.0, 0.0], [90.0, 68.4]),
2230 ([90.0, 68.4], [5.25, 15.0], [90.0, 86.4]),
2231 ([0.0, 8.0], [30.0, 0.0], [36.0, 14.4]),
2232 ] {
2233 let point = [stored[0] + drag[0], stored[1] + drag[1]];
2234 let snapped = snap_to_grid(point, margin);
2235 assert!(
2236 (0..2).all(|axis| (snapped[axis] - expected[axis]).abs() < 1e-4),
2237 "{snapped:?} {expected:?}"
2238 );
2239 }
2240}
2241
2242/// OneNote 2010 at 75 to 150% opens a page scrolled fully left and up: 11 px beyond 7.5 pt
2243/// left of text, a list marker, 0.75 pt into a tag's slot, or 6 pt above an outline, never
2244/// right of or below the page origin, even when the only outline lies below the view.
2245#[test]
2246fn a_page_opens_where_onenote_places_the_view() {
2247 use crate::editor::{Formatting, NoteTag};
2248 let open = |origin: [f32; 2], tag: bool| {
2249 let mut engine = TextEngine::default();
2250 let outline = TextOutline::new(
2251 &mut engine,
2252 TextDocument::new(vec![Paragraph::new("Plain".into(), Format::default())]).unwrap(),
2253 468.0,
2254 origin,
2255 )
2256 .unwrap();
2257 let mut editor =
2258 CanvasEditor::from_text_outlines(vec![outline], Default::default(), None).unwrap();
2259 if tag {
2260 editor
2261 .format(
2262 &mut engine,
2263 Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
2264 )
2265 .unwrap();
2266 }
2267 let reached = reach(editor.active_outline());
2268 let view = PageView::new(
2269 editor,
2270 engine,
2271 None,
2272 [800, 600],
2273 1.0,
2274 Duration::from_millis(500),
2275 );
2276 (reached, view.viewport.origin)
2277 };
2278 // Native at 100%: page origin 21 px right and 20 px down, 74 and 60 px, 37 px and 0.
2279 for (origin, tag, reached, placed) in [
2280 ([0.0, 0.0], false, [-7.5, -6.0], [21.0, 19.0]),
2281 ([-40.0, -30.0], false, [-47.5, -36.0], [74.33, 59.0]),
2282 ([0.0, 40.0], true, [-19.5, 34.0], [37.0, 0.0]),
2283 ([72.0, 72.0], false, [64.5, 66.0], [0.0, 0.0]),
2284 ] {
2285 let (actual, view) = open(origin, tag);
2286 assert_eq!(actual, reached);
2287 assert!(
2288 (0..2).all(|axis| (view[axis] - placed[axis]).abs() < 0.01),
2289 "{origin:?} {view:?}"
2290 );
2291 }
2292 let (_, view) = open([50.0, 900.0], false);
2293 assert_eq!(view, [0.0, 0.0]);
2294}
2295
2296/// OneNote 2010 returns to a page at the scroll, in device pixels whatever the zoom, and the
2297/// selection it was left with, without revealing the selection.
2298#[test]
2299fn a_page_reopens_where_it_was_left() {
2300 use crate::document::TextPosition;
2301 let mut engine = TextEngine::default();
2302 let outlines: Vec<TextOutline> = [("First", [0.0, 0.0]), ("Far below", [50.0, 900.0])]
2303 .into_iter()
2304 .map(|(text, origin)| {
2305 let paragraph = Paragraph::new(text.into(), Format::default());
2306 let document = TextDocument::new(vec![paragraph]).unwrap();
2307 TextOutline::new(&mut engine, document, 468.0, origin).unwrap()
2308 })
2309 .collect();
2310 let editor = |count: usize| {
2311 CanvasEditor::from_text_outlines(outlines[..count].to_vec(), Default::default(), None)
2312 .unwrap()
2313 };
2314 let mut view = PageView::new(
2315 editor(2),
2316 engine,
2317 None,
2318 [800, 600],
2319 1.0,
2320 Duration::from_millis(500),
2321 );
2322 let far = outlines[1].id;
2323 view.editor.focus_outline(far).unwrap();
2324 let selection = Selection::from([2, 5].map(|offset| TextPosition {
2325 paragraph: 0,
2326 offset,
2327 }));
2328 view.editor.select(selection).unwrap();
2329 let _ = view.scroll_to(1, 300.0).unwrap();
2330 let place = view.place();
2331
2332 view.open(editor(2), None, None);
2333 assert_eq!(view.viewport.origin, [21.0, 19.0]);
2334 let _ = view.set_zoom(2.0).unwrap();
2335 view.open(editor(2), None, Some(place));
2336 assert_eq!(view.viewport.origin, [21.0, -300.0]);
2337 assert_eq!(view.editor.active_outline().id, far);
2338 assert_eq!(view.editor.selection(), selection);
2339
2340 // A place past the page's content is kept within it, here 6 pt above the outline at 200%.
2341 view.open(editor(1), None, Some(place));
2342 assert_eq!(view.viewport.origin, [21.0, 27.0]);
2343 assert_eq!(view.editor.active_outline().id, outlines[0].id);
2344}
2345
2346#[test]
2347fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() {
2348 let mut engine = TextEngine::default();
2349 let lines = (0..100)
2350 .map(|line| Paragraph::new(format!("Line {line}"), Default::default()))
2351 .collect();
2352 let outline = TextOutline::new(
2353 &mut engine,
2354 TextDocument::new(lines).unwrap(),
2355 240.0,
2356 [36.0, 36.0],
2357 )
2358 .unwrap();
2359 let editor = CanvasEditor::from_page(
2360 Page {
2361 title: String::new(),
2362 identity: None,
2363 created: None,
2364 margin_origin: [36.0, 14.4],
2365 rtl: false,
2366 color: None,
2367 rule_lines: None,
2368 definitions: Default::default(),
2369 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
2370 },
2371 &mut engine,
2372 )
2373 .unwrap();
2374 let mut view = PageView::new(
2375 editor,
2376 engine,
2377 None,
2378 [800, 200],
2379 1.0,
2380 Duration::from_millis(500),
2381 );
2382 view.viewport.scale = 1.0;
2383 let page_down = Key::Named(NamedKey::PageDown);
2384 let option = Modifiers {
2385 option: true,
2386 ..Modifiers::default()
2387 };
2388 for modifiers in [Modifiers::default(), option] {
2389 view.viewport.origin = [0.0; 2];
2390 view.editor
2391 .move_selection(&mut view.engine, Movement::DocumentStart, false)
2392 .unwrap();
2393 let _ = view.modifiers_changed(modifiers).unwrap();
2394 let caret = view.caret_area().unwrap()[1];
2395 let _ = view.key(&page_down, None).unwrap();
2396 let scrolled = -view.viewport.origin[1];
2397 let shown = view.caret_area().unwrap()[1];
2398 match Command::from_key(&page_down, modifiers) {
2399 Some(Command::ScrollPage { up: false }) => {
2400 assert_eq!(scrolled, 190.0, "a page less AppKit's ten points");
2401 assert_eq!(shown, caret - scrolled, "the caret stays in the text");
2402 }
2403 Some(Command::MovePage { up: false }) => {
2404 assert!(scrolled >= 200.0, "{scrolled}");
2405 assert_eq!(shown, caret, "the caret keeps its place on screen");
2406 }
2407 None => assert_eq!((scrolled, shown), (0.0, caret)),
2408 other => panic!("Page Down is {other:?}"),
2409 }
2410 }
2411}
2412
2413#[test]
2414fn an_empty_preedit_without_a_composition_changes_nothing() {
2415 let (mut view, _) = picture_view();
2416 assert_eq!(
2417 view.compose(String::new(), None).unwrap(),
2418 Response::default()
2419 );
2420 assert!(view.compose("k".into(), Some((1, 1))).unwrap().changed);
2421 assert!(view.compose(String::new(), None).unwrap().changed);
2422 assert!(view.editor.marked_range().is_none());
2423}
2424
2425/// The lab's page: a title, then outlines placed in the order Alpha, Beta, Gamma, Delta, with
2426/// Delta highest on the page, drawn at 1 point per pixel.
2427fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) {
2428 let mut engine = TextEngine::default();
2429 let outline = |engine: &mut TextEngine, text: &str, origin| {
2430 let paragraphs = text
2431 .split('\n')
2432 .map(|line| Paragraph::new(line.into(), Default::default()))
2433 .collect();
2434 TextOutline::new(
2435 engine,
2436 TextDocument::new(paragraphs).unwrap(),
2437 240.0,
2438 origin,
2439 )
2440 .unwrap()
2441 .snapshot()
2442 };
2443 let mut title = outline(&mut engine, "Multi", [0.0; 2]);
2444 title.title = true;
2445 let body = [
2446 ("Alpha one\nAlpha two", [36.0, 90.0]),
2447 ("Beta", [300.0, 180.0]),
2448 ("Gamma long line of text", [90.0, 300.0]),
2449 ("Delta", [380.0, 72.0]),
2450 ]
2451 .map(|(text, origin)| outline(&mut engine, text, origin));
2452 let ids = body.each_ref().map(|outline| outline.id);
2453 let page = Page {
2454 title: "Multi".into(),
2455 identity: None,
2456 created: None,
2457 margin_origin: [36.0, 14.4],
2458 rtl: false,
2459 color: None,
2460 rule_lines: None,
2461 definitions: Default::default(),
2462 objects: std::iter::once(onestore::page::PageObject::Title(onestore::page::Title {
2463 id: onestore::page::text::new_id().unwrap(),
2464 date: None,
2465 layout: Default::default(),
2466 outlines: vec![title],
2467 }))
2468 .chain(body.map(onestore::page::PageObject::Outline))
2469 .collect(),
2470 };
2471 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
2472 let mut view = PageView::new(
2473 editor,
2474 engine,
2475 Some((scene, [0.0; 2])),
2476 [800, 600],
2477 1.0,
2478 Duration::from_millis(500),
2479 );
2480 view.viewport.scale = 1.0;
2481 view.viewport.origin = [0.0; 2];
2482 (view, ids)
2483}
2484
2485const START: TextPosition = TextPosition {
2486 paragraph: 0,
2487 offset: 0,
2488};
2489
2490fn select_page(view: &mut PageView, from: onestore::ExGuid) {
2491 view.editor.focus_outline(from).unwrap();
2492 view.editor.select([START; 2].into()).unwrap();
2493 while view.editor.whole() != Some(Whole::Page) {
2494 let _ = view.widen_selection().unwrap();
2495 }
2496}
2497
2498fn origins(view: &PageView) -> Vec<[f32; 2]> {
2499 let body = view
2500 .editor
2501 .outlines()
2502 .iter()
2503 .filter(|outline| !outline.title);
2504 body.map(TextOutline::origin).collect()
2505}
2506
2507/// As OneNote 2010 widens Ctrl+A past an outline (lab, 2026-09-28): a body outline's
2508/// paragraph, the outline as an object, then every body outline, focusing the last.
2509#[test]
2510fn select_all_widens_from_an_outline_to_the_page() {
2511 let (mut view, [alpha, beta, _, delta]) = page_selection_view();
2512 let levels = |view: &mut PageView, from| {
2513 view.editor.focus_outline(from).unwrap();
2514 view.editor.select([START; 2].into()).unwrap();
2515 (0..4)
2516 .map(|_| {
2517 let _ = view.widen_selection().unwrap();
2518 (view.editor.whole(), view.editor.active_outline().id)
2519 })
2520 .collect::<Vec<_>>()
2521 };
2522 let page = (Some(Whole::Page), delta);
2523 for from in [alpha, beta] {
2524 assert_eq!(
2525 levels(&mut view, from),
2526 [(None, from), (Some(Whole::Outline), from), page, page]
2527 );
2528 }
2529 // The title has no object level: its text, then the page.
2530 let title = view.editor.outlines()[0].id;
2531 assert_eq!(levels(&mut view, title), [(None, title), page, page, page]);
2532 // The selection it chose ends with any other; an arrow leaves the caret at its start.
2533 let _ = view.key(&Key::Named(NamedKey::ArrowLeft), None).unwrap();
2534 assert_eq!(view.editor.whole(), None);
2535 assert_eq!(view.editor.active_outline().id, delta);
2536 assert_eq!(view.editor.selection().positions, [START; 2]);
2537}
2538
2539/// Each outline Select All holds shows its frame and a grey body, its text highlighted.
2540#[test]
2541fn a_page_selection_frames_and_greys_every_body_outline() {
2542 let (mut view, [alpha, ..]) = page_selection_view();
2543 let grey = crate::gpu::colorref(0x00f0f0f0);
2544 let paint = |view: &PageView| {
2545 let primitives = view.primitives(COLORS).unwrap();
2546 let count =
2547 |wanted: &dyn Fn(&Primitive) -> bool| primitives.iter().filter(|p| wanted(p)).count();
2548 (
2549 count(
2550 &|p| matches!(p, Primitive::RoundedRect { color, stroke: None, .. } if *color == grey),
2551 ),
2552 count(&|p| matches!(p, Primitive::Rect { color, .. } if *color == COLORS.selection)),
2553 )
2554 };
2555 view.editor.focus_outline(alpha).unwrap();
2556 assert_eq!(paint(&view).0, 0);
2557 let _ = view.widen_selection().unwrap();
2558 let _ = view.widen_selection().unwrap();
2559 let (greyed, alpha) = paint(&view);
2560 assert_eq!(greyed, 1);
2561 let _ = view.widen_selection().unwrap();
2562 // One line each of the others besides Alpha's.
2563 assert_eq!(paint(&view), (4, alpha + 3));
2564}
2565
2566/// Keys on the page selection (lab): arrows up and down and Tab leave it, Enter and Right
2567/// put the caret at the last outline's end, Delete and Backspace remove every outline and
2568/// leave the caret where the first began, and typing replaces them all with the last outline
2569/// holding the text. Each edit stores as one edit and undoes in one step to the selection.
2570#[test]
2571fn keys_act_on_the_page_selection_as_onenote_does() {
2572 let (mut view, [alpha, beta, gamma, delta]) = page_selection_view();
2573 let end = TextPosition {
2574 paragraph: 0,
2575 offset: 5,
2576 };
2577 for key in [
2578 NamedKey::ArrowUp,
2579 NamedKey::ArrowDown,
2580 NamedKey::Tab,
2581 NamedKey::Enter,
2582 ] {
2583 select_page(&mut view, beta);
2584 let _ = view.key(&Key::Named(key), None).unwrap();
2585 let kept = key != NamedKey::Enter;
2586 assert_eq!(view.editor.whole() == Some(Whole::Page), kept, "{key:?}");
2587 if !kept {
2588 assert_eq!(view.editor.selection().positions, [end; 2]);
2589 }
2590 }
2591 assert_eq!(view.editor.take_ops().unwrap(), []);
2592
2593 let stored = origins(&view);
2594 for key in [NamedKey::Delete, NamedKey::Backspace] {
2595 select_page(&mut view, gamma);
2596 let _ = view.key(&Key::Named(key), None).unwrap();
2597 assert!(view.editor.outlines().iter().all(|outline| outline.title));
2598 assert_eq!(
2599 view.editor.caret_outline().map(TextOutline::origin),
2600 Some(stored[0])
2601 );
2602 let ops = view.editor.take_ops().unwrap();
2603 let deleted = ops.iter().filter_map(|op| match op {
2604 onestore::op::PageOp::Delete { object } => Some(*object),
2605 _ => None,
2606 });
2607 assert_eq!(
2608 deleted.collect::<BTreeSet<_>>(),
2609 BTreeSet::from([alpha, beta, gamma, delta])
2610 );
2611 assert_eq!(ops.len(), 4);
2612 let _ = view.undo(false).unwrap();
2613 assert_eq!(origins(&view), stored);
2614 assert_eq!(view.editor.whole(), Some(Whole::Page));
2615 view.editor.take_ops().unwrap();
2616 }
2617
2618 select_page(&mut view, alpha);
2619 let _ = view.key(&Key::Character("Z".into()), Some("Z")).unwrap();
2620 let left = view
2621 .editor
2622 .outlines()
2623 .iter()
2624 .filter(|outline| !outline.title);
2625 assert_eq!(
2626 left.map(|outline| (outline.id, outline.shown_text()))
2627 .collect::<Vec<_>>(),
2628 [(delta, "Z".to_owned())]
2629 );
2630 let _ = view.undo(false).unwrap();
2631 assert_eq!(origins(&view), stored);
2632 assert_eq!(view.editor.whole(), Some(Whole::Page));
2633 assert_eq!(view.editor.active_outline().shown_text(), "Delta");
2634}
2635
2636/// Copy takes the outlines top to bottom with a blank line between, as OneNote's plain text
2637/// does; Cut removes them as Delete does.
2638#[test]
2639fn copy_and_cut_take_the_page_selection() {
2640 let (mut view, [alpha, ..]) = page_selection_view();
2641 select_page(&mut view, alpha);
2642 let text = "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text";
2643 assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some(text));
2644 assert_eq!(view.editor.whole(), Some(Whole::Page));
2645 assert_eq!(copied(view.copy(true).unwrap()).as_deref(), Some(text));
2646 assert!(view.editor.outlines().iter().all(|outline| outline.title));
2647 assert!(view.editor.caret_outline().is_some());
2648}
2649
2650/// Dragging any selected outline's handle moves them all by one snapped offset, stored as one
2651/// edit and undone in one step; a click on a handle selects that outline alone.
2652#[test]
2653fn a_dragged_page_selection_moves_every_outline() {
2654 let (mut view, [alpha, beta, ..]) = page_selection_view();
2655 let stored = origins(&view);
2656 select_page(&mut view, alpha);
2657 let now = Instant::now();
2658 let bounds = view
2659 .editor
2660 .outlines()
2661 .iter()
2662 .find(|outline| outline.id == beta)
2663 .unwrap()
2664 .bounds();
2665 let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0];
2666 let _ = view.pointer_moved(header).unwrap();
2667 let _ = view.pointer_pressed(now).unwrap();
2668 assert_eq!(view.editor.whole(), Some(Whole::Page));
2669 let _ = view
2670 .pointer_moved([header[0] + 30.0, header[1] + 40.0])
2671 .unwrap();
2672 // Every selected outline follows the drag while it lasts.
2673 let dragged = view.primitives(COLORS).unwrap().len();
2674 assert!(dragged > 0);
2675 let _ = view.pointer_released().unwrap();
2676 // Beta lands on the grid, 18 pt cells from the margin origin, and the rest follow.
2677 let moved = origins(&view);
2678 assert_eq!(moved[1], [324.0, 212.4]);
2679 for (before, after) in stored.iter().zip(&moved) {
2680 for axis in 0..2 {
2681 let delta = moved[1][axis] - stored[1][axis];
2682 assert!((after[axis] - before[axis] - delta).abs() < 1e-3);
2683 }
2684 }
2685 assert_eq!(view.editor.whole(), Some(Whole::Page));
2686 assert_eq!(view.editor.take_ops().unwrap().len(), 4);
2687 let _ = view.undo(false).unwrap();
2688 assert_eq!(origins(&view), stored);
2689
2690 // Option-Command-arrows nudge the whole selection.
2691 let _ = view
2692 .modifiers_changed(Modifiers {
2693 command: true,
2694 option: true,
2695 ..Modifiers::default()
2696 })
2697 .unwrap();
2698 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
2699 let _ = view.modifiers_changed(Modifiers::default()).unwrap();
2700 assert!(
2701 origins(&view)
2702 .iter()
2703 .zip(&stored)
2704 .all(|(after, before)| *after == [before[0] + 1.0, before[1]])
2705 );
2706
2707 let _ = view.pointer_moved(header).unwrap();
2708 let _ = view.pointer_pressed(now + Duration::from_secs(1)).unwrap();
2709 let _ = view.pointer_released().unwrap();
2710 assert_eq!(view.editor.whole(), Some(Whole::Outline));
2711 assert_eq!(view.editor.active_outline().id, beta);
2712}
2713
2714/// A toggle on the page selection turns on everywhere unless every outline has it, as one
2715/// undo step that keeps the selection.
2716#[test]
2717fn formatting_the_page_selection_settles_on_one_state() {
2718 let (mut view, [alpha, beta, ..]) = page_selection_view();
2719 let bold = |view: &mut PageView| {
2720 let ids = view
2721 .editor
2722 .outlines()
2723 .iter()
2724 .filter(|outline| !outline.title)
2725 .map(|outline| outline.id)
2726 .collect::<Vec<_>>();
2727 let focus = view.editor.active_outline().id;
2728 let selection = view.editor.selection();
2729 let whole = view.editor.whole();
2730 let states = ids
2731 .into_iter()
2732 .map(|id| {
2733 view.editor.focus_outline(id).unwrap();
2734 view.editor.select_all().unwrap();
2735 view.editor
2736 .format_state()
2737 .unwrap()
2738 .toggles
2739 .contains(&Toggle::Bold)
2740 })
2741 .collect::<Vec<_>>();
2742 if whole == Some(Whole::Page) {
2743 select_page(view, focus);
2744 } else {
2745 view.editor.focus_outline(focus).unwrap();
2746 view.editor.select(selection).unwrap();
2747 }
2748 states
2749 };
2750 view.editor.focus_outline(beta).unwrap();
2751 view.editor.select_all().unwrap();
2752 let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap();
2753 assert_eq!(bold(&mut view), [false, true, false, false]);
2754 select_page(&mut view, alpha);
2755 let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap();
2756 assert_eq!(view.editor.whole(), Some(Whole::Page));
2757 assert_eq!(bold(&mut view), [true; 4]);
2758 let _ = view.format(Formatting::Toggle(Toggle::Bold)).unwrap();
2759 assert_eq!(bold(&mut view), [false; 4]);
2760 let _ = view.undo(false).unwrap();
2761 assert_eq!(bold(&mut view), [true; 4]);
2762 assert_eq!(view.editor.whole(), Some(Whole::Page));
2763}
2764
2765/// Selecting the last outline's text again by other means selects only that text.
2766#[test]
2767fn the_page_selection_does_not_return_with_its_text_selection() {
2768 let (mut view, [alpha, ..]) = page_selection_view();
2769 select_page(&mut view, alpha);
2770 let whole = view.editor.selection();
2771 let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
2772 view.editor.select(whole).unwrap();
2773 assert_eq!(view.editor.whole(), None);
2774}
2775
2776/// The point under the pointer stays put through a pinch, as in Safari, even where the
2777/// page's content is too small to scroll that far; scrolling afterwards still reaches it.
2778#[test]
2779fn pinch_zooms_about_the_pointer_within_range() {
2780 let mut engine = TextEngine::default();
2781 let editor = CanvasEditor::new(
2782 &mut engine,
2783 TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(),
2784 240.0,
2785 )
2786 .unwrap();
2787 let mut view = PageView::new(
2788 editor,
2789 engine,
2790 None,
2791 [800, 600],
2792 1.0,
2793 Duration::from_millis(500),
2794 );
2795 let pointer = [700.0, 500.0];
2796 let under = view.viewport.document_point(pointer);
2797 let shown = view.viewport.document_point([800.0, 600.0]);
2798 let stays = |view: &PageView| {
2799 let after = view.viewport.document_point(pointer);
2800 assert!(
2801 (after[0] - under[0]).abs() < 1e-3 && (after[1] - under[1]).abs() < 1e-3,
2802 "{under:?} moved to {after:?}"
2803 );
2804 };
2805 let zoom = view.zoom();
2806 for factor in [1.05, 1.0 / 1.05, 1.05] {
2807 for _ in 0..10 {
2808 assert!(view.pinch(factor, pointer).unwrap().moved);
2809 stays(&view);
2810 }
2811 }
2812 assert!((view.zoom() / zoom - 1.05_f32.powi(10)).abs() < 1e-4);
2813 let _ = view.wheel([-5000.0; 2]).unwrap();
2814 let corner = view.viewport.document_point([800.0, 600.0]);
2815 assert!((corner[0] - shown[0]).abs() < 1e-3 && (corner[1] - shown[1]).abs() < 1e-3);
2816 let _ = view.pinch(100.0, pointer).unwrap();
2817 assert!((view.zoom() - 4.0).abs() < 1e-5);
2818}
2819
2820fn texts(view: &PageView) -> Vec<(String, [f32; 2])> {
2821 view.editor
2822 .outlines()
2823 .iter()
2824 .filter(|outline| !outline.title)
2825 .map(|outline| (outline.shown_text(), outline.origin()))
2826 .collect()
2827}
2828
2829/// Drags Insert Space from `from` to `to`, view points at 1:1.
2830fn insert_space(view: &mut PageView, from: [f32; 2], to: [f32; 2]) {
2831 let _ = view.insert_space();
2832 let _ = view.pointer_moved(from).unwrap();
2833 let _ = view.pointer_pressed(Instant::now()).unwrap();
2834 let _ = view.pointer_moved(to).unwrap();
2835 let _ = view.pointer_released().unwrap();
2836}
2837
2838/// As OneNote 2010's Insert Space (lab, 2026-09-29): what starts at or below the line moves
2839/// by the drag, an outline it crosses parts between paragraphs into a new outline below, the
2840/// title stays, and one undo step puts everything back.
2841#[test]
2842fn insert_space_moves_what_lies_below_and_parts_a_crossed_outline() {
2843 let (mut view, [alpha, ..]) = page_selection_view();
2844 let stored = texts(&view);
2845 let title = view.editor.outlines()[0].origin();
2846 let alpha_outline = view
2847 .editor
2848 .outlines()
2849 .iter()
2850 .find(|o| o.id == alpha)
2851 .unwrap();
2852 let second = alpha_outline.origin()[1] + alpha_outline.shaped().paragraphs[1].origin[1];
2853 let _ = view.insert_space();
2854 let _ = view.pointer_moved([400.0, second]).unwrap();
2855 assert!(view.inserting_space());
2856 assert_eq!(view.cursor(), Cursor::RowResize);
2857 let _ = view.pointer_pressed(Instant::now()).unwrap();
2858 let _ = view.pointer_moved([400.0, second + 50.0]).unwrap();
2859 // Nothing moves until the drag ends; its lines, arrow and landing tints show.
2860 assert_eq!(texts(&view), stored);
2861 assert!(view.primitives(COLORS).is_ok());
2862 let _ = view.pointer_released().unwrap();
2863 assert!(!view.inserting_space());
2864 let moved = texts(&view);
2865 let expected: Vec<(String, [f32; 2])> = vec![
2866 ("Alpha one".into(), stored[0].1),
2867 ("Alpha two".into(), [stored[0].1[0], second + 50.0]),
2868 ("Beta".into(), [300.0, 230.0]),
2869 ("Gamma long line of text".into(), [90.0, 350.0]),
2870 ("Delta".into(), [380.0, 72.0]),
2871 ];
2872 assert_eq!(moved, expected);
2873 assert_eq!(view.editor.outlines()[0].origin(), title);
2874 assert!(!view.editor.take_ops().unwrap().is_empty());
2875 let _ = view.undo(false).unwrap();
2876 assert_eq!(texts(&view), stored);
2877 assert_eq!(view.editor.whole(), None);
2878 let _ = view.undo(true).unwrap();
2879 assert_eq!(texts(&view), expected);
2880}
2881
2882/// Within half an inch of the view's side the line runs down the page and moves what starts
2883/// right of it; taking space back stops at the end of what stays before the line.
2884#[test]
2885fn insert_space_runs_down_the_page_near_its_side_and_takes_back_only_space() {
2886 let (mut view, _) = page_selection_view();
2887 let stored = texts(&view);
2888 let _ = view.insert_space();
2889 let _ = view.pointer_moved([20.0, 200.0]).unwrap();
2890 assert_eq!(view.cursor(), Cursor::ColResize);
2891 let _ = view.pointer_moved([790.0, 200.0]).unwrap();
2892 assert_eq!(view.cursor(), Cursor::ColResize);
2893 insert_space(&mut view, [20.0, 200.0], [60.0, 200.0]);
2894 let right: Vec<_> = stored
2895 .iter()
2896 .map(|(text, [x, y])| (text.clone(), [x + 40.0, *y]))
2897 .collect();
2898 assert_eq!(texts(&view), right);
2899 let _ = view.undo(false).unwrap();
2900 // Undo scrolls to what it restores; the drags below are in page points.
2901 view.viewport.origin = [0.0; 2];
2902
2903 // Beta ends at its line's bottom; Gamma, below the line at 250, comes up to it and no
2904 // further.
2905 let beta = view.editor.outlines()[2].bounds().y1 as f32;
2906 insert_space(&mut view, [400.0, 250.0], [400.0, 20.0]);
2907 let gamma = texts(&view)[2].1[1];
2908 assert!((gamma - (300.0 - (250.0 - beta))).abs() < 1e-3, "{gamma}");
2909 // A line with something stationary across it cannot take space back.
2910 let _ = view.undo(false).unwrap();
2911 view.viewport.origin = [0.0; 2];
2912 insert_space(&mut view, [400.0, 185.0], [400.0, 20.0]);
2913 assert_eq!(texts(&view), stored);
2914}
2915
2916#[test]
2917fn escape_cancels_insert_space() {
2918 let (mut view, _) = page_selection_view();
2919 let stored = texts(&view);
2920 let _ = view.editor.take_ops().unwrap();
2921 let _ = view.insert_space();
2922 let _ = view.pointer_moved([400.0, 150.0]).unwrap();
2923 let _ = view.key(&Key::Named(NamedKey::Escape), None).unwrap();
2924 assert!(!view.inserting_space());
2925 let _ = view.insert_space();
2926 let _ = view.pointer_pressed(Instant::now()).unwrap();
2927 let _ = view.pointer_moved([400.0, 250.0]).unwrap();
2928 let _ = view.key(&Key::Named(NamedKey::Escape), None).unwrap();
2929 let _ = view.pointer_released().unwrap();
2930 assert!(!view.inserting_space());
2931 assert_eq!(texts(&view), stored);
2932 assert!(view.editor.take_ops().unwrap().is_empty());
2933}
2934
2935/// Rule lines draw as OneNote 2010 draws them: horizontal lines from the margin origin
2936/// down, a margin line 1/96 inch left of it, and grid lines through it either way.
2937#[test]
2938fn rule_lines_start_at_the_margin_origin() {
2939 // Each line's axis and the centre across it, to a hundredth of a point.
2940 let lines = |lines| {
2941 super::rule_primitives(
2942 Some(lines),
2943 [36.0, 14.4],
2944 [0.0, 0.0, 100.0, 100.0],
2945 0.5,
2946 Paper::WHITE,
2947 )
2948 .into_iter()
2949 .map(|primitive| match primitive {
2950 Primitive::Rect {
2951 rect: [x0, y0, x1, y1],
2952 ..
2953 } => {
2954 let vertical = y1 - y0 > x1 - x0;
2955 let center = if vertical { x0 + x1 } else { y0 + y1 } / 2.0;
2956 (vertical, (center * 100.0).round() / 100.0)
2957 }
2958 _ => panic!("rule lines are rectangles"),
2959 })
2960 .collect::<Vec<_>>()
2961 };
2962 let [(_, standard), (_, small)] = [
2963 crate::template::RULE_LINES[2],
2964 crate::template::RULE_LINES[4],
2965 ];
2966 assert_eq!(
2967 lines(standard),
2968 [(false, 14.4), (false, 47.52), (false, 80.64), (true, 35.25)]
2969 );
2970 let vertical: Vec<f32> = lines(small)
2971 .into_iter()
2972 .filter_map(|(vertical, at)| vertical.then_some(at))
2973 .collect();
2974 assert_eq!(
2975 vertical,
2976 [0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 96.0]
2977 );
2978}
2979
2980/// Template art the Page Color menu gives draws in the frame after, over the rule lines as
2981/// OneNote's opaque pictures hide them, and follows undo and redo without a reload.
2982#[test]
2983fn art_draws_at_once_over_the_rule_lines_and_follows_undo() {
2984 let mut engine = TextEngine::default();
2985 let outline = TextOutline::new(
2986 &mut engine,
2987 TextDocument::new(vec![Paragraph::new("Notes".into(), Format::default())]).unwrap(),
2988 300.0,
2989 [300.0, 100.0],
2990 )
2991 .unwrap()
2992 .snapshot();
2993 let page = Page {
2994 identity: None,
2995 title: String::new(),
2996 created: None,
2997 margin_origin: [36.0, 14.4],
2998 rtl: false,
2999 color: None,
3000 rule_lines: Some(crate::template::RULE_LINES[2].1),
3001 definitions: Default::default(),
3002 objects: vec![onestore::page::PageObject::Outline(outline)],
3003 };
3004 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
3005 let mut view = PageView::new(
3006 editor,
3007 engine,
3008 Some((scene, [0.0; 2])),
3009 [800, 600],
3010 1.0,
3011 Duration::from_millis(500),
3012 );
3013 let ivy = crate::template::find("Ivy").unwrap();
3014 let art = ivy.pictures().unwrap();
3015 let [x, y] = ivy.art[0].position;
3016 let [width, height] = ivy.art[0].size.unwrap();
3017 let drawn = |view: &mut PageView, paper: Paper| {
3018 view.update_pictures(paper, std::task::Waker::noop());
3019 let primitives = view.primitives(TextColors { paper, ..COLORS }).unwrap();
3020 let backing = primitives.iter().position(|primitive| {
3021 matches!(primitive, Primitive::Rect { rect, color }
3022 if *rect == [x, y, x + width, y + height] && *color == paper.color)
3023 });
3024 let image = primitives
3025 .iter()
3026 .position(|primitive| matches!(primitive, Primitive::Image { .. }));
3027 let lines = primitives.iter().rposition(
3028 |primitive| matches!(primitive, Primitive::Rect { rect, .. } if rect[0] < x),
3029 );
3030 (backing, image, lines)
3031 };
3032 let _ = view
3033 .set_paper(None, view.editor.rule_lines(), Some(art))
3034 .unwrap();
3035 let (backing, image, lines) = drawn(&mut view, Paper::WHITE);
3036 assert!(
3037 lines < backing && backing < image,
3038 "{lines:?} {backing:?} {image:?}"
3039 );
3040 let dark = Paper {
3041 color: [0.0137, 0.0144, 0.0159, 1.0],
3042 ink: [0.791, 0.791, 0.791, 1.0],
3043 };
3044 // Dark paper hides the lines in its own colour while its recolouring is made.
3045 assert!(matches!(drawn(&mut view, dark), (Some(_), None, _)));
3046 let _ = view.undo(false).unwrap();
3047 assert!(matches!(drawn(&mut view, Paper::WHITE), (None, None, _)));
3048 let _ = view.undo(true).unwrap();
3049 assert!(matches!(
3050 drawn(&mut view, Paper::WHITE),
3051 (Some(_), Some(_), _)
3052 ));
3053}
3054
3055#[test]
3056fn an_attached_file_selects_opens_on_a_double_click_and_deletes() {
3057 let (mut view, _) = picture_view();
3058 let now = Instant::now();
3059 let file = onestore::page::Attachment {
3060 id: onestore::page::text::new_id().unwrap(),
3061 filename: "notes.txt".into(),
3062 source_path: None,
3063 size: Some([24.0, 24.0]),
3064 layout: Default::default(),
3065 bytes: Some(Arc::from(b"notes".as_slice())),
3066 preview: None,
3067 recording: None,
3068 tags: Vec::new(),
3069 };
3070 view.editor
3071 .move_selection(&mut view.engine, Movement::DocumentEnd, false)
3072 .unwrap();
3073 assert!(view.insert_attachment(file.clone()).unwrap().changed);
3074 let stored = view.editor.attachment(file.id).unwrap().clone();
3075 // OneNote draws a broken picture for a file stored without its icon.
3076 assert!(stored.preview.as_ref().unwrap().starts_with(b"\x89PNG"));
3077 view.primitives(COLORS).unwrap();
3078
3079 let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap();
3080 let center = [(x0 + x1) / 2.0, (y0 + y1) / 2.0];
3081 click(&mut view, center, now);
3082 assert_eq!(view.object_focus(), Some(ObjectFocus::File(file.id)));
3083 assert!(!view.accepts_text());
3084 view.primitives(COLORS).unwrap();
3085 let _ = view.pointer_moved(center).unwrap();
3086 assert_eq!(
3087 view.pointer_pressed(now + Duration::from_millis(100))
3088 .unwrap()
3089 .request,
3090 Some(Request::OpenAttachment(stored.clone()))
3091 );
3092 let _ = view.pointer_released().unwrap();
3093 let (_, context) = view.context().unwrap().unwrap();
3094 assert_eq!(context.attachment, Some(stored));
3095
3096 assert!(
3097 view.key(&Key::Named(NamedKey::Delete), None)
3098 .unwrap()
3099 .changed
3100 );
3101 assert!(view.editor.attachment(file.id).is_none());
3102 assert!(view.accepts_text());
3103}
3104
3105/// OneNote 2010 puts a file attached or dropped on blank page on the page there, where it
3106/// selects, drags on the grid, opens and deletes as one (`corpus/attachment-floating`).
3107#[test]
3108fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() {
3109 use onestore::op::PageOp;
3110 let mut engine = TextEngine::default();
3111 let document =
3112 TextDocument::new(vec![Paragraph::new("Text".into(), Default::default())]).unwrap();
3113 let outline = TextOutline::new(&mut engine, document, 240.0, [36.0, 36.0]).unwrap();
3114 let page = Page {
3115 title: String::new(),
3116 identity: None,
3117 created: None,
3118 margin_origin: [36.0, 14.4],
3119 rtl: false,
3120 color: None,
3121 rule_lines: None,
3122 definitions: Default::default(),
3123 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
3124 };
3125 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
3126 let mut view = PageView::new(
3127 editor,
3128 engine,
3129 Some((scene, [0.0; 2])),
3130 [800, 600],
3131 1.0,
3132 Duration::from_millis(500),
3133 );
3134 view.viewport.scale = 1.0;
3135 view.viewport.origin = [0.0; 2];
3136 let _ = view.editor.take_ops().unwrap();
3137 let file = onestore::page::Attachment {
3138 id: onestore::page::text::new_id().unwrap(),
3139 filename: "float.txt".into(),
3140 source_path: None,
3141 size: Some([24.0, 24.0]),
3142 layout: Default::default(),
3143 bytes: Some(Arc::from(b"float".as_slice())),
3144 preview: None,
3145 recording: None,
3146 tags: Vec::new(),
3147 };
3148 let _ = view.drop_attachment([400.0, 400.0], file.clone()).unwrap();
3149 assert_eq!(view.editor.outlines().len(), 1);
3150 let placed = |view: &PageView| {
3151 view.editor
3152 .page()
3153 .unwrap()
3154 .objects
3155 .into_iter()
3156 .find_map(|object| match object {
3157 onestore::page::PageObject::Attachment(file) => Some(file),
3158 _ => None,
3159 })
3160 };
3161 // At the caret a click there places, on the 18 pt grid from the margin origin.
3162 let stored = placed(&view).unwrap();
3163 assert_eq!(
3164 [stored.layout.x, stored.layout.y],
3165 [Some(396.0), Some(392.4)]
3166 );
3167 assert_eq!(stored.layout.max_width, Some(54.0));
3168 assert!(matches!(
3169 &view.editor.take_ops().unwrap()[..],
3170 [PageOp::Add { object: onestore::page::PageObject::Attachment(added), before: None }]
3171 if added.id == file.id
3172 ));
3173 assert!(
3174 view.primitives(COLORS)
3175 .unwrap()
3176 .iter()
3177 .any(|primitive| matches!(primitive, Primitive::Text { .. }))
3178 );
3179
3180 let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap();
3181 let center = [(x0 + x1) / 2.0, (y0 + y1) / 2.0];
3182 let now = Instant::now();
3183 let _ = view.pointer_moved(center).unwrap();
3184 let _ = view.pointer_pressed(now).unwrap();
3185 assert_eq!(view.object_focus(), Some(ObjectFocus::File(file.id)));
3186 let _ = view
3187 .pointer_moved([center[0] + 100.0, center[1] + 50.0])
3188 .unwrap();
3189 view.primitives(COLORS).unwrap();
3190 let _ = view.pointer_released().unwrap();
3191 let moved = placed(&view).unwrap();
3192 assert_eq!([moved.layout.x, moved.layout.y], [Some(504.0), Some(446.4)]);
3193 assert!(matches!(
3194 &view.editor.take_ops().unwrap()[..],
3195 [PageOp::Outline { object, .. }] if *object == file.id
3196 ));
3197 let (_, context) = view.context().unwrap().unwrap();
3198 assert_eq!(context.attachment.map(|found| found.id), Some(file.id));
3199
3200 let _ = view.undo(false).unwrap();
3201 assert_eq!(placed(&view).unwrap().layout.x, Some(396.0));
3202 let _ = view.undo(true).unwrap();
3203 assert_eq!(placed(&view).unwrap().layout.x, Some(504.0));
3204 let _ = view.editor.take_ops().unwrap();
3205
3206 let [x0, y0, x1, y1] = view.editor.attachment_rect(file.id).unwrap();
3207 click(
3208 &mut view,
3209 [(x0 + x1) / 2.0, (y0 + y1) / 2.0],
3210 now + Duration::from_secs(1),
3211 );
3212 let _ = view.key(&Key::Named(NamedKey::Delete), None).unwrap();
3213 assert!(placed(&view).is_none());
3214 assert!(matches!(
3215 &view.editor.take_ops().unwrap()[..],
3216 [PageOp::Delete { object }] if *object == file.id
3217 ));
3218 let _ = view.undo(false).unwrap();
3219 assert_eq!(placed(&view).unwrap().layout.x, Some(504.0));
3220 view.primitives(COLORS).unwrap();
3221}
3222
3223/// Words the fake dictionary misses are marked red under their text once checked, except
3224/// the word still being typed; the context menu offers the dictionary's corrections, and
3225/// taking one is one edit and one undo step.
3226#[test]
3227fn misspelled_words_are_marked_and_corrected_in_one_edit() {
3228 struct Wake(std::sync::mpsc::Sender<()>);
3229 impl std::task::Wake for Wake {
3230 fn wake(self: Arc<Self>) {
3231 let _ = self.0.send(());
3232 }
3233 }
3234 let mut engine = TextEngine::default();
3235 let document = TextDocument::new(vec![Paragraph::new(
3236 "Ths sentence has the typos".into(),
3237 Default::default(),
3238 )])
3239 .unwrap();
3240 let outline = TextOutline::new(&mut engine, document, 400.0, [36.0, 36.0]).unwrap();
3241 let editor = CanvasEditor::from_page(
3242 Page {
3243 title: String::new(),
3244 identity: None,
3245 created: None,
3246 margin_origin: [36.0, 14.4],
3247 rtl: false,
3248 color: None,
3249 rule_lines: None,
3250 definitions: Default::default(),
3251 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
3252 },
3253 &mut engine,
3254 )
3255 .unwrap();
3256 let mut view = PageView::new(editor, engine, None, [800, 600], 1.0, Duration::ZERO);
3257 view.viewport.scale = 1.0;
3258 view.viewport.origin = [0.0; 2];
3259 let (woken, wakes) = std::sync::mpsc::channel();
3260 view.spelling = Some(crate::spelling::Spelling::new(
3261 Box::new(crate::spelling::tests::Fake),
3262 Arc::new(Wake(woken)).into(),
3263 ));
3264 let red = draw::srgb(0xff, 0x00, 0x00);
3265 let marks = |view: &PageView| -> Vec<[f32; 2]> {
3266 view.primitives(COLORS)
3267 .unwrap()
3268 .into_iter()
3269 .filter_map(|primitive| match primitive {
3270 Primitive::Rect { rect, color } if color == red => Some([rect[0], rect[1]]),
3271 _ => None,
3272 })
3273 .collect()
3274 };
3275 assert!(marks(&view).is_empty());
3276 wakes
3277 .recv_timeout(Duration::from_secs(10))
3278 .expect("the spelling thread wakes the host");
3279 let outline = view.editor.active_outline();
3280 let [origin_x, origin_y] = outline.origin();
3281 let [left, right, baseline] = outline.underlines(0, 0..3).unwrap()[0];
3282 let under = marks(&view);
3283 // OneNote's pixels along "Ths": a column each, stepping mid, low, mid, high on the
3284 // three pixel rows from one to three below the baseline.
3285 let [start, end, line] =
3286 [origin_x + left, origin_x + right, origin_y + baseline].map(f32::round);
3287 assert_eq!(under.len(), (end - start) as usize);
3288 for (column, [x, y]) in under.into_iter().enumerate() {
3289 assert_eq!(x, start + column as f32);
3290 assert_eq!(y, line + [2.0, 3.0, 2.0, 1.0][column % 4]);
3291 }
3292
3293 let _ = view
3294 .pointer_moved([origin_x + (left + right) / 2.0, origin_y + baseline - 3.0])
3295 .unwrap();
3296 let (_, context) = view.context().unwrap().unwrap();
3297 let correction = context.spelling.unwrap();
3298 assert_eq!(correction.word, "Ths");
3299 assert_eq!(correction.suggestions, ["This", "Thus"]);
3300 view.editor.take_ops().unwrap();
3301 assert!(view.correct(&correction, "This").unwrap().changed);
3302 assert_eq!(
3303 view.editor.active_outline().shown_text(),
3304 "This sentence has the typos"
3305 );
3306 assert!(!view.editor.take_ops().unwrap().is_empty());
3307 marks(&view);
3308 wakes
3309 .recv_timeout(Duration::from_secs(10))
3310 .expect("the spelling thread wakes the host");
3311 assert!(marks(&view).is_empty());
3312 let _ = view.undo(false).unwrap();
3313 assert_eq!(
3314 view.editor.active_outline().shown_text(),
3315 "Ths sentence has the typos"
3316 );
3317
3318 view.editor
3319 .move_selection(&mut view.engine, Movement::DocumentEnd, false)
3320 .unwrap();
3321 let _ = view.key(&Key::Character(" ".into()), Some(" ")).unwrap();
3322 for letter in ["q", "w", "r", "t"] {
3323 let _ = view
3324 .key(&Key::Character(letter.into()), Some(letter))
3325 .unwrap();
3326 }
3327 marks(&view);
3328 wakes
3329 .recv_timeout(Duration::from_secs(10))
3330 .expect("the spelling thread wakes the host");
3331 let typing = marks(&view).len();
3332 let _ = view.key(&Key::Character(" ".into()), Some(" ")).unwrap();
3333 marks(&view);
3334 wakes
3335 .recv_timeout(Duration::from_secs(10))
3336 .expect("the spelling thread wakes the host");
3337 assert!(marks(&view).len() > typing);
3338}
3339
3340/// See Playback scrolls the note playing into view, as OneNote 2010 does
3341/// (`corpus/recording/native/read/onenote-see-playback-scrolls.png`); the caret stays.
3342#[test]
3343fn the_note_playing_scrolls_into_view() {
3344 let mut engine = TextEngine::default();
3345 let lines = (0..100)
3346 .map(|line| Paragraph::new(format!("Line {line}"), Default::default()))
3347 .collect();
3348 let outline = TextOutline::new(
3349 &mut engine,
3350 TextDocument::new(lines).unwrap(),
3351 240.0,
3352 [36.0, 36.0],
3353 )
3354 .unwrap();
3355 let editor = CanvasEditor::from_page(
3356 Page {
3357 title: String::new(),
3358 identity: None,
3359 created: None,
3360 margin_origin: [36.0, 14.4],
3361 rtl: false,
3362 color: None,
3363 rule_lines: None,
3364 definitions: Default::default(),
3365 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
3366 },
3367 &mut engine,
3368 )
3369 .unwrap();
3370 let mut view = PageView::new(
3371 editor,
3372 engine,
3373 None,
3374 [800, 200],
3375 1.0,
3376 Duration::from_millis(500),
3377 );
3378 view.viewport.scale = 1.0;
3379 view.viewport.origin = [0.0; 2];
3380 let caret = view.editor.selection();
3381 let outline = view.editor.outlines()[0].id;
3382 let at = |offset| TextPosition {
3383 paragraph: 60,
3384 offset,
3385 };
3386 let note: Selection = [at(0), at(7)].into();
3387 let played = Some((outline, note));
3388 assert!(view.set_played(played).unwrap().moved);
3389 let rects = view.editor.outlines()[0].range_rects(note).unwrap();
3390 let top = view.editor.outlines()[0].origin()[1] + rects[0].y0 as f32;
3391 let bottom = view.editor.outlines()[0].origin()[1] + rects[0].y1 as f32;
3392 let shown = [top, bottom].map(|y| y + view.viewport.origin[1]);
3393 assert!(shown[0] >= 0.0 && shown[1] <= 200.0, "{shown:?}");
3394 assert_eq!(view.editor.selection(), caret);
3395 // Playing on within the same note leaves the view where the reader put it.
3396 view.viewport.origin[1] = 0.0;
3397 assert!(!view.set_played(played).unwrap().moved);
3398 assert_eq!(view.viewport.origin[1], 0.0);
3399}
3400
3401/// The Spelling pane walks marked words from the caret in page order, wraps to the page's
3402/// top, and ends once none is left.
3403#[test]
3404fn the_spelling_pane_walks_marked_words_from_the_caret() {
3405 let mut engine = TextEngine::default();
3406 let document = TextDocument::new(
3407 ["the wrng word", "this is qwrt here"]
3408 .map(|text| Paragraph::new(text.into(), Default::default()))
3409 .into(),
3410 )
3411 .unwrap();
3412 let outline = TextOutline::new(&mut engine, document, 400.0, [36.0, 36.0]).unwrap();
3413 let editor = CanvasEditor::from_page(
3414 Page {
3415 title: String::new(),
3416 identity: None,
3417 created: None,
3418 margin_origin: [36.0, 14.4],
3419 rtl: false,
3420 color: None,
3421 rule_lines: None,
3422 definitions: Default::default(),
3423 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
3424 },
3425 &mut engine,
3426 )
3427 .unwrap();
3428 let mut view = PageView::new(editor, engine, None, [800, 600], 1.0, Duration::ZERO);
3429 let spelling = crate::spelling::Spelling::new(
3430 Box::new(crate::spelling::tests::Fake),
3431 std::task::Waker::noop().clone(),
3432 );
3433 view.spelling = Some(spelling.clone());
3434 let caret = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into();
3435 view.editor.select(caret(1, 17)).unwrap();
3436 let next = |view: &mut PageView| {
3437 view.next_correction()
3438 .unwrap()
3439 .map(|(_, correction)| correction.word)
3440 };
3441 assert_eq!(next(&mut view).as_deref(), Some("wrng"));
3442 assert_eq!(
3443 view.editor.selection().positions,
3444 [
3445 TextPosition {
3446 paragraph: 0,
3447 offset: 4
3448 },
3449 TextPosition {
3450 paragraph: 0,
3451 offset: 8
3452 }
3453 ]
3454 );
3455 spelling.ignore("wrng");
3456 assert_eq!(next(&mut view).as_deref(), Some("qwrt"));
3457 spelling.learn("qwrt");
3458 assert_eq!(next(&mut view), None);
3459}
3460
3461/// Page `title` of `section`, with a view of it whose scene holds its pictures.
3462fn scene_view(section: &[u8], title: &str) -> PageView {
3463 let arena = onestore::Arena::default();
3464 let mut section = onestore::Section::open(&arena, section.to_vec()).unwrap();
3465 let (space, ..) = section
3466 .pages()
3467 .unwrap()
3468 .into_iter()
3469 .find(|page| page.1 == title)
3470 .unwrap();
3471 let mut engine = TextEngine::default();
3472 let (scene, editor) = PageScene::from_page(section.page(space).unwrap(), &mut engine).unwrap();
3473 PageView::new(
3474 editor,
3475 engine,
3476 Some((scene, [0.0; 2])),
3477 [800, 600],
3478 1.0,
3479 Duration::from_millis(500),
3480 )
3481}
3482
3483fn device(view: &PageView, point: [f32; 2]) -> [f32; 2] {
3484 [0, 1].map(|axis| point[axis] * view.viewport.scale + view.viewport.origin[axis])
3485}
3486
3487/// A click selects a picture with a link and Ctrl+click (Command on macOS) follows it, as in
3488/// OneNote 2010, whose tooltip says so (`corpus/picture-link`).
3489#[test]
3490fn ctrl_click_follows_a_pictures_link() {
3491 let mut view = scene_view(
3492 include_bytes!("../../../../corpus/m6/native-features-01/notebook/Features.one"),
3493 "Image png",
3494 );
3495 let (id, [x, y], [width, height]) = view
3496 .editor
3497 .objects
3498 .iter()
3499 .find_map(|object| match object {
3500 crate::editor::page::Content::Image(image) => Some(image.id),
3501 _ => None,
3502 })
3503 .and_then(|id| {
3504 let (origin, size) = view.editor.image_placement(id)?;
3505 Some((id, origin, size))
3506 })
3507 .unwrap();
3508 assert_eq!(
3509 view.editor.picture_link(id),
3510 Some("https://example.invalid/image/png")
3511 );
3512 let _ = view
3513 .pointer_moved(device(&view, [x + width / 2.0, y + height / 2.0]))
3514 .unwrap();
3515 let response = view.pointer_pressed(Instant::now()).unwrap();
3516 let _ = view.pointer_released().unwrap();
3517 assert_eq!(response.request, None);
3518 assert_eq!(view.object_focus, Some(ObjectFocus::Image(id)));
3519 let _ = view
3520 .modifiers_changed(Modifiers {
3521 control: true,
3522 command: true,
3523 ..Modifiers::default()
3524 })
3525 .unwrap();
3526 assert_eq!(view.cursor(), Cursor::Pointer);
3527 let response = view.pointer_pressed(Instant::now()).unwrap();
3528 let _ = view.pointer_released().unwrap();
3529 assert_eq!(
3530 response.request,
3531 Some(Request::OpenLink(
3532 "https://example.invalid/image/png".into()
3533 ))
3534 );
3535}
3536
3537/// A click on the check box OneNote 2010 draws beside a tagged picture on the page checks
3538/// it, and undo clears it (`corpus/object-tags`).
3539#[test]
3540fn a_check_box_beside_a_page_picture_checks_it() {
3541 let mut view = scene_view(
3542 include_bytes!("../../../../corpus/object-tags/native/notebook/files.one"),
3543 "Pictures and files",
3544 );
3545 let (id, [x, y], [_, height]) = view
3546 .editor
3547 .objects
3548 .iter()
3549 .find_map(|object| match object {
3550 crate::editor::page::Content::Image(image) if !image.tags.is_empty() => Some(image.id),
3551 _ => None,
3552 })
3553 .and_then(|id| {
3554 let (origin, size) = view.editor.image_placement(id)?;
3555 Some((id, origin, size))
3556 })
3557 .unwrap();
3558 let status = |view: &PageView| {
3559 view.editor
3560 .objects
3561 .iter()
3562 .find_map(|object| match object {
3563 crate::editor::page::Content::Image(image) if image.id == id => {
3564 Some(image.tags[0].status)
3565 }
3566 _ => None,
3567 })
3568 .unwrap()
3569 };
3570 let _ = view
3571 .pointer_moved(device(&view, [x - 18.0, y + height / 2.0]))
3572 .unwrap();
3573 assert_eq!(view.cursor(), Cursor::Default);
3574 assert!(view.pointer_pressed(Instant::now()).unwrap().changed);
3575 let _ = view.pointer_released().unwrap();
3576 assert_eq!(status(&view) & 1, 1);
3577 assert!(view.editor.undo(&mut view.engine).unwrap());
3578 assert_eq!(status(&view) & 1, 0);
3579}
3580
3581/// OneNote 2010 resizes a column from its right border: a column-resize pointer over it, the
3582/// table redrawn as it moves, and one edit on release.
3583#[test]
3584fn a_column_border_drags_with_a_resize_pointer_and_stores_on_release() {
3585 let mut engine = TextEngine::default();
3586 let mut editor = CanvasEditor::new(
3587 &mut engine,
3588 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(),
3589 468.0,
3590 )
3591 .unwrap();
3592 editor.insert_table(&mut engine, 1, 2).unwrap();
3593 let mut view = PageView::new(
3594 editor,
3595 engine,
3596 None,
3597 [800, 600],
3598 1.0,
3599 Duration::from_millis(500),
3600 );
3601 let _ = view.editor.take_ops().unwrap();
3602 let outline = view.editor.active_outline();
3603 let cell = outline.shaped().tables[0].cells[0].clone();
3604 let origin = outline.origin();
3605 let (scale, offset) = (view.viewport.scale, view.viewport.origin);
3606 let device = |point: [f32; 2]| [0, 1].map(|axis| point[axis] * scale + offset[axis]);
3607 let border = [
3608 origin[0] + cell.rect[2],
3609 origin[1] + (cell.rect[1] + cell.rect[3]) / 2.0,
3610 ];
3611 let _ = view.pointer_moved(device(border)).unwrap();
3612 assert_eq!(view.cursor(), Cursor::ColResize);
3613 let _ = view.pointer_pressed(Instant::now()).unwrap();
3614 let to = device([border[0] + 50.0, border[1]]);
3615 assert!(view.pointer_moved(to).unwrap().changed);
3616 assert_eq!(view.cursor(), Cursor::ColResize);
3617 view.primitives(COLORS).unwrap();
3618 let onestore::page::ParagraphContent::Table(table) =
3619 &view.editor.active_outline().document().nodes()[0].content
3620 else {
3621 panic!()
3622 };
3623 assert!(!table.columns[0].locked);
3624 let _ = view.pointer_released().unwrap();
3625 let onestore::page::ParagraphContent::Table(table) =
3626 &view.editor.active_outline().document().nodes()[0].content
3627 else {
3628 panic!()
3629 };
3630 assert!(table.columns[0].locked);
3631 assert!((table.columns[0].width - (37.11 + 50.0)).abs() < 0.01);
3632 assert_eq!(view.editor.take_ops().unwrap().len(), 1);
3633}
3634
3635/// The text of what a response asks the host to copy.
3636fn copied(response: Response) -> Option<String> {
3637 match response.request {
3638 Some(Request::Copy(clip)) => Some(clip.text()),
3639 _ => None,
3640 }
3641}