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