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